A Next.js file whose first line is 'use server' exports an async createUser function, a MAX_TITLE_LENGTH constant and a small synchronous formatName helper. The build fails. Why, and what is the correct fix?
answer
- exports become endpoints
- an endpoint has to be callable
- imports are unrestricted, exports are not
- don't make the helper async to appease it
- split the file, don't bend it
basics
~20 sA module marked 'use server' may export only async functions, because Next turns every export into a network-callable endpoint and a constant or synchronous function cannot be one. Move the constant and the helper into a separate module and import them.
solid answer
~50 sThe directive applies to the whole file, so Next tries to publish *every* export as a server function with its own generated id. A callable endpoint is inherently asynchronous — the browser reaches it over the network — so the compiler enforces that each export is an `async` function, and it fails the build with an error saying only async functions may be exported from a file carrying the directive. `MAX_TITLE_LENGTH` and `formatName` are not functions the client can call, so there is nothing sensible for Next to generate. The fix is not to make `formatName` async: that would publish it as a real endpoint, which you do not want. Move both into an ordinary module — say `lib/validation.ts` — with no directive, and import them from the action file. Importing into a `'use server'` module is fine; only *exports* are constrained.
code
typescript · 6 lines// lib/user-rules.ts — plain module, no directive
export const MAX_TITLE_LENGTH = 120
export function formatName(n: string): string {
return n.trim().replace(/\s+/g, ' ')
}go deeper
Recall the rule as stated: in a file starting with 'use server', every export must be an async function. Be able to spot the offending exports in a snippet.
Explain why the rule follows from the directive publishing each export as a network endpoint, and state the fix — move non-action exports into a plain module — rather than making the helper async.
Talk about the file-layout discipline this implies: action modules hold handlers only, so the generated endpoint surface stays intentional and reviewable, and shared rules stay importable from both sides of the boundary.
Be ready to turn the rule into a codebase convention and enforce it — where action modules may live, what a lint rule or review checklist should reject, and how you keep shared domain rules in one place when both server actions and client-side validation need them.
## Why the rule exists A module-level `'use server'` is a statement about the module's public interface: *everything this file exports is callable from the client over the network*. Next honours that literally. At build time it walks the exports and, for each one, generates an id and a client-side stub that POSTs back to the server. That only makes sense for an async function: - A network round trip cannot be synchronous, so the stub must return a promise. A function declared `async` already has that shape; a synchronous one would silently change its contract on the client side. - A constant is not callable at all. There is no id to invoke, and Next deliberately does not "just export the value" — that would quietly ship a server-side value into the client graph, which is exactly what the boundary exists to prevent. So the compiler rejects the file rather than guessing, with an error to the effect that only async functions may be exported from a file with the `'use server'` directive. ## The failing file ```ts // app/user-actions.ts 'use server' export const MAX_TITLE_LENGTH = 120 // not a function → build error export function formatName(n: string) { // not async → build error return n.trim() } export async function createUser(formData: FormData) { /* ok */ } ``` ## The wrong fixes **Making `formatName` async.** It compiles, and now you have published a pure string helper as a public POST endpoint. Worse, every existing caller that used it as a plain function now receives a promise, and if a Client Component imports it the call becomes a network round trip to trim a string. **Dropping the module-level directive and marking only `createUser` inline.** This does work mechanically — inline `'use server'` inside the function body scopes the directive to that function, and the other exports become ordinary module exports again. But it is the wrong instinct here, because those exports are still in a file whose job is mutations, and if a Client Component imports `MAX_TITLE_LENGTH` from it the whole module (including the action's imports, unless tree-shaken) gets pulled toward the client graph. Keep concerns in separate files. **Un-exporting them.** A non-exported constant or helper used only inside the actions is perfectly legal in a `'use server'` module — the rule is about exports, not about what the file contains. That is a fine fix when nothing else needs the value. ## The right fix Split the module along the boundary the directive draws: ```ts // lib/user-rules.ts — no directive, ordinary module export const MAX_TITLE_LENGTH = 120 export function formatName(n: string) { return n.trim() } ``` ```ts // app/user-actions.ts 'use server' import { MAX_TITLE_LENGTH, formatName } from '@/lib/user-rules' export async function createUser(formData: FormData) { const name = formatName(String(formData.get('name') ?? '')) if (name.length > MAX_TITLE_LENGTH) return { ok: false as const } // ... return { ok: true as const } } ``` Imports are unrestricted: a `'use server'` file can import anything, including plain constants and synchronous helpers. Only its exports are constrained, because only its exports become endpoints. ## The habit to take away Treat a `'use server'` module the way you would treat a public API route file: it contains handlers and nothing else. Every time you are tempted to add "just one small export" to it, you are adding a public POST endpoint. Constants, schemas, types and pure helpers belong next door.
- Is it legal to define a non-exported synchronous helper inside a 'use server' module?Yes. The constraint applies to exports only, because only exports become callable endpoints. A private helper, a local constant, or an imported schema can live in the file freely. It is exporting it that forces Next to publish it, and that is what the build rejects.
- What would go wrong if you 'fixed' the sync helper by marking it async?The build passes and you have published a pure helper as a POST endpoint with its own id. Every call site now gets a promise instead of a string, and a call from a Client Component becomes a network round trip for work that should happen locally. It trades a build error for a live design defect.
- Can a 'use server' module have a default export?Yes, provided it is an async function — the rule is about the shape of each export, not its name. A default-exported async function is published just like a named one, with its own generated id.
saying these in an interview costs you the question
- Makes the helper async so the build passes
- Thinks the directive restricts imports as well as exports
- Says the constant is silently dropped from the bundle
- Believes a non-exported sync helper is also illegal
- Claims exports are fine as long as no form uses them