Part 1 of 5 in PHP to Next.js

return $this->redirectToRoute vs redirect() — Why a PHP Reflex Cancels Your Navigation in Next.js

In PHP, a redirect is a return value. In Next.js, it's an exception. Mix the two in a try/catch and the redirect silently disappears.

In PHP, a redirect is a return value. You call a method, it returns a Response object, you return that response, and the framework handles the rest. In Next.js, redirect() from next/navigation works the opposite way: it throws a special error (NEXT_REDIRECT), and the framework catches it at the boundary to send an HTTP 307 response to the browser.

If you come from PHP, this difference is invisible in the code. You write what looks like the same thing — and your redirect never executes.

The PHP reflex

Here is how you guard a page in Symfony:

php
public function login(): Response
{
    if ($this->isGranted('IS_AUTHENTICATED_FULLY')) {
        return $this->redirectToRoute('dashboard');
    }
    // render the login form
}

redirectToRoute() returns a RedirectResponse. The return keyword is load-bearing — the method returns a response object, the controller exits implicitly, and the framework sends the redirect. You can put this inside a try/catch without issue because nothing throws.

The same reflex in Laravel:

php
if (Auth::check()) {
    return redirect('/dashboard');
}

Same pattern. redirect() returns a RedirectResponse. The return does the work. Try/catch around it changes nothing.

The Next.js difference

Now consider this guard in a Next.js layout, written by the same developer who wrote the Symfony version above:

tsx
try {
  decodeToken(jwt)          // ← might throw if the token is malformed
  redirect('/dashboard')    // ← should navigate away — but doesn't
} catch {
  // token was invalid, stay on this page
}

The code reads correctly: try to decode the token, if it works redirect to the dashboard, if it fails catch the error and stay put. A PHP developer would write this without a second thought — it's the exact same structure as a Symfony controller guard, just in TypeScript.

Here is what actually happens:

  1. decodeToken(jwt) succeeds.
  2. redirect('/dashboard') is called.
  3. redirect() throws NEXT_REDIRECT — it never returns.
  4. The catch block catches NEXT_REDIRECT just like any other error.
  5. The catch block does nothing (the token was valid — no error to handle).
  6. Execution continues past the if block.
  7. The login form renders. The user stays on /login.

No console error. No log. The redirect simply never happens, because the framework's redirect mechanism (throwing an error) was intercepted by userland code before the framework could see it.

The fix is to separate the fallible operation from the redirect:

tsx
let isValid = true
try {
  decodeToken(jwt)
} catch {
  isValid = false
}

if (isValid) {
  redirect('/dashboard')   // ← outside try/catch — framework sees it
}

Two worlds, one language

The difference is not in the syntax — both PHP and TypeScript use try, catch, and throw. The difference is in what a redirect is:

PHP (Symfony/Laravel)Next.js
Redirect typeReturn value (Response)Exception (NEXT_REDIRECT)
Return typeResponse (explicit)never (never returns)
In try/catchSafe — no effectBreaks — caught as error
Framework boundaryreturn keywordFramework-level catch

Writing the "wrong" version above is not a JavaScript mistake. It is applying a correct mental model from one stack to another — and the new stack has a fundamentally different contract for the same operation.

Why this matters beyond one bug

The redirect() gotcha is a specific instance of a broader pattern: the PHP web framework model is built around returning responses, where a return value is the unit of control flow. Next.js and the React server component model are built around throwing responses (redirects, not-found, error boundaries), where control flow is mediated by the framework catching special exceptions at rendering boundaries.

The two models produce code that looks the same on the surface. They even use the same keywords — try, return, throw. But they have opposite semantics for one critical operation. That is the kind of difference that is invisible in a tutorial and discoverable only by making the mistake.


Delaa