◆ ESSAY
async function createPost(formData: FormData) {
'use server'
const title = formData.get('title')
await db.posts.create({title})
}
A single line: "use server". The moment you write this as the first line of a function body, this function is no longer an ordinary function. It becomes a server endpoint that can be called from the client. All the traditional backend processes of defining routes, setting up middleware, parsing requests, and serializing responses are hidden behind this single line.
React's official documentation clarified the terminology starting in September 20241:
"use server"action prop or called within an actionIn other words, all Server Actions are Server Functions, but not all Server Functions are Server Actions. This article uses the official term "Server Functions."
This article dissects everything that single line of "use server" creates. How code is transformed at build time, what happens on the network when called from the client, how arguments are serialized, how closure variables are encrypted, and why security deserves special attention.
The source code analysis in this article is based on React 19.2 and Next.js 16.1. Internal implementations may vary across versions.
To understand Server Functions, you need to understand RPC (Remote Procedure Call) first. RPC is "a protocol for calling functions on a remote server as if they were local functions."
// Traditional approach: Write all HTTP request details manually
const response = await fetch('/api/posts', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({title: 'New Post', content: 'Content'}),
})
const post = await response.json()
// RPC approach: Hide the existence of the network
const post = await createPost({title: 'New Post', content: 'Content'})
The core of RPC is abstracting network communication. The caller doesn't need to know whether this function executes locally or on a server on the other side of the globe. The technologies that make this abstraction possible are Serialization and Deserialization.
gRPC, JSON-RPC, and XML-RPC are representative RPC protocols. React's Server Functions use essentially the same mechanism, but differ in that they integrate with a custom serialization format called Flight Protocol and the React component tree.
Flight Protocol is a custom streaming serialization format created by the React team for RSC (React Server Components). It's used for bidirectional communication: sending server component rendering results to the client (Server → Client) and passing arguments and return values when calling server functions (Client → Server). Going beyond JSON's limitations (no support for functions, undefined, Date, circular references, etc.), it can stream React element trees, server references, Promises, Map, Set, etc. in line-based chunks. The prefix tokens like $h, $D, $n covered in this article are all encoding rules of Flight Protocol.
RPC is a concept introduced in 1984. There have been countless attempts—Java RMI, CORBA, DCOM—and most failed. Why did they fail? The "Eight Fallacies of Distributed Computing"2 summarized in 1994 hits the core. Eight assumptions developers unconsciously make about networks are all wrong.
Let's first look at four fallacies that directly clash with Server Functions:
try/catch, users see a frozen screen with no feedback.for loop results in 100 network round trips.The remaining four (topology changes, administrative control, transport costs, network heterogeneity) can't be ignored in large-scale services, but the above four are what frontend developers experience.
The React team knows these issues. The official documentation guides using Server Functions with useActionState or useTransition1. This explicitly addresses network-specific issues like pending states, error handling, and optimistic updates. "Hide the network, but expose network characteristics" — this is the lesson learned from past RPC failures.
The "use server" directive has no effect at runtime. The real work happens at build time.
When the bundler (webpack, Turbopack) discovers a "use server" directive, it calls React's registerServerReference function to inject metadata as properties into that function object. The actual implementation of this function is in the react-server-dom-webpack package3.
// react-server-dom-webpack/src/ReactFlightWebpackReferences.js
const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference')
export function registerServerReference(reference, id, exportName) {
return Object.defineProperties(reference, {
$$typeof: {value: SERVER_REFERENCE_TAG},
$$id: {
value: exportName === null ? id : id + '#' + exportName,
configurable: true,
},
$$bound: {value: null, configurable: true},
bind: {value: bind, configurable: true},
})
}
The key is Object.defineProperties. It overwrites three special properties on top of the original function object.
| Property | Value | Description |
|---|---|---|
$$typeof | Symbol.for('react.server.reference') | Tag identifying this function as server reference |
$$id | "moduleId#exportName" | Unique identifier for finding function on server |
$$bound | null | Arguments bound with .bind(). Initial value is null |
The format of $$id is important. It's moduleId + '#' + exportName. For example, if you export createPost from app/actions.ts, the ID becomes something like "app/actions.ts#createPost". In Next.js, this ID is hashed and converted to a 42-character string.
There's an important caveat here. The above code is the implementation for the RSC server layer. The client/SSR layer has a completely different implementation with the same name4.
// react-client/src/ReactFlightReplyClient.js
function registerBoundServerReference(reference, id, bound, encodeFormAction) {
knownServerReferences.set(reference, {
id: id,
originalBind: reference.bind,
bound: bound,
})
Object.defineProperties(reference, {
$$FORM_ACTION: {value: encodeFormAction || defaultEncodeFormAction},
$$IS_SIGNATURE_EQUAL: {value: isSignatureEqual},
bind: {value: bind},
})
}
Here's a summary of the differences between the two implementations:
RSC Server (ReactFlightWebpackReferences) | Client/SSR (ReactFlightReplyClient) | |
|---|---|---|
| Runtime Environment | RSC server (during Flight serialization) | Browser, SSR |
| Metadata Storage | Directly on function via Object.defineProperties | Stored in WeakMap (knownServerReferences) |
| Injected Properties | $$typeof, $$id, $$bound, bind | $$FORM_ACTION, $$IS_SIGNATURE_EQUAL, bind |
| Purpose | Identify server reference during Flight serialization ($$typeof check) | Progressive Enhancement ($$FORM_ACTION), HMR signature comparison |
The RSC server checks $$typeof === Symbol.for('react.server.reference') when serializing component trees and converts them to $h tokens. The client/SSR side doesn't need to serialize server references, so $$typeof is unnecessary. Instead, it needs $$FORM_ACTION for form submission without JS. WeakMap is used to avoid exposing unnecessary properties on function objects.
Turbopack also uses the same $$typeof/$$id/$$bound pattern in react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js. However, since Turbopack loads RSC and SSR layers as different module evaluation contexts within the same chunk file, static analysis (grep) of build artifacts only shows the client-side WeakMap implementation. The $$typeof version is loaded at runtime in the RSC context.
When you want to pre-bind values known at render time to server functions, you use .bind(). For example, binding each post's ID to delete buttons in a post list.
// Server Component
export default async function PostList() {
const posts = await db.posts.findMany()
return posts.map((post) => (
<form key={post.id} action={deletePost.bind(null, post.id)}>
<button type="submit">Delete</button>
</form>
))
}
The problem is that using regular Function.prototype.bind directly would lose server reference metadata like $$typeof and $$id from the returned function. Since React uses this metadata to determine "whether this function is a server reference," the result of .bind() must still be recognized as a server reference. So registerServerReference overrides .bind() with a custom implementation that maintains metadata while accumulating bound arguments in the $$bound array. (On the client side, they accumulate in the WeakMap's bound.)
// RSC server side: react-server-dom-webpack/src/ReactFlightWebpackReferences.js
function bind() {
const newFn = FunctionBind.apply(this, arguments)
if (this.$$typeof === SERVER_REFERENCE_TAG) {
const args = ArraySlice.call(arguments, 1)
return Object.defineProperties(newFn, {
$$typeof: {value: SERVER_REFERENCE_TAG},
$$id: {value: this.$$id},
$$bound: {
value: this.$$bound ? this.$$bound.concat(args) : args,
},
bind: {value: bind, configurable: true},
})
}
return newFn
}
Arguments are properly accumulated even when chaining .bind() multiple times.
const fn1 = deletePost.bind(null, userId) // $$bound: [userId]
const fn2 = fn1.bind(null, postId) // $$bound: [userId, postId]
Let's see specifically how the original code is separated at build time.
// Original: app/actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title')
await db.posts.create({title})
}
Server Bundle: Contains the original function with metadata injected via registerServerReference.
// Server bundle
import {registerServerReference} from 'react-server-dom-webpack/server'
async function createPost(formData) {
const title = formData.get('title')
await db.posts.create({title})
}
registerServerReference(createPost, 'abc123def456...', 'createPost')
Client Bundle: Function body is completely removed, and a proxy function is created with createServerReference.
// Client bundle
import {createServerReference} from 'react-server-dom-webpack/client'
export const createPost = createServerReference(
'abc123def456...#createPost',
callServer, // Callback provided by framework (Next.js)
)
Neither db.posts.create nor the logic of formData.get exists in the client bundle. What the client receives is only a proxy function that sends HTTP requests to the server.
What actually happens when a server function is called from the client? Let's examine the implementation of createServerReference we saw above.
// react-client/src/ReactFlightReplyClient.js
export function createServerReference(id, callServer, encodeFormAction) {
let action = function () {
const args = Array.prototype.slice.call(arguments)
return callServer(id, args)
}
registerBoundServerReference(action, id, null, encodeFormAction)
return action
}
Surprisingly simple. When a server function is called, it collects arguments into an array and calls callServer(id, args). That's it.
callServer is not provided by React but is a callback injected by the framework (Next.js)4. It's passed when initializing React's Flight client.
// When initializing React Flight client
this._callServer = callServer !== undefined ? callServer : missingCall
function missingCall() {
throw new Error(
'Trying to call a function from "use server" but the callServer ' +
'option was not implemented in your router runtime.',
)
}
Without callServer, you get an error. This means you can't call server functions with React alone without Next.js. React defines the protocol, Next.js implements it.
When there are arguments bound via .bind() or closures, createBoundServerReference is used. In this case, bound arguments are prepended to arguments from the call site.
// react-client/src/ReactFlightReplyClient.js
export function createBoundServerReference(metaData, callServer) {
const {id, bound} = metaData
let action = function () {
const args = Array.prototype.slice.call(arguments)
const p = bound
if (!p) {
return callServer(id, args)
}
// bound is Promise<Array<any>>
if (p.status === 'fulfilled') {
const boundArgs = p.value
return callServer(id, boundArgs.concat(args))
}
return Promise.resolve(p).then(function (boundArgs) {
return callServer(id, boundArgs.concat(args))
})
}
registerBoundServerReference(action, id, bound)
return action
}
The reason bound is a Promise is because decryption of closure variables is an asynchronous operation (covered in detail later). The p.status === 'fulfilled' check is an optimization for handling already resolved Promises synchronously.
When a server function is called from the client, its arguments must be transmitted over the network. This serialization is handled by the processReply function.
processReply serializes in two different ways depending on the complexity of the arguments.
// react-client/src/ReactFlightReplyClient.js
export function processReply(
root,
formFieldPrefix,
temporaryReferences,
resolve,
reject,
) {
let nextPartId = 1
let pendingParts = 0
let formData = null
const json = serializeModel(root, 0)
if (formData === null) {
resolve(json) // Simple case: JSON string
} else {
formData.set(formFieldPrefix + '0', json)
if (pendingParts === 0) {
resolve(formData) // Complex case: FormData
}
}
}
JSON path: When arguments consist only of primitive types, arrays, and plain objects. This is the lightest approach.
// When calling incrementLike(42)
"42"
FormData path: When complex types like Blob, ReadableStream, or other server references are included. Individual values are placed in separate FormData parts.
------WebKitFormBoundary
Content-Disposition: form-data; name="0"
42
------WebKitFormBoundary
Content-Disposition: form-data; name="1"
[Blob data]
------WebKitFormBoundary--
Values that cannot be represented in JSON within serializeModel are encoded as special tokens prefixed with $. Here's the complete token table used in actual React source code.
| Token | Meaning | Example |
|---|---|---|
$ + hex | Inline reference | $3 → reference to chunk 3 |
$@ + hex | Promise | Async value |
$h + hex | Server reference | $h5 → server function in chunk 5 |
$K + hex | FormData | |
$Q + hex | Map | |
$W + hex | Set | |
$B + hex | Blob | |
$A + hex | ArrayBuffer | |
$R + hex | ReadableStream | |
$D + dateJSON | Date | $D2026-03-09T00:00:00.000Z |
$n + digits | BigInt | $n12345678901234567890 |
$S + name | Symbol.for() | $Smy-symbol |
$-0 | -0 (negative zero) | |
$Infinity | Infinity | |
$-Infinity | -Infinity | |
$NaN | NaN | |
$undefined | undefined | |
$$ | Escaped $ | When strings contain $ |
This covers all types that JSON cannot represent: undefined, NaN, Infinity, -0, BigInt, Date, etc. The type support range is much broader than typical JSON-RPC.
Server functions themselves can be passed as arguments to other server functions. In this case, the function's ID and bound arguments are found in the knownServerReferences WeakMap and serialized as a $h token.
// Inside resolveToJSON
if (typeof value === 'function') {
const referenceClosure = knownServerReferences.get(value)
if (referenceClosure !== undefined) {
const {id, bound} = referenceClosure
const json = JSON.stringify({id, bound}, resolveToJSON)
if (formData === null) {
formData = new FormData()
}
const refId = nextPartId++
formData.set(formFieldPrefix + refId, json)
return serializeServerReferenceID(refId) // "$h" + hex
}
}
Passing regular functions (not server functions) that cannot be serialized will result in an error.
Serializable:
| Type | Notes |
|---|---|
string, number, bigint, boolean, undefined, null | Primitive types |
Symbol.for('name') | Only symbols registered in global registry |
Array, Map, Set, TypedArray, ArrayBuffer | Iterables |
Date, FormData, Promise | Built-in objects |
Plain objects ({}, { key: value }) | Created with object initializer |
| Server functions | Functions marked with "use server" |
Non-Serializable:
| Type | Reason |
|---|---|
| Regular functions, arrow functions | Code cannot cross network |
| Class instances | Cannot restore prototype chain |
| React elements (JSX) | Contains component functions |
| DOM event objects | Circular references, native objects |
Null prototype objects (Object.create(null)) | |
Symbol() not in global registry |
// ❌ Cannot pass event object
<button onClick={(e) => serverFn(e)}>
// ✅ Pass only necessary values
<button onClick={() => serverFn(someId)}>
When a POST request sent from the client arrives at the server, Next.js's handleAction function handles the entire lifecycle.
// next/src/server/app-render/server-action-request-meta.ts
function getServerActionRequestMetadata(req) {
const actionId = req.headers.get('next-action') // Action ID
const contentType = req.headers.get('content-type')
const isFetchAction = actionId && req.method === 'POST'
const isMultipartAction =
req.method === 'POST' && contentType?.startsWith('multipart/form-data')
const isURLEncodedAction =
req.method === 'POST' && contentType === 'application/x-www-form-urlencoded'
return {actionId, isFetchAction, isMultipartAction, isURLEncodedAction}
}
Server function requests come through two paths:
Next-Action header contains the Action ID.Next-Action header, but a $ACTION_ID_<hash> field exists in the FormData.// next/src/server/app-render/csrf-protection.ts
const originDomain = new URL(originHeader).host
const host = parseHostHeader(req.headers) // X-Forwarded-Host preferred, Host fallback
if (!originDomain) {
// Warning only — older browsers might not send Origin
} else if (!host || originDomain !== host.value) {
if (isCsrfOriginAllowed(originDomain, serverActions?.allowedOrigins)) {
// Domain allowed in next.config.js allowedOrigins
} else {
throw new Error('Invalid Server Actions request.') // CSRF blocked
}
}
Compares the Origin header with the Host header (or X-Forwarded-Host)5. Requests are rejected if they don't match. isCsrfOriginAllowed supports wildcard domain matching like *.example.com to accommodate reverse proxy environments.
No CSRF tokens are used. POST-only + Origin validation blocks most CSRF attacks, but note that if XSS exists, server functions can be called from the same origin.
This is the process of finding the actual function from the Action ID.
// next/src/server/app-render/action-handler.ts
function getActionModIdOrError(actionId, serverModuleMap) {
const actionModId = serverModuleMap[actionId]?.id
if (!actionModId) {
throw getActionNotFoundError(actionId) // Deployment skew or invalid request
}
return actionModId
}
// Function loading
const actionMod = await ComponentMod.__next_app__.require(actionModId)
const actionHandler = actionMod[actionId]
serverModuleMap is a manifest generated at build time6. It maps Action IDs (42-character hashes) to module paths. If this mapping doesn't exist — such as when requesting with an old build's ID to a new server — getActionNotFoundError is thrown. This is the version skew problem.
Version skew frequently occurs right after deployments. Users are viewing HTML from a previous build (with old Action IDs embedded in forms) while the server has been replaced with a new build. Since closure encryption keys also change with each build, bound arguments encrypted in the previous build cannot be decrypted.
There are several ways to handle this problem in production:
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: This environment variable can fix encryption keys across builds, preventing closure decryption failures. However, it doesn't solve the problem of Action IDs themselves changing.router.refresh() to reload the page and fetch new Action IDs from the new build.// For Fetch Actions
const args = await decodeReply(requestBody, serverModuleMap)
const result = await actionHandler.apply(null, args)
decodeReply is React's Flight deserialization function, the reverse process of processReply. It restores tokens like $D, $n, $h back to their original types.
When function execution completes, both the result and the updated UI are returned in a single response.
// When revalidation occurs
const flightResponse = await generateFlight({
actionResult: result,
// Also includes RSC Payload of changed pages
})
This is a key advantage of server functions. In traditional APIs, it's a 3-step process: "change → refetch → UI update", but with server functions, it's completed in a single round trip.
| Header | Direction | Purpose |
|---|---|---|
Next-Action | Request | Action ID (42-character hash) |
Content-Type | Request | multipart/form-data or text/plain |
Origin | Request | CSRF protection (compared with Host) |
Host / X-Forwarded-Host | Request | CSRF protection (compared with Origin) |
Cache-Control | Response | no-cache, no-store, max-age=0, must-revalidate |
x-action-redirect | Response | Redirect URL + type |
x-next-revalidated | Response | Cache invalidation directive |
Note that Cache-Control is always no-store. Server function responses are never cached because mutation results should not be cached.
When a server function defined inline within a Server Component captures external variables, these closure variables are encrypted and travel via the client. Let's dive into Next.js's actual implementation.
export default async function AdminPage() {
const secretConfig = await getSecretConfig()
async function updateConfig(formData: FormData) {
'use server'
// Captures secretConfig as closure
await db.config.update(secretConfig.id, {
value: formData.get('value'),
})
}
return <form action={updateConfig}>...</form>
}
secretConfig is sensitive data that should only exist on the server. However, due to how server functions work, closure variables must be sent to the client and then return to the server when called. Without encryption, you could view the contents of secretConfig in the browser's DevTools.
Next.js uses AES-GCM from the Web Crypto API7. This authenticated encryption method provides both confidentiality and integrity guarantees simultaneously.
// next/src/server/app-render/encryption-utils.ts
export function encrypt(key: CryptoKey, iv: Uint8Array, data: Uint8Array) {
return crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, data)
}
export function decrypt(key: CryptoKey, iv: Uint8Array, data: Uint8Array) {
return crypto.subtle.decrypt({name: 'AES-GCM', iv}, key, data)
}
// next/src/server/app-render/encryption-utils.ts
export async function getActionEncryptionKey() {
if (__next_loaded_action_key) {
return __next_loaded_action_key
}
const rawKey =
process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ||
serverActionsManifest.encryptionKey // Auto-generated at build time
__next_loaded_action_key = await crypto.subtle.importKey(
'raw',
stringToUint8Array(atob(rawKey)), // base64 decode
'AES-GCM',
true,
['encrypt', 'decrypt'],
)
return __next_loaded_action_key
}
Keys come from one of two sources:
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY environment variable (manual configuration)serverActionsManifest.encryptionKey (auto-generated at build time)Since a new key is generated with each build, closures encrypted by previous builds cannot be decrypted by the new build's server. This is the encryption aspect of the version skew problem discussed earlier. In deployment environments where multiple versions are served simultaneously, you should explicitly set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY to fix the key across builds. However, fixing the key creates a tradeoff between security and convenience, so managing key rotation cycles separately is recommended.
Let's look at the actual encryption process step by step.
// next/src/server/app-render/encryption.ts
async function encodeActionBoundArg(actionId: string, arg: string) {
const key = await getActionEncryptionKey()
// 1. Generate 16-byte random IV
const iv = new Uint8Array(16)
crypto.getRandomValues(iv)
// 2. Encrypt with actionId prepended to plaintext
// → Verify actionId matches during decryption (integrity check)
const encrypted = await encrypt(key, iv, textEncoder.encode(actionId + arg))
// 3. Return as base64(IV + ciphertext)
return btoa(arrayBufferToString(iv.buffer) + arrayBufferToString(encrypted))
}
Wire format: base64(IV_16bytes + AES_GCM_ciphertext)
Prepending the actionId to the plaintext is crucial. While AES-GCM itself provides integrity verification, including the actionId in the plaintext provides additional verification that "this ciphertext is really for this Action ID". This prevents attacks where encrypted closures from other server functions are pasted into this server function.
async function decodeActionBoundArg(actionId: string, arg: string) {
const key = await getActionEncryptionKey()
// 1. base64 decode → separate IV (16 bytes) + ciphertext
const payload = atob(arg)
const iv = stringToUint8Array(payload.slice(0, 16))
const ciphertext = stringToUint8Array(payload.slice(16))
// 2. Decrypt
const decrypted = textDecoder.decode(await decrypt(key, iv, ciphertext))
// 3. Verify actionId prefix
if (!decrypted.startsWith(actionId)) {
throw new Error('Invalid Server Action payload: failed to decrypt.')
}
// 4. Remove actionId → original serialized data
return decrypted.slice(actionId.length)
}
Encryption/decryption of closure variables operates on top of the React Flight Protocol. Instead of converting variables to bytes, it uses Flight's renderToReadableStream/createFromReadableStream for serialization/deserialization.
// During encryption
export const encryptActionBoundArgs = React.cache(async function (
actionId,
...args
) {
// 1. Serialize with Flight Protocol
const serialized = await streamToString(
renderToReadableStream(args, clientModules),
)
// 2. Encrypt with AES-GCM
return await encodeActionBoundArg(actionId, serialized)
})
// During decryption
export async function decryptActionBoundArgs(actionId, encryptedPromise) {
const encrypted = await encryptedPromise
// 1. Decrypt with AES-GCM
const decrypted = await decodeActionBoundArg(actionId, encrypted)
// 2. Deserialize with Flight Protocol
return await createFromReadableStream(
new ReadableStream({
start(controller) {
controller.enqueue(textEncoder.encode(decrypted))
controller.close()
},
}),
{
serverConsumerManifest: {
/* module maps */
},
},
)
}
The reason for using Flight Protocol is that closure variables can contain types that cannot be represented as JSON, such as server references, Date, Map, etc.
It's wrapped with React.cache so it gets cached when called with identical arguments by reference within the same rendering pass. However, since React.cache compares using reference equality (Object.is), cache hits won't occur if objects created anew each render are included in the arguments. It's most effective for module-scoped server functions that have no closures.
Values passed via .bind() are not encrypted. Looking at the bind implementation in ReactFlightWebpackReferences.js3, it only accumulates bound arguments in a $$bound array without going through the encryption path. While I couldn't find official statements from the React team, this appears to be intentional behavior based on the source code structure.
// Closure — encrypted
async function deletePost() {
'use server'
await db.posts.delete(post.id) // post.id is transmitted encrypted
}
// .bind() — not encrypted
const deletePostWithId = deletePost.bind(null, post.id)
// post.id is exposed to the client as plaintext
| Closure | .bind() | |
|---|---|---|
| Encryption | ✅ AES-GCM | ❌ Plaintext |
| Performance | Encryption/decryption overhead | No overhead |
| Use case | Can contain sensitive data | Public data only |
If you pass a secret token via .bind(), it will be exposed directly to the client. If secret values are needed, use closures or read them directly inside the server function.
The most important characteristic of the server function and <form> combination is that it works even without JavaScript. Let's see how this is implemented.
When a form is submitted before JavaScript loads, the browser performs a regular HTML form submission. Since it can't send the Next-Action header, the Action ID is hidden inside the FormData.
<!-- HTML rendered by server (conceptual) -->
<form method="POST" action="/posts">
<input type="hidden" name="$ACTION_ID_abc123def456..." value="" />
<input type="text" name="title" />
<button type="submit">Submit</button>
</form>
Special FormData field names used by Next.js:
| Field Prefix | Purpose |
|---|---|
$ACTION_ID_ | Action ID for server functions without binding (42 chars) |
$ACTION_REF_ | Reference for server functions with binding |
When the server receives this request, since there's no Next-Action header, it looks for fields starting with $ACTION_ID_ in the FormData to extract the Action ID.
When users submit forms while JavaScript is loading (before hydration), React queues them and replays them once hydration is complete.
1. Server renders HTML → sends to browser
2. User immediately submits form (JS not yet loaded)
3. Submission is stored in queue
4. JavaScript loads + hydration completes
5. Process queued submissions in order
This is why forms using server functions have the guarantee of being "safe to submit while loading". Using the third argument of useActionState (permalink), you can redirect to a specific URL to show results if submitted before hydration completes.
useActionState manages server function return values as state and tracks pending status.
'use client'
import {useActionState} from 'react'
import {createPost} from '@/app/actions'
function PostForm() {
const [state, submitAction, isPending] = useActionState(createPost, null)
return (
<form action={submitAction}>
<input type="text" name="title" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>
{state?.error && <p>{state.error}</p>}
</form>
)
}
'use server'
export async function createPost(previousState: any, formData: FormData) {
const title = formData.get('title') as string
if (!title) {
return {error: 'Please enter a title'}
}
await db.posts.create({title})
return {error: null}
}
Using useActionState changes the server function's signature. A previous state is added as the first argument. React internally stores the return value from the previous call and injects it as the first argument in the next call.
When calling server functions from event handlers rather than forms, you must wrap them with startTransition.
'use client'
import {useState, useTransition} from 'react'
import {incrementLike} from '@/app/actions'
function LikeButton({postId}: {postId: number}) {
const [likes, setLikes] = useState(0)
const [isPending, startTransition] = useTransition()
return (
<button
disabled={isPending}
onClick={() => {
startTransition(async () => {
const updatedLikes = await incrementLike(postId)
setLikes(updatedLikes)
})
}}
>
{isPending ? '...' : `Likes ${likes}`}
</button>
)
}
<form action> automatically uses transitions internally. For event handlers, you must explicitly wrap them. Without this, you can't track pending state and error boundaries won't work properly.
'use server'
import {revalidatePath} from 'next/cache'
export async function createPost(formData: FormData) {
await db.posts.create({title: formData.get('title') as string})
revalidatePath('/posts')
}
When revalidatePath is called, the server function's response includes the updated RSC Payload. The client handles both the data change results and UI updates with this single response.
'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}`)
}
redirect throws an exception internally. Since subsequent code won't execute, revalidatePath must be called before redirect. Next.js's action handler catches this exception and converts it to an x-action-redirect header.
Sequential execution of server functions is per individual client (browser tab), not server-wide. More precisely, React's client runtime dispatches server function calls one at a time. It doesn't send the second request until the server returns a response to the first request.
User A's browser: [action1] ──complete──> [action2] ──complete──> [action3]
User B's browser: [action1] ──complete──> [action2]
↑ Processed in parallel, independent of each other
From the server's perspective, requests from User A and User B are processed simultaneously. Sequential execution only applies within the same browser tab. If a user rapidly clicks a like button 3 times, React queues the second/third calls on the client side and sends them in order after the first response returns.
This is an implementation detail of React's client runtime and could change in the future. Currently, you can't make parallel server function calls from one tab, so if parallel processing is needed, you must use Promise.all within a single server function.
'use server'
// ❌ Even simultaneous calls from client are processed sequentially
// await Promise.all([publishPost(1), publishPost(2), publishPost(3)])
// ✅ Parallel processing within one server function
export async function batchPublish(ids: number[]) {
await Promise.all(ids.map((id) => db.posts.publish(id)))
}
Server Functions are inherently public API endpoints. The moment you write "use server", that function can be called by anyone via HTTP requests.
curl -X POST https://your-app.com/posts \
-H "Next-Action: abc123def456..." \
-H "Content-Type: multipart/form-data" \
-F "0=malicious data"
'use server'
// ❌ Trusting TypeScript types
export async function deletePost(id: number) {
await db.posts.delete(id) // id could be a string
}
// ✅ Runtime validation
import {z} from 'zod'
const schema = z.object({id: z.number().int().positive()})
export async function deletePost(id: unknown) {
const {id: validId} = schema.parse({id})
await db.posts.delete(validId)
}
Server Functions can be called independently of pages. Even if middleware blocks page access, Server Functions can still be called directly via POST requests. You must verify authentication/authorization inside the Server Function itself.
'use server'
import {getCurrentUser} from '@/lib/auth'
export async function deletePost(id: number) {
const user = await getCurrentUser()
if (!user) throw new Error('Authentication required')
const post = await db.posts.get(id)
if (post.authorId !== user.id && !user.isAdmin) {
throw new Error('Insufficient permissions')
}
await db.posts.delete(id)
}
Instead of calling the database directly from Server Functions, it's recommended to have a separate data access layer8.
// data/posts.ts
import 'server-only'
import {getCurrentUser} from './auth'
export async function deletePostById(id: number) {
const user = await getCurrentUser()
if (!user) throw new Error('Unauthorized')
const post = await db.posts.get(id)
if (post.authorId !== user.id && !user.isAdmin) {
throw new Error('Forbidden')
}
await db.posts.delete(id)
}
// app/actions.ts
'use server'
import {deletePostById} from '@/data/posts'
export async function deletePost(id: number) {
await deletePostById(id) // Auth/authz handled in data layer
}
Security audit scope narrows to the data layer. Even with 100 Server Functions, security logic can be managed in one place.
In production mode, React doesn't forward server error messages to the client9. Only an identifying hash is transmitted. This prevents messages like [credit card number] is not a valid phone number from being exposed.
In development mode, errors are forwarded to the client as-is. Production must always run in production mode.
import 'server-only'
export async function getSecretData() {
return process.env.SECRET_KEY
}
Importing this module in a Client Component will cause a build error. While "use server" creates boundaries for Server Functions, it's safer to explicitly mark utility functions called by Server Functions with server-only.
Server Functions are designed for mutations1.
// ❌ Data fetching with Server Functions
'use server'
export async function getPosts() {
return await db.posts.findMany()
}
// On the client
useEffect(() => {
getPosts().then(setPosts)
}, [])
Problems with this pattern:
// ✅ Direct data fetching in Server Components
export default async function PostsPage() {
const posts = await db.posts.findMany()
return <PostList posts={posts} />
}
The role of Server Functions is clear: changing server state due to user actions. Form submissions, likes, deletions, updates. Beyond this scope, there are more suitable tools.
When Server Components pass Server Functions as props to Client Components, let's see how Server Functions are encoded in the Flight stream.
In ReactFlightServer.js10, when encountering a function value, it checks if it's a server reference with isServerReference.
// react-server/src/ReactFlightServer.js
if (typeof value === 'function') {
if (isClientReference(value)) {
return serializeClientReference(request, parent, parentPropertyName, value)
}
if (isServerReference(value)) {
return serializeServerReference(request, value)
}
// Function that's neither server nor client reference → error
}
isServerReference simply checks the $$typeof property.
export function isServerReference(reference) {
return reference.$$typeof === Symbol.for('react.server.reference')
}
serializeServerReference creates a metadata object from the function's $$id and $$bound, and outlines it as a separate Flight chunk.
function serializeServerReference(request, serverReference) {
const existingId = request.writtenServerReferences.get(serverReference)
if (existingId !== undefined) {
return '$h' + existingId.toString(16) // Reuse already processed reference
}
const id = getServerReferenceId(request.bundlerConfig, serverReference)
const bound = getServerReferenceBoundArguments(
request.bundlerConfig,
serverReference,
)
const metadata = {
id,
bound: bound === null ? null : Promise.resolve(bound),
}
const metadataId = outlineModel(request, metadata)
request.writtenServerReferences.set(serverReference, metadataId)
return '$h' + metadataId.toString(16)
}
In the actual Flight stream, it looks like this:
5:{"id":"abc123#deletePost","bound":null}
0:["$","form",null,{"action":"$h5"}]
5: chunk contains the Server Function metadata"$h5" → Points to "server reference in chunk 5"When the client Flight parser (ReactFlightClient.js)11 encounters a $h token, it calls loadServerReference.
// Inside parseModelString
case 'h': {
const ref = value.slice(2)
return getOutlinedModel(response, ref, parentObject, key, loadServerReference)
}
loadServerReference takes the metadata's id and bound and creates a callable function with createBoundServerReference. This function is a proxy that executes callServer(id, args) when called.
What's hidden behind that single line of "use server":
Build time: registerServerReference injects $$typeof, $$id, $$bound into functions. .bind() is overridden to accumulate bound arguments. Only proxy functions calling callServer remain in the client bundle.
Serialization: processReply serializes arguments to JSON or FormData. Over 20 prefix tokens like $h, $D, $n overcome JSON's limitations.
Network: Always POST. 42-character hash ID in Next-Action header. CSRF blocked by Origin vs Host comparison. Response is no-store.
Server processing: Find function by ID in serverModuleMap, restore arguments with decodeReply, execute, return result and updated UI together with generateFlight.
Closure encryption: AES-GCM encryption. Keys auto-generated per build. Action ID included in plaintext for integrity verification. .bind() isn't encrypted.
Progressive Enhancement: Hide Action ID in FormData with $ACTION_ID_ prefix. Pre-hydration submissions are queued and replayed.
Server Functions are convenient, but that convenience stands on 40 years of RPC history, build-time code transformation, Flight Protocol, AES-GCM encryption, and CSRF protection. Understanding all these layers reveals why that single line of "use server" is so heavy.
React Official Documentation, Server Functions ↩ ↩2 ↩3
Fallacies of Distributed Computing, Wikipedia ↩
React v19.2.0 source, ReactFlightWebpackReferences.js ↩ ↩2
React v19.2.0 source, ReactFlightReplyClient.js ↩ ↩2
Next.js v16.1.7 source, csrf-protection.ts ↩
Next.js v16.1.7 source, action-handler.ts ↩
Next.js v16.1.7 source, encryption-utils.ts, encryption.ts ↩
Next.js Blog, How to Think About Security in Next.js ↩
React Official Documentation, "use server" ↩
React v19.2.0 source, ReactFlightServer.js ↩
React v19.2.0 source, ReactFlightClient.js ↩