In a Next.js Server Component you define an inline 'use server' action that closes over a value computed during render, and pass it to a form. Where does that captured value live between render and the moment the user submits, and what does that imply about what you may close over?
answer
- render and submit are different requests
- closures compile to bound arguments
- the value round-trips through the browser
- encrypted is not the same as server-only
- capture the id, not the entity
basics
~20 sThe captured value does not stay on the server. Next serializes it into the payload sent to the browser and the browser sends it back when the action is invoked, so it must be serializable, and a secret closed over this way has effectively been handed to the client.
solid answer
~50 sAn inline action's closed-over variables become **bound arguments**, not server-side state. There is no per-user session holding them between render and submit: Next serializes each captured value into the RSC payload delivered to the browser, and the browser returns it with the POST that invokes the action, where it arrives as a leading parameter. Two consequences follow. First, only values React can serialize may be captured — a database client, a class instance or a closure over a request object will fail. Second, the value has made a round trip through the client, so treat it as client-influenced input rather than trusted render-time state; anything a user must not see or must not choose does not belong in a closure. Next encrypts these bound values so they are opaque in the page source, which stops casual reading but does not make them server-only, and in a multi-instance deployment every instance must share the same encryption key or a payload created by one build cannot be decrypted by another.
code
typescript · 9 lines// Capture an identifier, not the whole entity.
// app/actions.ts
'use server'
export async function publishPost(postId: string) {
// authorisation is established HERE, at invocation time,
// not inferred from the fact that the closure existed
return { published: postId }
}go deeper
Know that an inline action can use values from the component that defined it, and that those values have to be simple serializable data like an id — not a database client or a class instance.
Explain the mechanism: captured variables are compiled into bound arguments, serialized into the payload sent to the browser, and returned with the invoking POST. Show that .bind on a module-level action is the same transport written explicitly.
Demonstrate the trust conclusion — a bound value round-trips through the client, so secrets never go in a closure and no authorisation decision may rest on one — and connect it to payload size and to what happens when a stale tab submits.
Own the operational side: how encryption keys are managed so several instances and successive builds interoperate, how rolling deploys are handled when clients hold payloads from an old build, and what the team's convention is for what may ever be captured.
## The question behind the question Inline actions read like ordinary JavaScript closures: ```tsx export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params const post = await db.post.findUnique({ where: { id } }) async function publish() { 'use server' await db.post.update({ where: { id: post.id }, data: { published: true } }) } return <form action={publish}><button>Publish</button></form> } ``` But the render and the submit are two separate HTTP requests, potentially minutes apart and potentially served by different machines. Nothing in the server process is holding `post` for you. So where did it go? ## Closures become bound arguments When Next compiles an inline action, it rewrites the captured variables into leading parameters of the underlying server function and *binds* the current values at render time. Those bound values are serialized into the RSC payload that goes to the browser along with the action's id. When the form submits, the browser POSTs the id, the bound values and the form data; the server resolves the id, unpacks the bound values and calls the real function with them. The explicit spelling of the same mechanism is `Function.prototype.bind` against a module-level action: ```tsx // app/actions.ts 'use server' export async function publishPost(id: string) { /* ... */ } ``` ```tsx // in a Server Component <form action={publishPost.bind(null, post.id)}> <button>Publish</button> </form> ``` This is not a different feature. It is the same transport, written where a reviewer can see it — which is a good reason to prefer it when the captured value matters. ## Consequence one: serializability Because the value crosses the network twice, it must be serializable by React's serialization. Strings, numbers, booleans, `null`, plain objects and arrays of those, `Date`, `Map`, `Set`, typed arrays, promises and other server functions travel; a Prisma client, a class instance with methods, a function that is not itself a server function, or a whole ORM row carrying lazy accessors does not. Capturing a large object also inflates every page that renders it — a list of 200 rows that each capture the full row rather than `row.id` pays for 200 serialized rows in the HTML. The practical rule is to close over an **identifier**, not an entity: `post.id`, not `post`. ## Consequence two: trust This is the part senior candidates are expected to reach. The bound value is not server state that the client happens to trigger; it is data the client is holding and returning. Next encrypts bound arguments, so the value is not sitting in the page source in plaintext and a user cannot trivially rewrite it — but encryption is a confidentiality and tamper control on a value that has still left your server. Two rules follow: - **Never close over a secret.** An API key, an internal connection string, a signed token you did not intend to expose, a flag that means "this caller is an admin" — none of these should ride in a closure. Read them inside the action body instead, where they never leave the server. - **Never let a closure carry the authorisation decision.** "I only rendered this action for owners, so `ownerId` in the closure proves ownership" is not an argument the runtime supports. The action is an endpoint; whatever it needs to establish about the caller it establishes in its own body, from the request context, at invocation time. ## Consequence three: deployment Because the bound values are encrypted, decryption must succeed on whatever machine receives the POST. Next derives an encryption key at build time; in a deployment with several instances they must all be running the same build, or share the key explicitly through configuration, otherwise a payload produced by one build fails to decrypt on another and the action errors. The same class of problem hits an old tab during a rolling deploy: the page in the browser was rendered by the previous build, and the ids and bound payloads it holds may not be valid against the new one. Design mutations to fail visibly and be retryable rather than to silently half-apply, and expect a stale client to need a reload. ## What to say in an interview Summarise it as: *inline closures are bound arguments that round-trip through the client, encrypted but not server-resident; capture ids, never secrets, and re-establish trust inside the action*. Then mention `.bind` as the readable equivalent. This behaviour is what Next.js 16 does, and it has been the model since Server Actions stabilised in Next.js 14.
- A teammate closes over a `stripeSecretKey` read from the environment inside an inline action. What exactly is wrong, given the value is encrypted?The key is serialized into the page payload and travels to every browser that renders it. Encryption stops casual reading, but the secret has left the trust boundary and its exposure now depends on that key management rather than on it never leaving. Read the environment variable inside the action body, where it stays on the server.
- During a rolling deploy, a user submits a form on a tab rendered by the previous build. What can go wrong?The page holds an action id and a bound payload produced by the old build. If the receiving instance is on the new build, the id may no longer exist or the payload may not decrypt, and the action fails. Make mutations idempotent and surface a retry or reload rather than assuming every in-flight submit lands.
- Why is closing over `post` rather than `post.id` a performance problem and not just a style choice?The whole object is serialized into the payload once per rendered action. A list of 200 rows that captures each full row ships 200 serialized rows in the HTML instead of 200 short strings, inflating the document and the client's memory for data the action could re-fetch by id in microseconds.
- Can an inline action close over a function defined in the same component?Only if that function is itself a server function; an ordinary local function is not serializable and the capture fails. In practice, move shared logic into a module the action imports rather than trying to capture it — imports resolve on the server and never enter the payload.
A closed-over value is less like a note kept in the server's drawer and more like a sealed envelope handed to the visitor on the way out: they cannot read it, but they carry it, and they hand it back when they return.
saying these in an interview costs you the question
- Assumes the closed-over value stays on the server
- Says encryption makes the captured value server-only
- Treats a captured ownerId as proof of authorisation
- Closes over a whole ORM row or a database client
- Thinks bind() and a closure use different transports