Here is a bug report you will eventually get: "The signup form told me what was wrong on your machine, but on the live site it just says something went wrong."
Nothing is broken. Next.js is doing this on purpose. But the code that triggers it looks completely normal, and it is in a lot of Server Actions tutorials, including a version in my own earlier post on replacing API routes.
The Code That Works Locally
// actions/signup.ts
'use server';
export async function signup(formData: FormData) {
const email = formData.get('email') as string;
const existing = await User.findOne({ email });
if (existing) {
throw new Error('That email is already registered');
}
await User.create({ email });
}
// components/SignupForm.tsx
'use client';
export function SignupForm() {
const [error, setError] = useState('');
async function handleSubmit(formData: FormData) {
try {
await signup(formData);
} catch (e) {
setError((e as Error).message);
}
}
return (
<form action={handleSubmit}>
<input name="email" type="email" />
<button type="submit">Sign up</button>
{error && <p>{error}</p>}
</form>
);
}
In development, a duplicate email shows "That email is already registered". After next build and next start, or on Vercel, the same submission shows a generic message about an error occurring on the server, with a short digest string instead of your text.
Why Next.js Hides It
An error thrown on the server can carry anything: a database connection string inside a driver message, a file path, a query, a stack trace. If Next.js forwarded every thrown message to the browser, a single unexpected failure could leak internal details to anyone watching the network tab.
So in production builds, Next.js replaces the message of a thrown error with a generic one and attaches a digest, a short hash you can search for in your server logs. In development it shows you the real message because you are the only one looking.
That is the correct default. The mistake is using throw for something that is not an unexpected failure.
Two Kinds of Errors, Two Different Tools
Expected errors are things that will happen during normal use: a duplicate email, a wrong password, a failed validation, a card that was declined. The user should see a specific message and fix it.
Unexpected errors are things that should not happen: the database is down, a bug, a null that should not be null. The user should see a safe generic message and you should see the details in your logs.
throw is the right tool for the second kind. It is the wrong tool for the first, because the framework treats every thrown error as potentially sensitive and strips it.
The Fix: Return Expected Errors as Data
// actions/signup.ts
'use server';
import { z } from 'zod';
const SignupSchema = z.object({
email: z.string().email('Enter a valid email'),
});
export type SignupState = {
success: boolean;
message?: string;
fieldErrors?: Record<string, string[]>;
};
export async function signup(
prevState: SignupState | null,
formData: FormData
): Promise<SignupState> {
const parsed = SignupSchema.safeParse({
email: formData.get('email'),
});
if (!parsed.success) {
return {
success: false,
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
const existing = await User.findOne({ email: parsed.data.email });
if (existing) {
return { success: false, message: 'That email is already registered' };
}
await User.create({ email: parsed.data.email });
return { success: true, message: 'Account created' };
}
// components/SignupForm.tsx
'use client';
import { useActionState } from 'react';
import { signup } from '@/actions/signup';
export function SignupForm() {
const [state, action, isPending] = useActionState(signup, null);
return (
<form action={action}>
<input name="email" type="email" />
{state?.fieldErrors?.email && <p>{state.fieldErrors.email[0]}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating account...' : 'Sign up'}
</button>
{state?.message && <p>{state.message}</p>}
</form>
);
}
A returned value is just data. Next.js does not treat it as sensitive, so the exact same message reaches the user in development and in production. You also get useActionState handling pending state and the previous result for free, with no try/catch in the component.
Keep throw for the Real Failures
export async function signup(prevState, formData) {
// ...expected errors returned as data above...
try {
await User.create({ email: parsed.data.email });
} catch (error) {
console.error('signup failed', error); // full details stay in your logs
throw error; // user gets the safe generic message
}
return { success: true, message: 'Account created' };
}
When this throws in production, the user sees the generic message and an error boundary can take over. You search your logs for the digest and find the real error. Nothing sensitive reaches the browser.
Why This Survives Testing
Every manual test happens in next dev, where thrown messages are shown in full. The code looks perfect right up until the first production build, which is usually when real users start signing up.
The Rule
Return expected errors as data, throw only for unexpected failures. If a message is meant for the user to read, it should travel as a returned value. If it is meant for you to debug, it should go to your logs and surface to the user as something generic.
I use this return-a-state pattern in the forms and dashboard actions across my Next.js templates, so validation messages behave identically in development and on the live site.
Get the templates: https://pixelanas.gumroad.com
Search your own codebase for throw new Error inside files marked 'use server'. For each one, ask whether a user is supposed to read that message. If yes, it is probably already invisible in production. Drop what you find in the comments.
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)