Next.js Server Actions: Replace API Routes With Forms
On this page
For years, the standard way to handle a form submission in a React app looked the same: build a <form>, attach an onSubmit handler, call fetch('/api/something'), serialize the body to JSON, write a matching API route on the server, parse the request, and wire up loading and error state by hand. Server Actions collapse that entire round trip into a single async function. If you've been maintaining a folder full of thin route.ts files that exist only to receive form data, this is the pattern that lets you delete most of them.
This post walks through what Server Actions are, when they beat API routes, and how to migrate real forms without breaking your app.
What a Server Action Actually Is
A Server Action is an async function that runs on the server but can be called directly from a component — including being passed straight to a form's action attribute. You mark it with the 'use server' directive, either at the top of a file or inside a function body.
// app/actions.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const body = formData.get('body') as string
await db.post.create({ data: { title, body } })
revalidatePath('/posts')
}
And the component that uses it:
import { createPost } from './actions'
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" />
<textarea name="body" />
<button type="submit">Publish</button>
</form>
)
}
There is no fetch, no JSON serialization, no API route, and no manual event handler. When the form submits, Next.js sends the FormData to the server, runs createPost, and — because of revalidatePath — refreshes the affected route with fresh data. The function reference in action={createPost} looks like a client-side call but compiles down to a POST request that Next.js manages for you.
Why Replace API Routes
API routes aren't going away, and they shouldn't for every case. But for form-driven mutations, Server Actions remove a lot of accidental complexity.
Less boilerplate. Each API route needs a handler, request parsing, a response shape, and a corresponding client-side fetch. A Server Action is one function.
Type safety across the boundary. With an API route, the contract between client and server is a URL and an untyped JSON blob. A Server Action is a normal TypeScript function, so your editor knows its signature and your build catches mismatches.
Progressive enhancement for free. Because the action is attached to the form's native action attribute, the form works even before JavaScript loads or if it fails entirely. The browser submits the form the old-fashioned way and Next.js still runs your action. You cannot get this with a fetch-based handler.
Colocation. The mutation logic lives next to the component that uses it instead of in a parallel app/api/ tree you have to keep in sync.
Handling Pending State and Results
Real forms need feedback. The useActionState hook (formerly useFormState) gives you the action's return value and a pending flag, and it works with progressive enhancement intact.
'use client'
import { useActionState } from 'react'
import { createPost } from './actions'
const initialState = { message: '' }
export function PostForm() {
const [state, formAction, pending] = useActionState(createPost, initialState)
return (
<form action={formAction}>
<input name="title" />
<textarea name="body" />
<button disabled={pending}>{pending ? 'Saving…' : 'Publish'}</button>
{state.message && <p role="alert">{state.message}</p>}
</form>
)
}
Your action now takes the previous state as its first argument and returns the next state:
'use server'
export async function createPost(prevState: State, formData: FormData) {
const title = formData.get('title') as string
if (!title) {
return { message: 'Title is required.' }
}
await db.post.create({ data: { title } })
revalidatePath('/posts')
return { message: 'Published!' }
}
For button-level pending state inside a larger form, useFormStatus reads the status of the nearest parent form, which keeps your submit button component decoupled from the action itself.
Validation and Error Handling
Never trust FormData. It arrives from the client and can contain anything, so validate on the server before touching your database. Zod pairs well here because it gives you both parsing and typed output.
'use server'
import { z } from 'zod'
const schema = z.object({
title: z.string().min(1).max(120),
body: z.string().min(1),
})
export async function createPost(prevState: State, formData: FormData) {
const parsed = schema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
})
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }
}
await db.post.create({ data: parsed.data })
revalidatePath('/posts')
redirect('/posts')
}
Return field-level errors as part of your state object so the form can render them next to the right inputs. For unexpected failures, let the error bubble to the nearest error.tsx boundary or catch it and return a generic message — don't leak stack traces to the client.
Revalidation and Redirects
After a mutation, your cached pages are stale. Two tools fix this:
revalidatePath('/posts')purges the cache for a specific route so the next visit fetches fresh data.revalidateTag('posts')purges everything tagged with a cache tag, which is cleaner when the same data appears on multiple routes.
Call redirect('/posts') at the end of an action to send the user somewhere after success. Note that redirect throws internally to interrupt execution, so put it outside any try/catch block or it will be swallowed.
Security Considerations
Server Actions are public HTTP endpoints even though they look like local function calls. Treat them like any other endpoint.
- Authenticate and authorize inside every action. Check the session and confirm the user is allowed to perform this specific mutation. Do not assume that because the form was only rendered for admins, only admins can call the action.
- Validate all input, as above.
- Next.js provides built-in CSRF protection for Server Actions by checking the Origin against the Host, but that does not replace your own authorization logic.
- Be careful about closures. Values captured in a closure around an inline action are serialized and sent to the client, so never close over secrets.
When to Keep Using API Routes
Server Actions are for mutations triggered from your own app. Reach for a traditional Route Handler when you need:
- A public REST or webhook endpoint consumed by third parties or mobile clients.
- Non-POST semantics, custom headers, or streaming responses.
- An endpoint called by something that isn't a React component, like a cron job or another service.
Server Actions and API routes coexist happily. Migrate your internal forms first and leave your genuine API surface alone.
A Practical Migration Path
- Find a form that posts to an API route used nowhere else.
- Move the route's logic into a
'use server'function, swappingreq.bodyparsing forformData.get. - Point the form's
actionat the new function and delete theonSubmithandler. - Add
useActionStatefor pending and error UI. - Add server-side validation and a
revalidatePathcall. - Delete the old
route.ts.
Do this one form at a time. Because the two approaches don't conflict, you can migrate incrementally over several PRs rather than in one risky sweep.
FAQ
Do Server Actions work without JavaScript?
Yes, when passed directly to a form's action attribute. The browser submits the form natively and Next.js runs the action server-side. Interactive enhancements like pending state require JS, but the core submission degrades gracefully.
Can I call a Server Action outside a form?
Yes. You can invoke it from an event handler, like a button's onClick, or inside a useTransition call. You lose the no-JS progressive enhancement, but you gain flexibility for actions that aren't tied to a form.
Are Server Actions slower than API routes? No meaningfully. Under the hood they're POST requests managed by the framework. The main performance lever is caching and revalidation, which you control the same way in both approaches.
How do I handle file uploads?
FormData handles files natively. Add <input type="file" name="avatar" /> and read it with formData.get('avatar'), which returns a File you can stream to storage. Watch your server's body size limits for large uploads.
Can I share one action between multiple forms? Absolutely. An action is just an exported function. Import it wherever you need it, and use validation to handle the differences between call sites.
Do I still need a state management library for forms?
Usually not for the submission itself. Between useActionState, useFormStatus, and server-side revalidation, most form flows no longer need client-side global state to track results.
Wrapping Up
Server Actions don't just save keystrokes — they realign where your logic lives. Mutations sit next to the components that trigger them, the client/server contract becomes a typed function signature, and your forms keep working even when JavaScript doesn't. Start by migrating one internal form, lean on server-side validation and revalidation, and keep true API routes for the external surface that genuinely needs them.