skip to content

In a production Next.js build, a Server Action invoked from a form throws an unexpected exception — a database call failed. What does the browser actually receive, where do you find the real error, and what does that imply about how the action should report an expected validation failure?

level: seniorimportance: should knowfreq 40%

answer

  1. dev shows it, production does not
  2. message and stack are withheld
  3. a hash correlates client and server
  4. errors carry server-side detail
  5. expected failures are results, not throws

basics

~20 s

Production builds do not send the message or stack to the browser: the client sees a generic error carrying a digest that matches a server-side log entry. Expected failures should be returned as values from the action, not thrown.

solid answer

~50 s

In a production build Next deliberately withholds the error's message and stack from the client, because an exception raised on the server can easily contain a query, a file path, or an internal hostname. What the browser gets is a generic error carrying a `digest` — a hash you correlate with the full error logged on the server. In development you see the real message and stack instead, which is why this surprises teams only after they deploy. The design consequence matters more than the mechanic: a thrown error is, by construction, useless to the user, so anything you *expect* to happen — a failed validation, a duplicate email, a version conflict — should be `return`ed from the action as a serializable result the UI can render, and only genuinely exceptional failures should be allowed to throw. Treat a throw as an operator signal, not a user-facing message.

go deeper

for a junior

Know that an unexpected error inside a Server Action is not shown to the user in production — you see the full message only while developing locally.

for a middle

Explain the two halves: the client receives a generic error carrying a digest, while the real message and stack are logged on the server under that same digest for correlation.

for a senior

Turn it into a design rule — expected outcomes are returned as serializable results so the UI can render them, throws are reserved for operator-facing failures, and a caught error's message is never forwarded to the browser.

for a principal

Own the failure contract across the codebase: a consistent result shape for expected outcomes, digests captured by client error reporting, server logs actually shipped and queryable, and a review habit that treats a forwarded error message as a disclosure defect.

## What the client is given Split the behaviour by build. **In development**, an uncaught error inside a Server Action shows up with its real message and stack — in the overlay and in the terminal. Debugging feels normal. **In production**, the message and stack are withheld from the client on purpose. The browser receives a generic error object that carries a `digest` property: a hash identifying that specific error occurrence. The full error, with its message and stack, is emitted on the server, tagged with the same digest. Your job during an incident is to take the digest the user or your client-side error reporter captured and grep the server logs for it. ## Why redaction is the right default Errors thrown on the server carry server context. A failed query can echo the SQL and column names, a filesystem error carries absolute paths that reveal the deployment layout, a network error names an internal hostname or port, and an ORM's constraint violation can quote the offending value — which may itself be another user's data. Shipping those strings to a browser is a real information-disclosure path, and no team reliably audits every possible throw site for what it might reveal. Redacting by default and opting in per message is the only version of this that holds up. ## The design consequence Once you accept that a throw produces something the user cannot act on, the API design falls out. Distinguish two categories: **Expected outcomes.** The email is already registered. The password is too short. The document changed since you loaded it. The coupon expired. None of these are exceptions — they are results. `return` them, as a serializable object the UI can render: ```ts 'use server' export async function signUp(_prev: unknown, formData: FormData) { const email = String(formData.get('email') ?? '') if (!email.includes('@')) { return { ok: false, fieldErrors: { email: 'Enter a valid email address.' } } } // ... return { ok: true } } ``` The returned value comes back in the action's response and is available to render inline next to the field. It survives production untouched, because it is data you chose to send, not an error that was redacted. **Unexpected failures.** The database is unreachable, a dependency threw, an invariant broke. Let those throw. They become a redacted client-side error plus a full server log entry — exactly the right split, because the user can do nothing but retry and the operator needs the stack. A useful heuristic when reviewing an action: if you can write the sentence the user should read, the case belongs in a `return`. If you cannot, it belongs in a throw. ## Getting a message to the user without leaking When a caught internal failure does need user-visible text, write the text yourself rather than forwarding the caught error: ```ts try { await db.order.create({ data }) } catch (err) { console.error('order create failed', err) // full detail, server side return { ok: false, message: 'We could not place your order. Please try again.' } } ``` That gives the user a sentence, the operator a stack, and the attacker nothing. ## Operational hygiene - **Capture the digest client-side.** Whatever renders your failure state should record the digest into your error reporter; without it, correlation with server logs is guesswork. - **Do not re-throw a caught error just to "pass it along".** It is redacted all the same, and you have lost the chance to return something useful. - **Do not develop against the dev overlay only.** The failure UX you should be reviewing is the production one, which is much less informative than what you have been staring at. - **Check that server logs actually leave the machine.** The redaction model is only workable if the full error lands somewhere queryable in your hosting environment. ## Common misconception The frequent wrong answer is "catch the error in the action and return `err.message` so the user sees it". That reintroduces exactly the leak the redaction exists to prevent — now under your own code, where no framework can protect you — and it produces user-facing copy written by a database driver.

  • So how does the user get a specific, useful message about a duplicate email?
    By the action returning it. The result object comes back in the same response as the invocation and the UI renders it next to the field — no throw involved, so nothing is redacted. Write the copy yourself in the action rather than surfacing the underlying constraint-violation message, which is written for operators and can quote internal detail.
  • Does the digest give an attacker anything useful?
    No. It identifies an occurrence so you can find the matching server log entry; it is not the message and it does not encode one. Its only real risk is the mild one that two identical failures produce the same digest, which tells an observer that the same failure recurred — not what it was.
  • A colleague catches every error in the action and returns err.message so users see something specific. What is your objection?
    It defeats the redaction deliberately. The caught error can carry SQL, absolute paths, internal hostnames or another user's data, and it is now being rendered in a browser by your own code, where no framework default protects you. Log the caught error in full on the server and return copy you wrote for a human instead.

saying these in an interview costs you the question

  • Expects the production client to show the real error message
  • Catches the error and returns err.message to the user
  • Thinks the digest is an encoded form of the message
  • Throws for validation failures and wonders why nothing renders
  • Reviews only the development overlay's failure experience

context