·Updated

Next.js Server Actions: The Complete Guide (2026)

The complete 2026 guide to Next.js Server Actions: useActionState, validation with Zod, error handling, Next.js 16 specifics, and when to choose Server Actions over Route Handlers.

Next.js Server Actions are async functions that run on the server but can be called from React components like regular JavaScript functions. Pass one to a form's action prop and Next.js handles the HTTP request, serialization, and response automatically — no API endpoint, no fetch, no JSON wrangling. They're the recommended way to handle form submissions and data mutations in the App Router.

This guide covers the complete production playbook: how to define and invoke Server Actions, manage form state with useActionState, validate input with Zod, handle errors safely, revalidate caches correctly, and avoid the five most common pitfalls. Tested with Next.js 16.1 and React 19 as of May 2026.

When should I use Server Actions?

Use a Server Action for any mutation triggered from inside your Next.js app — form submissions, button clicks that change state, optimistic UI updates. Use a Route Handler when you need to expose an HTTP endpoint to external callers (webhooks, mobile apps, public APIs) or when you specifically need GET caching. For everything else inside the app, Server Actions are simpler and safer by default.

What Can You Do With Server Actions?

Server Actions handle any server-side operation you'd normally put in an API route:

  • Database mutations: Create, update, delete records directly from forms
  • File operations: Upload files, generate documents, process images
  • External API calls: Call third-party services without exposing keys
  • Email sending: Trigger transactional emails from form submissions

The key constraint: Server Actions use POST requests, so they're designed for mutations, not data fetching. For reading data, use Server Components or Route Handlers.

Why Use Server Actions?

  1. No API boilerplate: Skip creating route handlers for mutations
  2. Type safety: TypeScript types flow from server to client automatically
  3. Progressive enhancement: Forms work even with JavaScript disabled
  4. Single roundtrip: Next.js returns updated UI and data together
  5. Code colocation: Keep related logic close to where it's used

Defining Server Actions

There are two ways to define Server Actions: at the file level or inline within Server Components.

Add 'use server' at the top of a file to mark all exported functions as Server Actions:

// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Your database call here (Drizzle, Prisma, Supabase, etc.)
await db.posts.create({ title, content });
revalidatePath('/posts');
return { success: true };
}
export async function deletePost(id: string) {
await db.posts.delete(id);
revalidatePath('/posts');
}

Keep Server Actions in dedicated files for better organization. This is the pattern we use across all MakerKit projects.

Inline definition

Define Server Actions inline within Server Components for one-off forms:

// app/page.tsx (Server Component)
export default function Page() {
async function submitFeedback(formData: FormData) {
'use server';
const message = formData.get('message') as string;
await saveFeedback(message);
}
return (
<form action={submitFeedback}>
<textarea name="message" required />
<button type="submit">Send Feedback</button>
</form>
);
}

Important: Server Action arguments and return values must be serializable (no functions, classes, or circular references).

Invoking Server Actions

The cleanest approach uses the form's action prop:

import { createPost } from '@/app/actions';
export default function NewPostForm() {
return (
<form action={createPost}>
<input type="text" name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Write something..." required />
<button type="submit">Create Post</button>
</form>
);
}

This supports progressive enhancement: the form works even if JavaScript fails to load.

From event handlers

For more control, call Server Actions from event handlers:

'use client';
import { useState } from 'react';
import { incrementLike } from '@/app/actions';
export function LikeButton({ postId, initialLikes }: {
postId: string;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
return (
<button
onClick={async () => {
const newLikes = await incrementLike(postId);
setLikes(newLikes);
}}
>
{likes} likes
</button>
);
}

With useTransition for pending states

Wrap Server Action calls in useTransition to get a pending state:

'use client';
import { useTransition } from 'react';
import { saveSettings } from '@/app/actions';
export function SettingsForm() {
const [isPending, startTransition] = useTransition();
const handleSubmit = (formData: FormData) => {
startTransition(async () => {
await saveSettings(formData);
});
};
return (
<form action={handleSubmit}>
<input name="name" />
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save Settings'}
</button>
</form>
);
}

Handling Form State with useActionState

React 19 introduced useActionState to manage form submissions with built-in pending and error states. This is now the recommended pattern for forms.

Migration note: If you're upgrading from React 18, useActionState replaces useFormState from react-dom. The API is similar but now lives in the react package.

'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions';
const initialState = {
error: null as string | null,
success: false,
};
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(
createPost,
initialState
);
return (
<form action={formAction}>
<input type="text" name="title" required />
<textarea name="content" required />
{state.error && (
<p className="text-red-500">{state.error}</p>
)}
{state.success && (
<p className="text-green-500">Post created!</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}

The Server Action receives the previous state as its first argument:

'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(
prevState: { error: string | null; success: boolean },
formData: FormData
) {
const title = formData.get('title') as string;
if (title.length < 3) {
return { error: 'Title must be at least 3 characters', success: false };
}
try {
await db.posts.create({ title, content: formData.get('content') as string });
revalidatePath('/posts');
return { error: null, success: true };
} catch (e) {
return { error: 'Failed to create post', success: false };
}
}

Showing Loading States with useFormStatus

For submit buttons that need loading state, use useFormStatus from react-dom. This hook must be used in a component rendered inside a <form>:

'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
// Usage
export function ContactForm() {
return (
<form action={submitContact}>
<input name="email" type="email" required />
<textarea name="message" required />
<SubmitButton />
</form>
);
}

useActionState vs useFormStatus: Use useActionState (from react) when you need the action result and form-level state. Use useFormStatus (from react-dom) for simple pending indicators in submit buttons.

Error Handling Strategies

Return errors as part of your response rather than throwing:

'use server';
type ActionResult = {
success: boolean;
error?: string;
data?: Post;
};
export async function createPost(formData: FormData): Promise<ActionResult> {
try {
const post = await db.posts.create({
title: formData.get('title') as string,
});
revalidatePath('/posts');
return { success: true, data: post };
} catch (e) {
console.error('Failed to create post:', e);
return { success: false, error: 'Something went wrong. Please try again.' };
}
}

Using Error Boundaries

For unexpected errors, wrap forms in an Error Boundary. Using the react-error-boundary package:

'use client';
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div className="p-4 bg-red-50 rounded">
<p className="text-red-700">Something went wrong</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
export function FormWithErrorBoundary() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<CreatePostForm />
</ErrorBoundary>
);
}

Try-catch with useTransition

When calling Server Actions imperatively:

'use client';
import { useTransition } from 'react';
import { deletePost } from '@/app/actions';
export function DeleteButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
startTransition(async () => {
try {
await deletePost(postId);
} catch (error) {
// Show toast notification or update error state
console.error('Delete failed:', error);
}
});
};
return (
<button onClick={handleDelete} disabled={isPending}>
{isPending ? 'Deleting...' : 'Delete'}
</button>
);
}

Input Validation with Zod

Always validate input on the server. Client-side validation improves UX but provides no security:

'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const CreatePostSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters').max(100),
content: z.string().min(10, 'Content must be at least 10 characters'),
});
export async function createPost(
prevState: { error: string | null },
formData: FormData
) {
const result = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
});
if (!result.success) {
return { error: result.error.errors[0].message };
}
await db.posts.create(result.data);
revalidatePath('/posts');
return { error: null };
}

For production apps, consider using a wrapper that handles validation, authentication, and error handling consistently. next-safe-action is a popular choice that we use in MakerKit's Drizzle and Prisma kits.

Here's how we structure actions with next-safe-action and middleware:

'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { authenticatedActionClient } from '@kit/action-middleware';
import { db, posts } from '@kit/database';
const schema = z.object({
title: z.string().min(3),
content: z.string().min(10),
});
export const createPostAction = authenticatedActionClient
.inputSchema(schema)
.action(async ({ parsedInput, ctx }) => {
// ctx.user is available from middleware - auth already verified
// parsedInput is validated against the schema
const [post] = await db
.insert(posts)
.values({
...parsedInput,
authorId: ctx.user.id,
})
.returning();
revalidatePath('/posts');
return { post };
});

The authenticatedActionClient handles session verification automatically, so your action only runs for authenticated users. Combined with Zod schemas, you get type-safe, validated input with minimal boilerplate.

We cover these patterns in depth in our guide on writing secure Server Actions.

Revalidating Data After Mutations

After a mutation, update the UI using these Next.js cache functions. Choosing between revalidatePath and revalidateTag is the single decision that most often goes wrong in production code.

revalidatePath vs revalidateTag: which to use

SituationUse
You know the URL(s) affected by the mutationrevalidatePath
Same data shows up across many routes (e.g. a counter, a user's avatar)revalidateTag
You're inside a dynamic route segmentrevalidatePath('/posts/[id]', 'page')
You want layout-level revalidation, not just the pagerevalidatePath('/posts', 'layout')
You're caching fetch() calls with next.tagsrevalidateTag

Rule of thumb: start with revalidatePath because it's easier to reason about. Reach for revalidateTag once you have data that lives behind multiple URLs and you don't want to enumerate them.

revalidatePath

Revalidate all data for a specific path:

'use server';
import { revalidatePath } from 'next/cache';
export async function updatePost(id: string, formData: FormData) {
await db.posts.update(id, { title: formData.get('title') as string });
// Revalidate the posts list and the specific post page
revalidatePath('/posts');
revalidatePath(`/posts/${id}`);
}

revalidateTag

Revalidate data by cache tag for granular control:

'use server';
import { revalidateTag } from 'next/cache';
export async function updatePost(id: string, formData: FormData) {
await db.posts.update(id, { title: formData.get('title') as string });
revalidateTag('posts');
}

Redirecting after mutations

Use redirect after successful mutations. Call revalidatePath before redirect to ensure fresh data:

'use server';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const post = await db.posts.create({
title: formData.get('title') as string,
});
revalidatePath('/posts');
redirect(`/posts/${post.id}`);
}

Next.js 16 Specifics

If you've worked with Server Actions in earlier Next.js versions, a few things changed in 16 that you need to know:

Encrypted closures, but no leaked secrets

When you define an inline Server Action that closes over a variable (like a user ID from the parent Server Component), Next.js encrypts the closure with a per-deployment secret before sending its reference to the client. That makes inline closures safe as long as you don't close over actual secrets — API keys, service-role tokens, signed JWTs. The encryption protects against tampering, not against the closure being decrypted on the next server-side invocation.

// SAFE — closing over a non-secret value
async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
async function buyNow() {
'use server';
await purchase(id); // id is encrypted in transit
}
return <BuyButton action={buyNow} />;
}
// UNSAFE — closing over a real secret
async function AdminPage() {
const adminToken = process.env.ADMIN_TOKEN!;
async function impersonate() {
'use server';
// The token bytes never reach the browser, but the action ID
// hashes them — generate the secret inside the action instead.
return await callAdmin(adminToken);
}
}

params and cookies are Promises

In Next.js 16, params, searchParams, and cookies() all return Promises. Server Actions inherit the same rule when they read them:

'use server';
import { cookies } from 'next/headers';
export async function logout() {
const cookieStore = await cookies();
cookieStore.delete('session');
}

Action IDs are stable across deploys (with cacheKey)

Next.js 16 added a cacheKey option in next.config.ts that stabilizes Server Action IDs across deploys. Without it, deploys mid-session can break in-flight forms; with it, the action that submitted three seconds before your deploy still resolves correctly. Turn it on for any production app:

export default {
experimental: {
serverActions: {
// Stable IDs across deploys
bodySizeLimit: '2mb',
},
},
} satisfies import('next').NextConfig;

Body size limits

Server Actions have a 1 MB request body limit by default. For file uploads larger than that, raise the limit in next.config.ts (above) or use a Route Handler with streaming. Don't fight Server Actions on this — Route Handlers are the right tool for big payloads.

Pitfalls and Gotchas

1. Using Server Actions for data fetching

Pitfall: Calling Server Actions to fetch data instead of using Server Components.

Why it happens: Server Actions feel convenient, and you might think "it runs on the server, so why not?"

Fix: Server Actions use POST requests and can't be cached. Use Server Components for data fetching:

// Wrong: Server Action for reading
'use server';
export async function getPosts() {
return db.posts.findMany();
}
// Right: Server Component
export default async function PostsPage() {
const posts = await db.posts.findMany();
return <PostList posts={posts} />;
}

2. Trusting client-side validation

Pitfall: Relying on form validation attributes or client-side Zod checks for security.

Why it happens: If you validate on the client, why do it again?

Fix: Always validate on the server. Client validation is for UX only:

// Server Action - always validate here
const result = CreatePostSchema.safeParse(formData);
if (!result.success) {
return { error: result.error.errors[0].message };
}

3. Forgetting authorization checks

Pitfall: Validating input but not checking if the user can perform the action.

Why it happens: Authentication and authorization are often confused.

Fix: Check both who the user is AND what they're allowed to do:

export async function deletePost(id: string) {
const user = await getAuthenticatedUser();
if (!user) return { error: 'Not authenticated' };
const post = await db.posts.findById(id);
if (post.authorId !== user.id) {
return { error: 'Not authorized to delete this post' };
}
await db.posts.delete(id);
}

4. Leaking sensitive error details

Pitfall: Returning database or internal error messages to users.

Why it happens: During development, detailed errors are helpful.

Fix: Log detailed errors server-side, return generic messages to users:

catch (e) {
// Log for debugging
console.error('Post creation failed:', e);
// Generic user message
return { error: 'Failed to create post. Please try again.' };
}

5. No loading feedback

Pitfall: Form submits with no indication anything is happening.

Why it happens: Forgetting to add pending state handling.

Fix: Always show loading state using useActionState, useFormStatus, or useTransition:

<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>

Server Actions vs Route Handlers

Use Server Actions when:

  • Handling form submissions from your Next.js app
  • Running mutations (create, update, delete)
  • You want type safety and progressive enhancement

Use Route Handlers when:

  • Building APIs for external services (webhooks, mobile apps)
  • Handling GET requests that benefit from HTTP caching
  • You need full control over HTTP responses

If unsure: Start with Server Actions for any mutation within your app. Switch to Route Handlers only when you hit a limitation.

For a deeper comparison, see Server Actions vs Route Handlers.

Frequently Asked Questions

What are Next.js Server Actions?
Server Actions are async functions that run on the server but can be called from React components. They handle form submissions and data mutations without requiring you to create API endpoints. Next.js manages the HTTP request and response automatically.
When should I use Server Actions vs an API route?
Use Server Actions for mutations triggered from inside your Next.js app — form submissions, button clicks that change state. Use Route Handlers when you need an HTTP endpoint for external callers (webhooks, mobile apps, public APIs) or when GET caching matters. Inside the app, Server Actions are simpler, type-safe, and progressively enhanced.
Do Server Actions work without JavaScript?
Yes, when used with the form action prop. This is called progressive enhancement. The form submits as a standard HTTP POST request if JavaScript is disabled or fails to load.
What's the difference between useActionState and useFormStatus?
useActionState (from 'react') manages the entire form state including the action result and pending status. useFormStatus (from 'react-dom') only provides the pending state and must be used inside a form element. Use useActionState for form-level state, useFormStatus for submit button loading indicators.
Can I use Server Actions for fetching data?
Server Actions use POST requests and are designed for mutations, not data fetching. For reading data, use Server Components for server-side fetching or Route Handlers for client-side fetching with caching support.
How do I handle errors in Server Actions?
Return errors as part of your response object rather than throwing exceptions. Use useActionState to display errors in the UI. For unexpected errors, wrap your form in an Error Boundary component.
Are Server Actions secure?
Server Actions run on the server, so secrets stay protected. Next.js 16 also encrypts inline closure variables in transit so they can't be tampered with from the client. However, you must still validate all input with Zod or similar, check authentication, and verify authorization. Treat Server Action inputs the same as any API endpoint input — see our guide on writing secure Server Actions (/blog/tutorials/secure-nextjs-server-actions).
What's the difference between revalidatePath and revalidateTag?
revalidatePath invalidates the cache for one or more URLs you specify. revalidateTag invalidates every fetch() call (or unstable_cache) tagged with that tag, regardless of which page rendered them. Start with revalidatePath. Move to revalidateTag when the same data shows up across many routes and enumerating URLs becomes painful.
What's the body size limit for Server Actions?
1 MB by default in Next.js 16. Raise it via experimental.serverActions.bodySizeLimit in next.config.ts. For uploads larger than a few MB, prefer a Route Handler with streaming — Server Actions are designed for form data, not large file transfer.

Next Steps

Server Actions simplify mutations in Next.js, but production code needs proper validation, authentication, and error handling layered on top.

Ready to build? Get a SaaS Starter Kit license.