Server Actions for Data Mutations
Use Server Actions to handle form submissions and data mutations in MakerKit. Covers the enhanceAction utility, validation, authentication, revalidation, and captcha protection.
Server Actions are async functions marked with 'use server' that run on the server but can be called directly from client components. They handle form submissions, data mutations, and any operation that modifies your database. MakerKit's enhanceAction utility adds authentication, Zod validation, and optional captcha verification with zero boilerplate. Tested with Next.js 16 and React 19.
Use Server Actions for any mutation: form submissions, button clicks that create/update/delete data, and operations needing server-side validation. Use Route Handlers only for webhooks and external API access.
Basic Server Action
A Server Action is any async function in a file marked with 'use server':
'use server';import { getSupabaseServerClient } from '@kit/supabase/server-client';export async function createTask(formData: FormData) { const supabase = getSupabaseServerClient(); const title = formData.get('title') as string; const { error } = await supabase.from('tasks').insert({ title }); if (error) { return { success: false, error: error.message }; } return { success: true };}This works, but lacks validation, authentication, and proper error handling. The enhanceAction utility solves these problems.
Using enhanceAction
The enhanceAction utility wraps your Server Action with:
- Authentication - Verifies the user is logged in (enabled by default)
- Validation - Validates input against a Zod schema
- Captcha - Verifies Cloudflare Turnstile token (optional)
- Monitoring - Reports errors to your monitoring provider
'use server';import { z } from 'zod';import { enhanceAction } from '@kit/next/actions';import { getSupabaseServerClient } from '@kit/supabase/server-client';const CreateTaskSchema = z.object({ title: z.string().min(1, 'Title is required').max(200), description: z.string().optional(), accountId: z.string().uuid(),});export const createTask = enhanceAction( async (data, user) => { // data is typed and validated // user is the authenticated user (or undefined if auth: false) const supabase = getSupabaseServerClient(); const { error } = await supabase.from('tasks').insert({ title: data.title, description: data.description, account_id: data.accountId, created_by: user.id, }); if (error) { throw new Error('Failed to create task'); } return { success: true }; }, { schema: CreateTaskSchema, });Configuration Options
enhanceAction(handler, { // Zod schema for input validation schema: MySchema, // Require authentication (default: true) auth: true, // Require captcha verification (default: false) captcha: false,});Disabling Authentication
For public actions (like contact forms), disable auth:
export const submitContactForm = enhanceAction( async (data) => { // user parameter will be undefined await sendEmail(data); return { success: true }; }, { schema: ContactFormSchema, auth: false, // Allow unauthenticated users captcha: true, // But require captcha for bot protection });Calling Server Actions from Components
With Forms
The simplest approach uses React's form action:
'use client';import { createTask } from './actions';export function CreateTaskForm({ accountId }: { accountId: string }) { return ( <form action={createTask}> <input type="hidden" name="accountId" value={accountId} /> <input type="text" name="title" placeholder="Task title" required /> <button type="submit">Create Task</button> </form> );}With useActionState (React 19)
For better control over loading and error states:
'use client';import { useActionState } from 'react';import { createTask } from './actions';export function CreateTaskForm({ accountId }: { accountId: string }) { const [state, formAction, isPending] = useActionState(createTask, null); return ( <form action={formAction}> <input type="hidden" name="accountId" value={accountId} /> <input type="text" name="title" placeholder="Task title" /> {state?.error && ( <p className="text-destructive">{state.error}</p> )} <button type="submit" disabled={isPending}> {isPending ? 'Creating...' : 'Create Task'} </button> </form> );}Direct Function Calls
Call Server Actions directly for more complex scenarios:
'use client';import { useState, useTransition } from 'react';import { createTask } from './actions';export function CreateTaskButton({ accountId }: { accountId: string }) { const [isPending, startTransition] = useTransition(); const [error, setError] = useState<string | null>(null); const handleClick = () => { startTransition(async () => { try { const result = await createTask({ title: 'New Task', accountId, }); if (!result.success) { setError('Failed to create task'); } } catch (e) { setError('An unexpected error occurred'); } }); }; return ( <> <button onClick={handleClick} disabled={isPending}> {isPending ? 'Creating...' : 'Quick Add Task'} </button> {error && <p className="text-destructive">{error}</p>} </> );}Revalidating Data
After mutations, revalidate cached data so the UI reflects changes:
Revalidate by Path
'use server';import { revalidatePath } from 'next/cache';export const createTask = enhanceAction( async (data, user) => { const supabase = getSupabaseServerClient(); await supabase.from('tasks').insert({ /* ... */ }); // Revalidate the tasks page revalidatePath('/tasks'); // Or revalidate with layout revalidatePath('/tasks', 'layout'); return { success: true }; }, { schema: CreateTaskSchema });Revalidate by Tag
For more granular control, use cache tags:
'use server';import { revalidateTag } from 'next/cache';export const updateTask = enhanceAction( async (data, user) => { const supabase = getSupabaseServerClient(); await supabase.from('tasks').update(data).eq('id', data.id); // Revalidate all queries tagged with 'tasks' revalidateTag('tasks'); // Or revalidate specific task revalidateTag(`task-${data.id}`); return { success: true }; }, { schema: UpdateTaskSchema });Redirecting After Mutation
'use server';import { redirect } from 'next/navigation';export const createTask = enhanceAction( async (data, user) => { const supabase = getSupabaseServerClient(); const { data: task } = await supabase .from('tasks') .insert({ /* ... */ }) .select('id') .single(); // Redirect to the new task redirect(`/tasks/${task.id}`); }, { schema: CreateTaskSchema });Error Handling
Returning Errors
Return structured errors for the client to handle:
'use server';export const createTask = enhanceAction( async (data, user) => { const supabase = getSupabaseServerClient(); // Check for duplicate title const { data: existing } = await supabase .from('tasks') .select('id') .eq('title', data.title) .eq('account_id', data.accountId) .single(); if (existing) { return { success: false, error: 'A task with this title already exists', }; } const { error } = await supabase.from('tasks').insert({ /* ... */ }); if (error) { // Log for debugging, return user-friendly message console.error('Failed to create task:', error); return { success: false, error: 'Failed to create task. Please try again.', }; } revalidatePath('/tasks'); return { success: true }; }, { schema: CreateTaskSchema });Throwing Errors
For unexpected errors, throw to trigger error boundaries:
'use server';export const deleteTask = enhanceAction( async (data, user) => { const supabase = getSupabaseServerClient(); const { error } = await supabase .from('tasks') .delete() .eq('id', data.taskId) .eq('created_by', user.id); // Ensure ownership if (error) { // This will be caught by error boundaries // and reported to your monitoring provider throw new Error('Failed to delete task'); } revalidatePath('/tasks'); return { success: true }; }, { schema: DeleteTaskSchema });Captcha Protection
For sensitive actions, add Cloudflare Turnstile captcha verification:
Server Action Setup
'use server';import { z } from 'zod';import { enhanceAction } from '@kit/next/actions';const TransferFundsSchema = z.object({ amount: z.number().positive(), toAccountId: z.string().uuid(), captchaToken: z.string(), // Required when captcha: true});export const transferFunds = enhanceAction( async (data, user) => { // Captcha is verified before this runs // ... transfer logic }, { schema: TransferFundsSchema, captcha: true, // Enables captcha verification });Client Component with Captcha
'use client';import { useCaptcha } from '@kit/auth/captcha/client';import { transferFunds } from './actions';export function TransferForm({ captchaSiteKey }: { captchaSiteKey: string }) { const captcha = useCaptcha({ siteKey: captchaSiteKey }); const handleSubmit = async (formData: FormData) => { try { const result = await transferFunds({ amount: Number(formData.get('amount')), toAccountId: formData.get('toAccountId') as string, captchaToken: captcha.token, }); if (result.success) { // Handle success } } finally { // Always reset captcha after submission captcha.reset(); } }; return ( <form action={handleSubmit}> <input type="number" name="amount" placeholder="Amount" /> <input type="text" name="toAccountId" placeholder="Recipient" /> {/* Render captcha widget */} {captcha.field} <button type="submit">Transfer</button> </form> );}See Captcha Protection for detailed setup instructions.
Real-World Example: Team Settings
Here's a complete example of Server Actions for team management:
// lib/server/team-actions.ts'use server';import { z } from 'zod';import { revalidatePath } from 'next/cache';import { enhanceAction } from '@kit/next/actions';import { getSupabaseServerClient } from '@kit/supabase/server-client';import { getLogger } from '@kit/shared/logger';const UpdateTeamSchema = z.object({ teamId: z.string().uuid(), name: z.string().min(2).max(50), slug: z.string().min(2).max(30).regex(/^[a-z0-9-]+$/),});const InviteMemberSchema = z.object({ teamId: z.string().uuid(), email: z.string().email(), role: z.enum(['member', 'admin']),});const RemoveMemberSchema = z.object({ teamId: z.string().uuid(), userId: z.string().uuid(),});export const updateTeam = enhanceAction( async (data, user) => { const logger = await getLogger(); const supabase = getSupabaseServerClient(); logger.info({ teamId: data.teamId, userId: user.id }, 'Updating team'); // Check if slug is taken const { data: existing } = await supabase .from('accounts') .select('id') .eq('slug', data.slug) .neq('id', data.teamId) .single(); if (existing) { return { success: false, error: 'This URL is already taken', field: 'slug', }; } const { error } = await supabase .from('accounts') .update({ name: data.name, slug: data.slug }) .eq('id', data.teamId); if (error) { logger.error({ error, teamId: data.teamId }, 'Failed to update team'); return { success: false, error: 'Failed to update team' }; } revalidatePath(`/home/${data.slug}/settings`); return { success: true }; }, { schema: UpdateTeamSchema });export const inviteMember = enhanceAction( async (data, user) => { const supabase = getSupabaseServerClient(); // Check if already a member const { data: existing } = await supabase .from('account_members') .select('id') .eq('account_id', data.teamId) .eq('user_email', data.email) .single(); if (existing) { return { success: false, error: 'User is already a member' }; } // Create invitation const { error } = await supabase.from('invitations').insert({ account_id: data.teamId, email: data.email, role: data.role, invited_by: user.id, }); if (error) { return { success: false, error: 'Failed to send invitation' }; } revalidatePath(`/home/[account]/settings/members`, 'page'); return { success: true }; }, { schema: InviteMemberSchema });export const removeMember = enhanceAction( async (data, user) => { const supabase = getSupabaseServerClient(); // Prevent removing yourself if (data.userId === user.id) { return { success: false, error: 'You cannot remove yourself' }; } const { error } = await supabase .from('account_members') .delete() .eq('account_id', data.teamId) .eq('user_id', data.userId); if (error) { return { success: false, error: 'Failed to remove member' }; } revalidatePath(`/home/[account]/settings/members`, 'page'); return { success: true }; }, { schema: RemoveMemberSchema });Common Mistakes
Forgetting to Revalidate
// WRONG: Data changes but UI doesn't updateexport const updateTask = enhanceAction(async (data, user) => { await supabase.from('tasks').update(data).eq('id', data.id); return { success: true };}, { schema: UpdateTaskSchema });// RIGHT: Revalidate after mutationexport const updateTask = enhanceAction(async (data, user) => { await supabase.from('tasks').update(data).eq('id', data.id); revalidatePath('/tasks'); return { success: true };}, { schema: UpdateTaskSchema });Using try/catch Incorrectly
// WRONG: Swallowing errors silentlyexport const createTask = enhanceAction(async (data, user) => { try { await supabase.from('tasks').insert(data); } catch (e) { // Error is lost, user sees "success" } return { success: true };}, { schema: CreateTaskSchema });// RIGHT: Return or throw errorsexport const createTask = enhanceAction(async (data, user) => { const { error } = await supabase.from('tasks').insert(data); if (error) { return { success: false, error: 'Failed to create task' }; } return { success: true };}, { schema: CreateTaskSchema });Not Validating Ownership
// WRONG: Any user can delete any taskexport const deleteTask = enhanceAction(async (data, user) => { await supabase.from('tasks').delete().eq('id', data.taskId);}, { schema: DeleteTaskSchema });// RIGHT: Verify ownership (or use RLS)export const deleteTask = enhanceAction(async (data, user) => { const { error } = await supabase .from('tasks') .delete() .eq('id', data.taskId) .eq('created_by', user.id); // User can only delete their own tasks if (error) { return { success: false, error: 'Task not found or access denied' }; } return { success: true };}, { schema: DeleteTaskSchema });Next Steps
- Route Handlers - For webhooks and external APIs
- Captcha Protection - Protect sensitive actions
- React Query - Combine with optimistic updates