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:

  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
'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

ClientImportUse Case
authActionClient@kit/next/safe-actionRequires authenticated user (most common)
publicActionClient@kit/next/safe-actionNo auth required (contact forms, etc.)
captchaActionClient@kit/next/safe-actionRequires 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

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>
);
}

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 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

// 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

// 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)

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 verification
export 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 optionv3 equivalent
{ auth: true } (default)authActionClient
{ auth: false }publicActionClient
{ captcha: true }captchaActionClient

Next Steps