Server Actions in Next.js let you run server-side code directly from forms and client components without writing separate API routes. You define a function with 'use server', and Next.js handles the network layer. This simplifies form handling, mutations, and any operation that needs server-side logic.
What are Server Actions?
A Server Action is an async function marked with 'use server'. When called from the client, Next.js:
- Serializes the arguments
- Sends a POST request to the server
- Executes the function on the server
- Returns the result to the client
| Traditional approach | Server Actions |
|---|---|
Create API route in pages/api or app/api |
Write function with 'use server' |
| Write fetch call in client | Pass function to form action or call directly |
| Parse request body | Receive typed arguments |
| Return JSON response | Return values or throw errors |
Server Actions reduce boilerplate and keep related code together.
Creating a Server Action
Mark a file or function with 'use server':
// app/actions.ts
'use server';
export async function submitContact(formData: FormData) {
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 { error: 'All fields are required' };
}
// Save to database, send email, etc.
await db.insert(contacts).values({ name, email, message });
return { success: true };
}
The function runs only on the server. You can use environment variables, database clients, and server-only APIs safely.
Using in a form
Pass the action to the form's action prop:
// app/contact/page.tsx
import { submitContact } from '../actions';
export default function ContactPage() {
return (
<form action={submitContact}>
<label>
Name
<input name="name" required />
</label>
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Message
<textarea name="message" required />
</label>
<button type="submit">Send</button>
</form>
);
}
The form submits to the current URL via POST. Next.js intercepts it, calls the Server Action, and handles the response. No JavaScript required for basic functionality—forms work without JS enabled.
Handling loading states
Use useFormStatus in a component inside the form:
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Sending...' : 'Send'}
</button>
);
}
Use it in the form:
import { submitContact } from '../actions';
import { SubmitButton } from './SubmitButton';
export default function ContactPage() {
return (
<form action={submitContact}>
{/* inputs */}
<SubmitButton />
</form>
);
}
The button shows "Sending..." and disables while the action runs.
Reading action results
Use useActionState to access the action's return value:
'use client';
import { useActionState } from 'react';
import { submitContact } from '../actions';
const initialState = { error: null, success: false };
export default function ContactForm() {
const [state, formAction, pending] = useActionState(
submitContact,
initialState
);
return (
<form action={formAction}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
{state.error && (
<p className="text-red-500">{state.error}</p>
)}
{state.success && (
<p className="text-green-500">Message sent!</p>
)}
<button type="submit" disabled={pending}>
{pending ? 'Sending...' : 'Send'}
</button>
</form>
);
}
The action must return a new state object. The component re-renders with the result.
Validation with Zod
Use a schema library for type-safe validation:
// app/actions.ts
'use server';
import { z } from 'zod';
const ContactSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
export async function submitContact(prevState: any, formData: FormData) {
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
};
const result = ContactSchema.safeParse(rawData);
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
success: false,
};
}
await db.insert(contacts).values(result.data);
return { errors: null, success: true };
}
Display field-level errors:
{state.errors?.name && (
<p className="text-red-500 text-sm">{state.errors.name[0]}</p>
)}
Calling actions from event handlers
You can call Server Actions from any client code, not just forms:
'use client';
import { deleteItem } from '../actions';
export function DeleteButton({ id }: { id: string }) {
async function handleClick() {
const result = await deleteItem(id);
if (result.error) {
alert(result.error);
}
}
return (
<button onClick={handleClick}>
Delete
</button>
);
}
The action:
'use server';
export async function deleteItem(id: string) {
const session = await getSession();
if (!session) {
return { error: 'Unauthorized' };
}
await db.delete(items).where(eq(items.id, id));
revalidatePath('/items');
return { success: true };
}
Revalidating data
After mutations, revalidate cached data:
import { revalidatePath, revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
await db.insert(posts).values({ /* ... */ });
// Revalidate specific path
revalidatePath('/posts');
// Or revalidate by tag
revalidateTag('posts');
}
The next request to that path or tag fetches fresh data.
Redirecting after success
Redirect to a new page after successful mutation:
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
const post = await db.insert(posts).values({ /* ... */ }).returning();
redirect(`/posts/${post.id}`);
}
Call redirect after all server work is complete. It throws internally to trigger navigation.
Optimistic updates
Update UI immediately while the action runs:
'use client';
import { useOptimistic } from 'react';
import { likePost } from '../actions';
export function LikeButton({ postId, initialLikes }: Props) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(current) => current + 1
);
async function handleLike() {
addOptimisticLike(null);
await likePost(postId);
}
return (
<button onClick={handleLike}>
❤️ {optimisticLikes}
</button>
);
}
The count increases immediately. If the action fails, React reverts the optimistic update.
Security considerations
Server Actions are secure by default, but follow these practices:
| Practice | Reason |
|---|---|
| Always validate inputs | Users can call actions with any data |
| Check authentication | Verify session before mutations |
| Use CSRF protection | Next.js includes this automatically |
| Rate limit sensitive actions | Prevent abuse |
| Don't trust client data | Re-fetch data you need server-side |
export async function deleteAccount() {
const session = await getSession();
if (!session) {
throw new Error('Unauthorized');
}
// Re-fetch user instead of trusting client-sent ID
const user = await db.query.users.findFirst({
where: eq(users.id, session.userId),
});
await db.delete(users).where(eq(users.id, user.id));
}
Error handling patterns
For expected errors, return error objects:
export async function updateProfile(formData: FormData) {
try {
await db.update(users).set({ /* ... */ });
return { success: true };
} catch (error) {
return { error: 'Failed to update profile' };
}
}
For unexpected errors, let them throw. Catch with error boundaries:
// app/profile/error.tsx
'use client';
export default function Error({ error, reset }) {
return (
<div>
<h2>Something went wrong</h2>
<button onClick={reset}>Try again</button>
</div>
);
}
Summary
Server Actions simplify form handling and mutations in Next.js. Mark functions with 'use server', pass them to form actions, and use hooks like useFormStatus and useActionState for loading states and results. Validate with Zod, revalidate cached data after mutations, and use optimistic updates for responsive UIs. Server Actions run only on the server, keeping your logic secure while reducing boilerplate.
