Version 3 of the kit: - Radix UI replaced with Base UI (using the Shadcn UI patterns) - next-intl replaces react-i18next - enhanceAction deprecated; usage moved to next-safe-action - main layout now wrapped with [locale] path segment - Teams only mode - Layout updates - Zod v4 - Next.js 16.2 - Typescript 6 - All other dependencies updated - Removed deprecated Edge CSRF - Dynamic Github Action runner
817 lines
21 KiB
Plaintext
817 lines
21 KiB
Plaintext
---
|
|
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<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:
|
|
|
|
```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 (
|
|
<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>
|
|
);
|
|
}
|
|
```
|
|
|
|
{% 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<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
|
|
|
|
```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<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](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
|