How to Fix: Unexpected behaviour in redirect next/navigation function – Next.js 15

8 min read

Next.js 15 redirect() from next/navigation can appear to fail or behave unexpectedly because the framework now handles redirects more aggressively through thrown control-flow errors and streaming-aware rendering.

If your Next.js 14 example works but the same pattern in Next.js 15 produces surprising results, the problem is usually not the redirect API itself. The real issue is where the redirect is called, when rendering has already started, or whether the redirect signal is being caught, transformed, or delayed by surrounding code.

Problem Overview

In the reproduction repository comparing Next 14 and Next 15, the visible difference comes from how redirects interact with the App Router, server rendering, and React streaming. The redirect() helper from next/navigation does not behave like a normal function that returns a value. It throws a special internal signal that Next.js intercepts to stop rendering and send a redirect response.

That design has existed for a while, but in Next.js 15 the rendering pipeline is stricter and more optimized. As a result, patterns that were previously tolerated can now produce behavior such as:

  • A redirect not happening where you expect.
  • Partial UI rendering before navigation.
  • A redirect being swallowed by try/catch.
  • Unexpected fallback behavior when redirecting after async work.
  • Confusion between Server Components, Server Actions, and Route Handlers.

If you treat redirect() as an exception-based control mechanism instead of a standard navigation helper, the behavior becomes much easier to reason about.

Understanding the Root Cause

The core reason is simple: redirect() throws a framework-specific error to abort rendering immediately.

In Next.js App Router, this is intentional. The framework needs a reliable way to interrupt the current render tree and convert that interruption into an HTTP redirect or client navigation event. In practice, that means:

  • redirect() never returns normally.
  • Any code after redirect() is unreachable.
  • If you catch the thrown redirect signal, you can accidentally prevent navigation.
  • If rendering has already streamed part of the response, redirect behavior may appear inconsistent because the response lifecycle has already advanced.

Here is the pattern that often causes the bug:

import { redirect } from 'next/navigation'
export default async function Page() {
  try {
    const shouldRedirect = true

    if (shouldRedirect) {
      redirect('/dashboard')
    }
  } catch (error) {
    console.error('Caught error:', error)
  }

  return <div>Still rendering this page</div>
}

This is broken because the redirect signal gets caught like a normal exception. Once that happens, Next.js cannot complete the redirect in the intended way.

Another source of confusion is streaming. In Next.js 15, the framework leans harder into streaming and async rendering. If some part of the tree has already started rendering or flushing output, redirecting deeper in the lifecycle can create results that feel inconsistent compared to earlier versions.

Technically, the behavior is tied to these framework concepts:

  • Thrown control-flow exceptions for redirect and notFound.
  • Server Component rendering boundaries.
  • React streaming and partial response flushing.
  • Async data fetching timing before and after render begins.
  • Error boundaries or generic catch blocks interfering with Next.js internals.

The fix is to make redirects happen early, in the correct execution context, and without being wrapped in generic error handling.

Step-by-Step Solution

The safest solution is to call redirect() only in places where Next.js expects it and to avoid catching it accidentally.

1. Redirect before rendering any UI

If the destination depends on authentication, params, or fetched data, resolve that first and redirect immediately.

import { redirect } from 'next/navigation'
export default async function Page() {
  const user = await getUser()

  if (!user) {
    redirect('/login')
  }

  return <div>Welcome back, {user.name}</div>
}

This works because the redirect happens before returning JSX that would continue rendering the page.

2. Do not swallow redirects inside try/catch

If you need error handling for other code, keep the redirect outside the catch block or rethrow the redirect signal.

Incorrect:

import { redirect } from 'next/navigation'
export default async function Page() {
  try {
    const session = await getSession()

    if (!session) {
      redirect('/login')
    }
  } catch (error) {
    return <div>Something went wrong</div>
  }

  return <div>Protected page</div>
}

Correct:

import { redirect } from 'next/navigation'
export default async function Page() {
  const session = await getSession()

  if (!session) {
    redirect('/login')
  }

  try {
    const data = await loadProtectedData()
    return <div>{data.title}</div>
  } catch (error) {
    return <div>Something went wrong</div>
  }
}

If you absolutely must catch broadly, make sure redirect-like errors are rethrown instead of absorbed by your custom logic.

3. Keep server redirects on the server

Use redirect() from next/navigation in:

  • Server Components
  • Server Actions
  • Route Handlers

For client event handlers, use the client router instead.

Client-side navigation:

'use client'
import { useRouter } from 'next/navigation'
export default function LoginButton() {
  const router = useRouter()

  return (
    <button onClick={() => router.push('/dashboard')}>
      Go to dashboard
    </button>
  )
}

If you mix server redirect semantics with client interactivity, the app can behave unpredictably.

4. In Server Actions, call redirect() after successful mutation

This is a common and supported pattern, but it still relies on the same throw-based flow.

'use server'
import { redirect } from 'next/navigation'
export async function createPost(formData) {
  const title = formData.get('title')

  await db.post.create({
    data: { title }
  })

  redirect('/posts')
}

Do not wrap the entire action in a broad catch unless you rethrow redirect-related control flow.

5. Prefer NextResponse.redirect() in Route Handlers when working at the HTTP layer

For app/api or custom request handling, use the response-based API.

import { NextResponse } from 'next/server'
export async function GET() {
  return NextResponse.redirect(new URL('/login', 'http://localhost:3000'))
}

This is often clearer when you are explicitly building an HTTP response instead of interrupting component rendering.

6. If auth logic is global, move it to middleware

When every request to a route group should redirect based on cookies, locale, or session state, middleware is often more stable than performing the same check in many pages.

import { NextResponse } from 'next/server'
export function middleware(request) {
  const isLoggedIn = Boolean(request.cookies.get('session'))

  if (!isLoggedIn && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

This avoids rendering the protected route at all and removes timing issues caused by deeper server rendering.

7. Refactor the problematic pattern from the issue

If your reproduction looks like a redirect happening inside an async render path with generic error handling, rewrite it into an early-return redirect flow.

import { redirect } from 'next/navigation'
async function getRedirectState() {
  const result = await fetchSomethingCritical()
  return result.shouldRedirect
}
export default async function Page() {
  const shouldRedirect = await getRedirectState()

  if (shouldRedirect) {
    redirect('/target')
  }

  return <div>Normal page content</div>
}

The key improvement is that the redirect decision is finalized before rendering continues into the rest of the component tree.

Common Edge Cases

Redirect inside a nested async component

If a child Server Component redirects after a parent has already begun streaming, the result may seem inconsistent. Move the redirect logic higher in the tree if the route decision affects the whole page.

Redirect caught by monitoring or logging wrappers

Some teams wrap loaders or actions with generic utilities for telemetry. If those wrappers catch all exceptions, they may also catch the redirect signal. Review any abstraction that does:

try {
  return await fn()
} catch (error) {
  log(error)
  return fallback
}

These helpers should not suppress Next.js control-flow exceptions.

Mixing redirect() and custom error UI

Do not try to both render fallback UI and redirect from the same execution branch. A redirect is meant to terminate rendering, not coexist with an alternate UI result.

Calling redirect after side effects that may partially complete

If you mutate data and then redirect, make sure the mutation is the final intended state. In actions and handlers this is normal, but in render code you should avoid side effects entirely.

Using relative assumptions in API or middleware contexts

Inside Route Handlers and middleware, prefer NextResponse.redirect() with a proper URL object. That avoids ambiguity and better reflects HTTP response handling.

Confusing permanentRedirect() with redirect()

If you need a permanent redirect semantics for SEO or caching reasons, use the correct API. Temporary and permanent redirects can differ in browser and proxy behavior.

FAQ

Why does redirect() behave differently in Next.js 15 compared to Next.js 14?

The biggest difference is not the public API but the stricter interaction between App Router, async rendering, and streaming. Patterns that relied on loose timing or accidentally caught the redirect signal are more likely to surface as bugs in Next.js 15.

Can I use redirect() inside a try/catch block?

You can, but it is risky. Since redirect() throws to stop rendering, a catch block can swallow it and prevent navigation. The safest approach is to call redirect outside broad exception handling, or rethrow framework control-flow errors.

Should I use redirect(), router.push(), or NextResponse.redirect()?

Use redirect() in Server Components and Server Actions, router.push() in client event handlers, and NextResponse.redirect() in Route Handlers or middleware where you are explicitly returning an HTTP response.

Final Takeaway

The issue is caused by a mismatch between how developers expect redirects to work and how Next.js 15 actually implements them. A redirect from next/navigation is control flow, not ordinary function logic. Once you move the redirect decision earlier, keep it out of generic catch blocks, and choose the correct API for the execution environment, the unexpected behavior disappears.

For official API semantics, compare your implementation against the Next.js redirect documentation and the reproduction linked in the GitHub issue repository.

Leave a Reply

Your email address will not be published. Required fields are marked *