Using Actions in React Router Supabase Turbo
Learn how to use the Actions in React Router Supabase Turbo
Actions in Makerkit Supabase Turbo follow React Router's pattern, letting you perform server-side operations directly from your components. They provide a clean way to handle form submissions, data mutations, and other server interactions.
What Are Actions?
Actions are server-side functions that can be triggered from the client. They handle operations like:
- Form submissions
- Data mutations (create, update, delete)
- Authentication flows
- Server-side business logic
Basic Structure of an Action
Actions are exported from route files and receive a parameter object with:
request
: The incoming request object- Other context information from React Router
export const action = async (args: Route.ActionArgs) => { // Parse incoming data const json = SomeSchema.parse(await args.request.json()); // Get Supabase client const client = getSupabaseServerClient(args.request); // Perform different operations based on the "intent" switch (json.intent) { case 'some-action': return doSomething({ client, data: json.payload }); default: return new Response('Invalid action', { status: 400 }); }};
Using Actions with Forms
From your components, you can trigger actions using React Router's useFetcher
:
'use client';import { useFetcher } from 'react-router';import { useForm } from 'react-hook-form';import { zodResolver } from '@hookform/resolvers/zod';function MyForm() { const form = useForm({ resolver: zodResolver(MyFormSchema), defaultValues: { // Your default values }, }); const fetcher = useFetcher(); const pending = fetcher.state === 'submitting'; return ( <form onSubmit={form.handleSubmit((data) => { return fetcher.submit( { intent: 'my-action-intent', payload: data, }, { encType: 'application/json', method: 'POST', }, ); })} > {/* Form fields */} <Button type="submit" disabled={pending}> {pending ? 'Processing...' : 'Submit'} </Button> </form> );}
Action Patterns in Makerkit
The Intent Pattern
Makerkit uses an "intent" pattern to handle multiple actions from a single route:
// In your route fileexport const action = async (args: Route.ActionArgs) => { const json = ActionsSchema.parse(await args.request.json()); const client = getSupabaseServerClient(args.request); switch (json.intent) { case 'delete-account': return deletePersonalAccountAction({ client, otp: json.payload.otp }); case 'update-profile': return updateProfileAction({ client, data: json.payload }); default: return new Response('Invalid action', { status: 400 }); }};
Creating Reusable Actions
For complex operations, create separate action functions:
// In a separate fileexport const deletePersonalAccountAction = async ({ client, otp,}: { client: SupabaseClient<Database>; otp: string;}) => { // Implementation here};// In your route fileimport { deletePersonalAccountAction } from './actions';export const action = async (args: Route.ActionArgs) => { // ... return deletePersonalAccountAction({ client, otp });};
Data Validation with Zod
Always validate incoming data with Zod:
const DeleteAccountFormSchema = z.object({ otp: z.string().min(1),});// In your actionconst data = DeleteAccountFormSchema.parse(json.payload);
Error Handling
Handle errors gracefully and return appropriate responses:
try { // Action implementation return redirectDocument('/success');} catch (error) { console.error('Action failed:', error); return json( { error: 'Something went wrong' }, { status: 500 } );}
Authentication in Actions
Check authentication status before performing sensitive operations:
const auth = await requireUser(client);if (!auth.data) { return redirectDocument(auth.redirectTo);}const user = auth.data;// Continue with authorized action
Example: Complete Account Deletion Flow
Here's a complete example of the account deletion flow:
- Route file exports the action:
export const action = async (args: Route.ActionArgs) => { const json = ActionsSchema.parse(await args.request.json()); const client = getSupabaseServerClient(args.request); switch (json.intent) { case 'delete-account': return deletePersonalAccountAction({ client, otp: json.payload.otp }); default: return new Response('Invalid action', { status: 400 }); }};
- Component uses the action:
function DeleteAccountForm(props: { email: string }) { const form = useForm({ resolver: zodResolver(DeleteAccountFormSchema), defaultValues: { otp: '', }, }); const fetcher = useFetcher(); const pending = fetcher.state === 'submitting'; return ( <Form {...form}> <form onSubmit={form.handleSubmit((data) => { return fetcher.submit( { intent: 'delete-account', payload: data, }, { encType: 'application/json', method: 'POST', }, ); })} > {/* Form fields */} <Button type="submit" disabled={pending} variant="destructive" > {pending ? 'Deleting Account...' : 'Delete Account'} </Button> </form> </Form> );}
- Implementation of the action:
export const deletePersonalAccountAction = async ({ client, otp,}: { client: SupabaseClient<Database>; otp: string;}) => { const auth = await requireUser(client); if (!auth.data) { return redirectDocument(auth.redirectTo); } const user = auth.data; // Verify OTP const otpApi = createOtpApi(client); const result = await otpApi.verifyToken({ purpose: 'delete-personal-account', userId: user.id, token: otp, }); if (!result.valid) { throw new Error('Invalid OTP'); } // Delete account await deleteAccount(user.id); // Sign out and redirect await client.auth.signOut(); return redirectDocument('/');};
Tips for Effective Actions
- Keep them focused - Each action should do one thing well
- Validate input - Always validate input data with Zod
- Check permissions - Verify the user has permission to perform the action
- Handle errors - Return appropriate error responses
- Use services - Move complex logic to service functions for better organization
- Provide feedback - Return status information the UI can use to show feedback
By following these patterns, you'll be able to create robust, type-safe server actions in your Makerkit application.