Server Actions for Data Mutations
Use Server Actions to handle form submissions and data mutations in MakerKit. Covers authActionClient, 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 authActionClient adds authentication and Zod validation with zero boilerplate, while publicActionClient and captchaActionClient handle public and captcha-protected actions respectively. 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 action clients solve these problems.
Using authActionClient
The authActionClient creates type-safe, validated server actions with built-in authentication:
- Authentication - Verifies the user is logged in
- Validation - Validates input against a Zod schema
- Type Safety - Full end-to-end type inference
'use server';import * as z from 'zod';import { authActionClient } from '@kit/next/safe-action';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 = authActionClient .inputSchema(CreateTaskSchema) .action(async ({ parsedInput: data, ctx: { user } }) => { // data is typed and validated // user is the authenticated user 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 }; });Available Action Clients
| Client | Import | Use Case |
|---|---|---|
authActionClient | @kit/next/safe-action | Requires authenticated user (most common) |
publicActionClient | @kit/next/safe-action | No auth required (contact forms, etc.) |
captchaActionClient | @kit/next/safe-action | Requires CAPTCHA + auth |
Public Actions
For public actions (like contact forms), use publicActionClient:
'use server';import * as z from 'zod';import { publicActionClient } from '@kit/next/safe-action';const ContactFormSchema = z.object({ name: z.string().min(1), email: z.string().email(), message: z.string().min(10),});export const submitContactForm = publicActionClient .inputSchema(ContactFormSchema) .action(async ({ parsedInput: data }) => { // No user context - this is a public action await sendEmail(data); return { success: true }; });Calling Server Actions from Components
With useAction (Recommended)
The useAction hook from next-safe-action/hooks is the primary way to call server actions from client components:
'use client';import { useAction } from 'next-safe-action/hooks';import { createTask } from './actions';export function CreateTaskForm({ accountId }: { accountId: string }) { const { execute, isPending } = useAction(createTask, { onSuccess: ({ data }) => { // Handle success }, onError: ({ error }) => { // Handle error }, }); const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const formData = new FormData(e.currentTarget); execute({ title: formData.get('title') as string, accountId, }); }; return ( <form onSubmit={handleSubmit}> <input type="text" name="title" placeholder="Task title" required /> <button type="submit" disabled={isPending}> {isPending ? 'Creating...' : 'Create Task'} </button> </form> );}With useActionState (React 19)
useActionState works with plain Server Actions (not next-safe-action wrapped actions). Define a plain action for this pattern:
// actions.ts'use server';export async function createTaskFormAction(prevState: unknown, formData: FormData) { const title = formData.get('title') as string; const accountId = formData.get('accountId') as string; // validate and create task... return { success: true };}'use client';import { useActionState } from 'react';import { createTaskFormAction } from './actions';export function CreateTaskForm({ accountId }: { accountId: string }) { const [state, formAction, isPending] = useActionState(createTaskFormAction, 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> );}useActionState expects a plain server action with signature (prevState, formData) => newState. For next-safe-action wrapped actions, use the useAction hook from next-safe-action/hooks instead.
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?.data?.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 * as z from 'zod';import { revalidatePath } from 'next/cache';import { authActionClient } from '@kit/next/safe-action';import { getSupabaseServerClient } from '@kit/supabase/server-client';const CreateTaskSchema = z.object({ title: z.string().min(1), accountId: z.string().uuid(),});export const createTask = authActionClient .inputSchema(CreateTaskSchema) .action(async ({ parsedInput: data, ctx: { 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 }; });Revalidate by Tag
For more granular control, use cache tags:
'use server';import * as z from 'zod';import { revalidateTag } from 'next/cache';import { authActionClient } from '@kit/next/safe-action';import { getSupabaseServerClient } from '@kit/supabase/server-client';const UpdateTaskSchema = z.object({ id: z.string().uuid(), title: z.string().min(1),});export const updateTask = authActionClient .inputSchema(UpdateTaskSchema) .action(async ({ parsedInput: data, ctx: { 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 }; });Redirecting After Mutation
'use server';import * as z from 'zod';import { redirect } from 'next/navigation';import { authActionClient } from '@kit/next/safe-action';import { getSupabaseServerClient } from '@kit/supabase/server-client';const CreateTaskSchema = z.object({ title: z.string().min(1), accountId: z.string().uuid(),});export const createTask = authActionClient .inputSchema(CreateTaskSchema) .action(async ({ parsedInput: data, ctx: { user } }) => { const supabase = getSupabaseServerClient(); const { data: task } = await supabase .from('tasks') .insert({ /* ... */ }) .select('id') .single(); // Redirect to the new task redirect(`/tasks/${task.id}`); });Error Handling
Returning Errors
Return structured errors for the client to handle:
'use server';import * as z from 'zod';import { revalidatePath } from 'next/cache';import { authActionClient } from '@kit/next/safe-action';import { getSupabaseServerClient } from '@kit/supabase/server-client';const CreateTaskSchema = z.object({ title: z.string().min(1), accountId: z.string().uuid(),});export const createTask = authActionClient .inputSchema(CreateTaskSchema) .action(async ({ parsedInput: data, ctx: { 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 }; });Throwing Errors
For unexpected errors, throw to trigger error boundaries:
'use server';import * as z from 'zod';import { revalidatePath } from 'next/cache';import { authActionClient } from '@kit/next/safe-action';import { getSupabaseServerClient } from '@kit/supabase/server-client';const DeleteTaskSchema = z.object({ taskId: z.string().uuid(),});export const deleteTask = authActionClient .inputSchema(DeleteTaskSchema) .action(async ({ parsedInput: data, ctx: { 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 }; });Captcha Protection
For sensitive actions, add Cloudflare Turnstile captcha verification:
Server Action Setup
'use server';import * as z from 'zod';import { captchaActionClient } from '@kit/next/safe-action';const TransferFundsSchema = z.object({ amount: z.number().positive(), toAccountId: z.string().uuid(), captchaToken: z.string(),});export const transferFunds = captchaActionClient .inputSchema(TransferFundsSchema) .action(async ({ parsedInput: data, ctx: { user } }) => { // Captcha is verified before this runs // ... transfer logic });Client Component with Captcha
'use client';import { useAction } from 'next-safe-action/hooks';import { useCaptcha } from '@kit/auth/captcha/client';import { transferFunds } from './actions';export function TransferForm({ captchaSiteKey }: { captchaSiteKey: string }) { const captcha = useCaptcha({ siteKey: captchaSiteKey }); const { execute, isPending } = useAction(transferFunds, { onSuccess: () => { // Handle success }, onSettled: () => { // Always reset captcha after submission captcha.reset(); }, }); const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const formData = new FormData(e.currentTarget); execute({ amount: Number(formData.get('amount')), toAccountId: formData.get('toAccountId') as string, captchaToken: captcha.token, }); }; return ( <form onSubmit={handleSubmit}> <input type="number" name="amount" placeholder="Amount" /> <input type="text" name="toAccountId" placeholder="Recipient" /> {/* Render captcha widget */} {captcha.field} <button type="submit" disabled={isPending}> {isPending ? 'Transferring...' : '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 * as z from 'zod';import { revalidatePath } from 'next/cache';import { authActionClient } from '@kit/next/safe-action';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 = authActionClient .inputSchema(UpdateTeamSchema) .action(async ({ parsedInput: data, ctx: { 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 }; });export const inviteMember = authActionClient .inputSchema(InviteMemberSchema) .action(async ({ parsedInput: data, ctx: { 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 }; });export const removeMember = authActionClient .inputSchema(RemoveMemberSchema) .action(async ({ parsedInput: data, ctx: { 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 }; });Common Mistakes
Forgetting to Revalidate
// WRONG: Data changes but UI doesn't updateexport const updateTask = authActionClient .inputSchema(UpdateTaskSchema) .action(async ({ parsedInput: data }) => { await supabase.from('tasks').update(data).eq('id', data.id); return { success: true }; });// RIGHT: Revalidate after mutationexport const updateTask = authActionClient .inputSchema(UpdateTaskSchema) .action(async ({ parsedInput: data }) => { await supabase.from('tasks').update(data).eq('id', data.id); revalidatePath('/tasks'); return { success: true }; });Using try/catch Incorrectly
// WRONG: Swallowing errors silentlyexport const createTask = authActionClient .inputSchema(CreateTaskSchema) .action(async ({ parsedInput: data }) => { try { await supabase.from('tasks').insert(data); } catch (e) { // Error is lost, user sees "success" } return { success: true }; });// RIGHT: Return or throw errorsexport const createTask = authActionClient .inputSchema(CreateTaskSchema) .action(async ({ parsedInput: data }) => { const { error } = await supabase.from('tasks').insert(data); if (error) { return { success: false, error: 'Failed to create task' }; } return { success: true }; });Not Validating Ownership
// WRONG: Any user can delete any taskexport const deleteTask = authActionClient .inputSchema(DeleteTaskSchema) .action(async ({ parsedInput: data }) => { await supabase.from('tasks').delete().eq('id', data.taskId); });// RIGHT: Verify ownership (or use RLS)export const deleteTask = authActionClient .inputSchema(DeleteTaskSchema) .action(async ({ parsedInput: data, ctx: { 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 }; });Using enhanceAction (Deprecated)
enhanceAction is still available but deprecated. Use authActionClient, publicActionClient, or captchaActionClient for new code.
The enhanceAction utility from @kit/next/actions wraps a Server Action with authentication, Zod validation, and optional captcha verification:
'use server';import * as 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).max(200), accountId: z.string().uuid(),});// Authenticated action (default)export const createTask = enhanceAction( async (data, user) => { const supabase = getSupabaseServerClient(); await supabase.from('tasks').insert({ title: data.title, account_id: data.accountId, created_by: user.id, }); return { success: true }; }, { schema: CreateTaskSchema });// Public action (no auth required)export const submitContactForm = enhanceAction( async (data) => { await sendEmail(data); return { success: true }; }, { schema: ContactFormSchema, auth: false });// With captcha verificationexport const sensitiveAction = enhanceAction( async (data, user) => { // captcha verified before this runs }, { schema: MySchema, captcha: true });Configuration Options
enhanceAction(handler, { schema: MySchema, // Zod schema for input validation auth: true, // Require authentication (default: true) captcha: false, // Require captcha verification (default: false)});Migrating to authActionClient
// Before (enhanceAction)export const myAction = enhanceAction( async (data, user) => { /* ... */ }, { schema: MySchema });// After (authActionClient)export const myAction = authActionClient .inputSchema(MySchema) .action(async ({ parsedInput: data, ctx: { user } }) => { /* ... */ });| enhanceAction option | v3 equivalent |
|---|---|
{ auth: true } (default) | authActionClient |
{ auth: false } | publicActionClient |
{ captcha: true } | captchaActionClient |
Next Steps
- Route Handlers - For webhooks and external APIs
- Captcha Protection - Protect sensitive actions
- React Query - Combine with optimistic updates