This one catches people specifically because the code looks like careful error handling, and it's actually the thing breaking the exact feature it's wrapped around.
The Setup That Looks Like Responsible Error Handling
// actions/auth.ts
'use server';
export async function login(formData: FormData) {
try {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const user = await verifyCredentials(email, password);
if (!user) {
return { error: 'Invalid credentials' };
}
await setSessionCookie(user);
redirect('/dashboard');
} catch (error) {
console.error('Login failed:', error);
return { error: 'Something went wrong' };
}
}
This looks like exactly the kind of defensive coding you'd want, wrap the risky operations, catch anything unexpected, return a clean error instead of crashing. It also means a successful login never actually redirects anywhere, and the console gets a confusing, misleading error log every single time someone logs in correctly.
Why This Happens
redirect() in Next.js doesn't work like a normal function returning a value that tells the framework "please navigate now." It works by throwing a special internal error, effectively an escape hatch, that Next.js's own machinery catches at a higher level in the rendering or request lifecycle, and uses to actually trigger the navigation. This is genuinely how the feature is implemented under the hood, not a bug, it's a deliberate mechanism for interrupting normal execution flow to redirect.
A try/catch block doesn't know or care that this particular thrown error is special. It catches everything thrown within its boundary, indiscriminately, which includes this internal redirect signal exactly the same way it would catch a genuine database error or a network failure. Your catch block intercepts it, logs a confusing "Login failed" message for what was actually a completely successful login, and returns your generic error response instead of ever letting the redirect signal propagate up to where Next.js needs to see it.
Why the Console Log Makes This Extra Confusing
The logged error typically has a distinctive-looking message or code related to Next.js's internal redirect mechanism, not a normal JavaScript error you'd immediately recognize. Someone unfamiliar with this specific gotcha sees an unfamiliar-looking error logged on every successful login and reasonably assumes something is actually broken, when the "error" being caught is, ironically, the successful outcome trying to happen.
The Actual Fix: Call redirect() Outside the try/catch
// actions/auth.ts
'use server';
export async function login(formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
let user;
try {
user = await verifyCredentials(email, password);
} catch (error) {
console.error('Login failed:', error);
return { error: 'Something went wrong' };
}
if (!user) {
return { error: 'Invalid credentials' };
}
await setSessionCookie(user);
redirect('/dashboard'); // outside any try/catch, free to throw its internal signal cleanly
}
Scoping the try/catch specifically around the operation that can genuinely fail in an unexpected way, the credential verification, database call, whatever's actually risky, and calling redirect() afterward, outside that block entirely, means its internal throw mechanism reaches Next.js cleanly, without your own error handling accidentally standing in the way.
If You Need Try/Catch Around Code That Includes a redirect() Call
Sometimes the structure genuinely requires a broader try block. In that case, explicitly re-throw anything that looks like a Next.js redirect or notFound signal, rather than treating everything caught as a genuine error:
import { isRedirectError } from 'next/dist/client/components/redirect';
try {
// some code that includes a redirect() call somewhere within it
} catch (error) {
if (isRedirectError(error)) {
throw error; // let it propagate, this isn't a real error
}
console.error('Actual error:', error);
return { error: 'Something went wrong' };
}
This pattern is more defensive but also more fragile, since it depends on an internal Next.js utility rather than a fully public, stable API. Restructuring the code to keep redirect() outside any try/catch entirely, the first fix above, is the cleaner, more robust solution whenever the code can reasonably be organized that way.
Where Else This Bites
The exact same issue applies to notFound(), which uses the same underlying throw-based mechanism. Any Server Action, Server Component, or route handler with a broad try/catch wrapping code that also calls redirect() or notFound() anywhere within that boundary is at risk of this exact silent failure, not just login flows specifically.
The Actual Rule
Never let a try/catch block unintentionally wrap a call to redirect() or notFound(). Scope error handling tightly around the specific operations that can genuinely throw a real error, and keep redirect and not-found calls outside that boundary, or explicitly detect and re-throw them if the structure genuinely requires a broader catch.
If you've got a Server Action with a broad try/catch that also calls redirect() somewhere inside it, go check whether that redirect is actually firing, or silently getting logged as a mysterious error instead. Drop what you find in the comments.
Get the templates: https://pixelanas.gumroad.com
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)