Server Actions let you run server-side code directly from React components — no separate API route file, no fetch call, no request/response boilerplate. A form submission can call a server function directly, and the result updates the UI. This is the pattern that replaces the classic POST /api/submit for most form handling.
Here's the complete pattern, including progressive enhancement, error handling, and the loading state management that makes forms feel responsive — the approach used for content management on website image generation patterns.
Defining a Server Action
Server Actions are async functions marked with 'use server'. They can be defined in a separate file or inline in a Server Component.
// app/actions/contact.ts
'use server';
import { revalidatePath } from 'next/cache';
type ContactFormData = {
name: string;
email: string;
message: string;
};
type ActionResult = {
success: boolean;
error?: string;
};
export async function submitContact(
formData: FormData
): Promise<ActionResult> {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// Validate
if (!name || !email || !message) {
return { success: false, error: 'All fields are required' };
}
if (!email.includes('@')) {
return { success: false, error: 'Invalid email address' };
}
try {
await saveToDatabase({ name, email, message });
revalidatePath('/contact');
return { success: true };
} catch (error) {
return { success: false, error: 'Submission failed. Please try again.' };
}
}
The 'use server' directive marks this as a Server Action — Next.js handles the network boundary automatically. The function receives FormData and returns a result.
Basic Form With Server Action
The simplest usage: attach the action directly to a form's action prop.
// app/contact/page.tsx
import { submitContact } from '@/app/actions/contact';
export default function ContactPage() {
return (
<form action={submitContact} className="space-y-4">
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
required
className="block w-full border rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
required
className="block w-full border rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
rows={4}
required
className="block w-full border rounded px-3 py-2"
/>
</div>
<button type="submit">Send Message</button>
</form>
);
}
This works — and it works without JavaScript enabled, because it's a standard HTML form submission. This is progressive enhancement built in.
Adding Loading State and Feedback With useFormState and useFormStatus
The basic form works but doesn't give users feedback. useFormState captures the action's return value; useFormStatus provides the pending state.
// app/contact/ContactForm.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { submitContact } from '@/app/actions/contact';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{pending ? 'Sending...' : 'Send Message'}
</button>
);
}
const initialState = { success: false, error: undefined };
export function ContactForm() {
const [state, formAction] = useFormState(submitContact, initialState);
if (state.success) {
return (
<div className="p-4 bg-green-50 text-green-800 rounded">
Message sent successfully. We'll be in touch.
</div>
);
}
return (
<form action={formAction} className="space-y-4">
{state.error && (
<div className="p-3 bg-red-50 text-red-700 rounded text-sm">
{state.error}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name
</label>
<input
id="name"
name="name"
type="text"
required
className="block w-full border rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="block w-full border rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message
</label>
<textarea
id="message"
name="message"
rows={4}
required
className="block w-full border rounded px-3 py-2"
/>
</div>
<SubmitButton />
</form>
);
}
Note that SubmitButton is a separate component — useFormStatus only works inside a form element, not in the component that renders the form. Moving it to a child component solves this.
Server Actions With Zod Validation
For robust validation, combine Server Actions with Zod:
// app/actions/contact.ts
'use server';
import { z } from 'zod';
const ContactSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters').max(1000),
});
type FormState = {
success: boolean;
error?: string;
fieldErrors?: Record<string, string[]>;
};
export async function submitContact(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const raw = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
};
const result = ContactSchema.safeParse(raw);
if (!result.success) {
return {
success: false,
fieldErrors: result.error.flatten().fieldErrors,
};
}
try {
await saveToDatabase(result.data);
return { success: true };
} catch {
return { success: false, error: 'Something went wrong. Please try again.' };
}
}
The fieldErrors object maps field names to arrays of error messages — useful for displaying inline validation errors next to each field.
Mutations Beyond Forms
Server Actions aren't limited to forms. You can call them from event handlers in Client Components:
'use client';
import { deleteItem } from '@/app/actions/items';
export function DeleteButton({ id }: { id: string }) {
return (
<button
onClick={async () => {
await deleteItem(id);
}}
className="text-red-600 hover:text-red-800"
>
Delete
</button>
);
}
And the action:
// app/actions/items.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function deleteItem(id: string) {
await db.items.delete({ where: { id } });
revalidatePath('/items');
}
revalidatePath tells Next.js to invalidate the cached data for a route after a mutation — so the list updates without a full page reload.
When to Use Server Actions vs API Routes
Server Actions are better for: form submissions, mutations triggered by user interaction, operations that need to revalidate cached data, and when you want progressive enhancement (works without JS).
API Routes are better for: webhook handlers, publicly accessible endpoints that external services call, streaming responses, and when you need fine-grained control over the HTTP response (status codes, headers, streaming).
The mental model: Server Actions are for your own UI. API Routes are for the outside world or situations where HTTP semantics matter.
Summary
Server Actions simplify the form handling pattern significantly. Instead of form → fetch('/api/submit') → handler → response → update state, you get form action → server function → return result. Less code, built-in progressive enhancement, and revalidatePath for cache invalidation after mutations.
The key files: the action function (marked 'use server'), the form component using useFormState for result capture, and a SubmitButton child component for useFormStatus access. That pattern covers the vast majority of form handling in Next.js applications.
Optimistic Updates With useOptimistic
For UI that should feel instant, useOptimistic lets you show the expected result before the server confirms it:
'use client';
import { useOptimistic } from 'react';
import { toggleLike } from '@/app/actions/likes';
type Post = { id: string; liked: boolean; likeCount: number };
export function LikeButton({ post }: { post: Post }) {
const [optimisticPost, setOptimisticPost] = useOptimistic(
post,
(state, liked: boolean) => ({
...state,
liked,
likeCount: liked ? state.likeCount + 1 : state.likeCount - 1,
})
);
const handleLike = async () => {
setOptimisticPost(!optimisticPost.liked);
await toggleLike(post.id);
};
return (
<button onClick={handleLike} className="flex items-center gap-1">
<span>{optimisticPost.liked ? '❤️' : '🤍'}</span>
<span>{optimisticPost.likeCount}</span>
</button>
);
}
The UI updates immediately on click. If the server action fails, the optimistic state reverts automatically. For interactions where users expect instant feedback, this pattern eliminates the perception of latency.
Top comments (0)