Next.js Server Actions, In Depth
Server Actions let you write server-side functions directly in components and call them from the frontend like async functions, without API routes. Learn how they work, what 'use server' means, progressive-enhancement form patterns, revalidation, security boundaries, and when to use Route Handlers.
Key Points
- Topic Focus: Next.js、React
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
When building a Next app, one kind of work is especially tedious: creating a document, updating a user profile, or deleting a comment. The logic runs on the server, where it needs database access and authorization, but it is triggered from the frontend. The traditional approach is to write /api/posts/route.ts, fetch it from the frontend, and handle serialization, errors, loading, and CSRF tokens yourself. A simple “submit a form” gets forcibly split into two sets of code on two sides.
Server Actions bring this back into a shape one person can maintain: write a server-side function directly in a component, and call it from the frontend as though it were an async function. No route file and no hand-written fetch. This article covers it from its mechanics to its pitfalls.
1. What It Actually Solves
In one sentence: it makes the code organization for “the frontend triggers a server-side side effect” look like “the frontend calls a local function.”
Write an async function marked with 'use server', and it can run only on the server. The frontend receives a “reference” to it. When called, Next sends a request to execute it on the server and returns the result. Authorization, database reads and writes, and environment variables all live inside this function, and secrets never enter the frontend bundle.
A terminology note: React officially calls these Server Functions. When they are used in mutation scenarios such as a form's action or a button's formAction, Next calls them Server Actions. They are fundamentally the same thing, and this article uses the terms interchangeably following Next's convention.
2. Behind 'use server': Compiled into an RPC Endpoint
'use server' is a compiler directive, not a runtime function. It has two forms:
// 写法 A:整个文件都是服务端函数
'use server'
import { db } from '@/lib/db'
export async function createPost(formData: FormData) {
await db.post.create({ data: { title: String(formData.get('title')) } })
}
// 写法 B:内联在某个函数体顶部
export default function Page() {
async function publish(formData: FormData) {
'use server'
// ...
}
return <form action={publish}><button>发布</button></form>
}
At build time, Next generates an encrypted, non-deterministic action ID for every action. The frontend does not receive the function body; it receives an “action ID reference.” When you call it, the framework serializes the arguments, sends a POST request to the internal endpoint associated with the action ID, deserializes and executes it on the server, then serializes the return value back.
If you use Object.fromEntries(formData) to read a form, you will see several extra fields beginning with $ACTION_. Those are the action ID and metadata Next injects, and the framework uses them to route the POST to the correct server-side function. In everyday code, read values with formData.get('fieldName'); you do not need to care about those internal fields.
A few implementation details worth knowing:
- The action ID is regenerated on every build and can be cached for up to 14 days. Change the build and the old ID becomes invalid, which provides a lightweight form of anti-guessing protection.
- Actions not referenced by any component are removed from the build output through dead code elimination, so they do not remain public endpoints.
- Client-side action calls use
POSTand accept onlyPOST.
So it is fundamentally RPC, except the compiler weaves that RPC for you and you only need to write the function signature.
3. Form Pattern: action={fn} + FormData, with Native Progressive Enhancement
The most natural use case for Server Actions is forms. React extends the <form> action attribute so you can pass a Server Action directly:
import { createPost } from '@/app/actions'
export function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" />
<button type="submit">发布</button>
</form>
)
}
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
export async function createPost(formData: FormData) {
const title = String(formData.get('title'))
const content = String(formData.get('content'))
await db.post.create({ data: { title, content } })
revalidatePath('/posts')
}
Note the function signature: the parameter is native FormData. Read values with formData.get('name'); field names correspond to the input's name. Do not use JSON. FormData is its “native language.”
The best part is that this is progressive enhancement. When the form appears in a Server Component and a user disables JavaScript or JavaScript has not finished loading, submission still works. The browser submits the action ID as the endpoint through a native form POST, and the server handles it normally. In a Client Component, submissions queue until hydration completes if JavaScript has not loaded. This means initial interaction does not depend on hydration, making the experience much more reliable.
To pass an extra argument to an action, such as postId, preset it with bind instead of using a closure:
const updateWithId = updatePost.bind(null, postId)
return <form action={updateWithId}>...</form>
bind also supports progressive enhancement, while using a closure introduces pitfalls covered later.
4. useActionState: Take Over State and Loading on the Client
Native form submission does not give you loading state or return values. For those, turn the component into a Client Component and use React's useActionState:
'use client'
import { useActionState } from 'react'
import { signup } from '@/app/actions'
export function SignupForm() {
const [state, formAction, pending] = useActionState(signup, { errors: undefined })
return (
<form action={formAction}>
<input name="email" type="email" required />
{state.errors?.email && <p className="text-red-600">{state.errors.email}</p>}
<button disabled={pending}>{pending ? '提交中…' : '注册'}</button>
</form>
)
}
The corresponding action signature needs an additional prevState as its first parameter:
'use server'
import { z } from 'zod'
const schema = z.object({ email: z.string().email() })
export async function signup(prevState: any, formData: FormData) {
const parsed = schema.safeParse({ email: formData.get('email') })
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }
}
// 写库...
return { errors: undefined, ok: true }
}
The formAction returned by useActionState can still go directly into <form action={...}>. Progressive enhancement remains intact: before JavaScript loads, it falls back to native submission; after it loads, the hook takes over. To control loading for a button alone without promoting the entire component to a client component, split the button into a child component and use useFormStatus(), which reads the nearest <form> context. For optimistic updates, pair it with useOptimistic.
The intent behind this combination is clear: with JavaScript, use the best JavaScript experience; without it, fall back to a native form. Both paths use the same action, and the business logic is written only once.
5. revalidatePath / revalidateTag: Refresh Caches After Execution
The App Router caches pages and fetch results. If you do not invalidate cache after writing to the database, users will still see old data. Call these in an action:
import { revalidatePath, revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) {
await db.post.create({ /* ... */ })
revalidatePath('/posts') // 按路径失效
revalidateTag('posts') // 按 tag 失效(fetch 时用 next: { tags: ['posts'] } 标的)
}
revalidatePath('/posts'): invalidates cache associated with this path, including its layout chain.revalidateTag('posts'): invalidates everyfetchor'use cache'block carrying this tag. It is suitable when multiple pages share one data source.
To navigate away after a mutation, use redirect('/posts'). Note that it implements navigation by throwing an exception, so code after it will not run. Put revalidatePath before redirect.
A newer member, refresh(), imported from next/cache, can refresh the current route tree, but it does not invalidate tagged data. To refresh tagged data, you still need revalidateTag.
6. Security Boundary, Crucial: It Is a Public HTTP Endpoint
This is where beginners are most likely to stumble. A Server Action is a public HTTP POST endpoint. Anyone can call it directly through POST; they do not have to go through your UI. Even if the action appeared only on a logged-in page, a user can obtain the action ID and call it directly outside the page.
Therefore, there are two hard rules:
- Reauthorize inside every action. A page-level login check does not protect the action inside it, because an action is an independent entry point.
- Validate input and perform authorization, not just authentication. Do not merely check whether someone is logged in. Check whether that user has permission for that resource, or you have the classic IDOR authorization flaw.
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function deletePost(postId: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const post = await db.post.findUnique({ where: { id: postId } })
if (!post || post.authorId !== session.user.id) {
throw new Error('Forbidden') // 不是你的帖子,删不了
}
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
}
Built-in framework defenses:
- The action ID is encrypted and changes with every build, reducing enumeration risk, but this must not be used as authorization.
- CSRF protection: actions accept only
POSTand compare the request'sOriginheader withHostorX-Forwarded-Hostto ensure they are same-origin. A mismatch is rejected directly. Combined with modern browsers' defaultSameSitecookies, this blocks most CSRF attacks. If you use a reverse proxy or the domain differs from the backend, configureexperimental.serverActions.allowedOriginsinnext.config. - Body-size limits: the default is 1 MB to prevent large payloads from overwhelming the server. For larger bodies, configure
experimental.serverActions.bodySizeLimit: '2mb'. - Return values are serialized to the frontend, so return only the minimum fields the UI needs. Do not send an entire database record back.
In one sentence: write a Server Action as though it were just as public and just as in need of defense as /api/route.ts.
One more easily overlooked point: rate-limit expensive operations. Sending email, writing to a database, or calling an LLM costs money per request or volume. An action does not protect against abuse on its own. Add rate limiting per user or IP, using upstash/ratelimit, middleware, or equivalent, just as you would in an API route.
7. Choosing Between Server Actions and API Routes
When should you use a Server Action, and when should you write a Route Handler (/api/.../route.ts)?
Server Actions are suitable for server-side logic tightly bound to a particular UI or component: form submissions, inline likes, delete buttons, and settings toggles. They are coupled to the component and consume component context, including closures, bind, and the current route. They do not need to be discoverable by the outside world.
API routes are suitable for public APIs exposed to others, third-party webhooks, and entry points called by non-browser clients such as cURL, mobile apps, scheduled jobs, or third-party services. Such callers have no “component that rendered them,” and need a stable, documented URL that can be independently versioned.
The criterion is simple: does this server-side entry point have “component context”? If yes, use an action; if not, use a route. The two are not mutually exclusive, and having both in a medium-sized project is entirely normal.
8. Four Pitfalls That Bite
-
Be careful passing values through closures. When an action is inlined inside a component, it captures outer variables, for example
const version = await getLatestVersion(). Those values are serialized to the frontend, then sent back to the server with the call. Next encrypts them and the key changes on every build. This can pass a snapshot from render time to an action, but never use it to pass sensitive data. Encryption is only a safeguard, not authorization. In self-hosted multi-instance deployments, inconsistent encryption keys across servers make actions fail. Set theNEXT_SERVER_ACTIONS_ENCRYPTION_KEYenvironment variable to unify the key. Functions and class instances cannot be passed at all; the framework blocks them by default. -
Use FormData for large payloads, not JSON arguments. For a few fields, use FormData. If you must pass a large nested object, the default 1 MB limit will block you and serialization overhead is not small. To pass files, use FormData and increase
bodySizeLimit. -
Do not cause side effects during rendering. Directly writing to the database, deleting cookies, or revalidating inside a Server Component render function is an anti-pattern. Next explicitly prevents revalidation or cookie changes during rendering. Send every mutation through an action.
-
Actions are dispatched serially. In the current implementation, the client dispatches actions one at a time, awaiting each one. Do not expect multiple actions to run concurrently. For parallel data loading, use parallel
fetchcalls in a Server Component. For parallel mutations, usePromise.allinside a single action, or write a Route Handler and control it yourself.
Summary
- A Server Action is a server-side async function marked with
'use server'. It is compiled into an RPC endpoint with an encrypted ID, and the frontend calls it like a local function without writing an/apiroute. - Form
action={fn}plusFormDatais its natural domain. It provides native progressive enhancement in Server Components. For loading state and return values, pair a Client Component withuseActionState;formActioncan still go into<form action>, and fallback behavior remains intact. - After a database write, invalidate cache with
revalidatePathorrevalidateTag.redirectuses an exception internally, so put it last. - Security red line: it is a public POST endpoint. Reauthorize within every action, validate input, and enforce resource-level authorization. The framework's action ID encryption and Origin validation are only supporting defenses.
- For the tradeoff: use actions for logic tightly coupled to components, and Route Handlers for public APIs, webhooks, and non-browser callers.
A final sentence: Server Actions finally give “the frontend triggers a server-side change” a code structure that matches its real complexity, but the public nature of the endpoint has not changed, so none of the required security measures can be skipped.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
How Should a Technical Community Design Its Tag System and Recommendation Feed?
Centered on independent development, Next.js technology trends, engineering practices, and the indie developer perspective.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.