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?
- No API boilerplate: Skip creating route handlers for mutations
- Type safety: TypeScript types flow from server to client automatically
- Progressive enhancement: Forms work even with JavaScript disabled
- Single roundtrip: Next.js returns updated UI and data together
- 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.
File-level definition (recommended)
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
From forms (recommended)
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> );}// Usageexport 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
Returning errors as data (recommended)
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 Componentexport 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 hereconst 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?
Do Server Actions work without JavaScript?
What's the difference between useActionState and useFormStatus?
Can I use Server Actions for fetching data?
How do I handle errors in Server Actions?
Are Server Actions secure?
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.