--- status: "published" title: "Server Actions for Data Mutations" label: "Server Actions" description: "Use Server Actions to handle form submissions and data mutations in MakerKit. Covers authActionClient, validation, authentication, revalidation, and captcha protection." order: 1 --- 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. {% callout title="When to use Server Actions" %} **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. {% /callout %} ## Basic Server Action A Server Action is any async function in a file marked with `'use server'`: ```tsx '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: 1. **Authentication** - Verifies the user is logged in 2. **Validation** - Validates input against a Zod schema 3. **Type Safety** - Full end-to-end type inference ```tsx '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`: ```tsx '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: ```tsx '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) => { e.preventDefault(); const formData = new FormData(e.currentTarget); execute({ title: formData.get('title') as string, accountId, }); }; return (
); } ``` ### With useActionState (React 19) `useActionState` works with plain Server Actions (not `next-safe-action` wrapped actions). Define a plain action for this pattern: ```tsx // 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 }; } ``` ```tsx 'use client'; import { useActionState } from 'react'; import { createTaskFormAction } from './actions'; export function CreateTaskForm({ accountId }: { accountId: string }) { const [state, formAction, isPending] = useActionState(createTaskFormAction, null); return (
{state?.error && (

{state.error}

)}
); } ``` {% alert type="info" %} `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. {% /alert %} ### Direct Function Calls Call Server Actions directly for more complex scenarios: ```tsx '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(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 ( <> {error &&

{error}

} ); } ``` ## Revalidating Data After mutations, revalidate cached data so the UI reflects changes: ### Revalidate by Path ```tsx '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: ```tsx '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 ```tsx '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: ```tsx '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: ```tsx '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 ```tsx '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 ```tsx '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) => { e.preventDefault(); const formData = new FormData(e.currentTarget); execute({ amount: Number(formData.get('amount')), toAccountId: formData.get('toAccountId') as string, captchaToken: captcha.token, }); }; return (
{/* Render captcha widget */} {captcha.field}
); } ``` See [Captcha Protection](captcha-protection) for detailed setup instructions. ## Real-World Example: Team Settings Here's a complete example of Server Actions for team management: ```tsx // 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 ```tsx // WRONG: Data changes but UI doesn't update export 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 mutation export 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 ```tsx // WRONG: Swallowing errors silently export 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 errors export 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 ```tsx // WRONG: Any user can delete any task export 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) {% callout title="Deprecated" %} `enhanceAction` is still available but deprecated. Use `authActionClient`, `publicActionClient`, or `captchaActionClient` for new code. {% /callout %} The `enhanceAction` utility from `@kit/next/actions` wraps a Server Action with authentication, Zod validation, and optional captcha verification: ```tsx '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 verification export const sensitiveAction = enhanceAction( async (data, user) => { // captcha verified before this runs }, { schema: MySchema, captcha: true } ); ``` ### Configuration Options ```tsx 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 ```tsx // 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](route-handlers) - For webhooks and external APIs - [Captcha Protection](captcha-protection) - Protect sensitive actions - [React Query](react-query) - Combine with optimistic updates