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

Learn how to use Next.js Server Actions for form submissions and data mutations. Covers useActionState, error handling, validation patterns, and production best practices.

Server Actions let you run server-side code directly from React components without creating API endpoints. Pass a function to a form's action prop, and Next.js handles the HTTP request, serialization, and response automatically.

Server Actions are async functions that run on the server but can be invoked from client components like regular JavaScript functions. Next.js creates a POST endpoint behind the scenes and manages the network request for you.

Tested with Next.js 16.1 and React 19 in January 2026. Server Actions are stable and production-ready.

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:

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

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

Next Steps

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

For these patterns in action, see MakerKit's open-source starter kit. Ready to build? Get a SaaS Starter Kit license.