How to Fix: Fallback rewrites do not trigger after calling `notFound` function in dynamic route

6 min read

When a dynamic App Router route calls notFound(), Next.js short-circuits rendering into its built-in 404 flow, so your configured fallback rewrite never gets a chance to run. That is why a request you expect to be rewritten instead stops at the route boundary and renders the not-found UI.

Why fallback rewrites stop working

In the reproduction linked in the GitHub repo, the expectation is straightforward: if a dynamic route cannot resolve a page, Next.js should continue to the configured fallback rewrite. Instead, calling notFound() inside the route ends the request lifecycle early.

This behavior is surprising if you think of rewrites as a final routing fallback. In practice, once execution has already entered a matched App Router segment and that segment throws the internal not-found signal, Next.js treats the request as resolved by the route layer, not as an unresolved pathname that should continue through rewrite fallback processing.

Understanding the Root Cause

The key detail is execution order.

Fallback rewrites are part of the request matching pipeline. They are evaluated when Next.js is deciding where a request should go. By contrast, notFound() is a rendering/runtime control flow mechanism used after a route has already been matched and execution has entered that route.

So the sequence looks like this:

  1. The incoming URL matches a dynamic route.
  2. Next.js loads that route’s server component or loader logic.
  3. Your code determines the content does not exist and calls notFound().
  4. Next.js throws its internal not-found exception and renders the nearest 404 handling path.
  5. The request does not return to the rewrite engine, because routing already succeeded.

Technically, this means notFound() is not a signal to “keep routing.” It is a signal to “render a 404 for the currently matched route tree.” That distinction explains the issue precisely.

If your architecture depends on trying another destination after a lookup fails, notFound() is the wrong primitive. You need to decide the alternative destination before committing to the route’s not-found state, or move the fallback logic into middleware, route handlers, or an explicit redirect/rewrite layer you control.

Step-by-Step Solution

The fix is to stop relying on notFound() to trigger a fallback rewrite. Instead, use one of these patterns depending on your goal:

  • Use middleware to rewrite before the App Router route resolves.
  • Use redirect() if the user should be sent to another route explicitly.
  • Render alternate content directly instead of throwing notFound().
  • Move lookup logic to a catch-all proxy route or route handler where you can fully control fallback behavior.

1. Do not call notFound() when you actually want fallback routing

If the missing content should resolve to another internal destination, replace this pattern:

import { notFound } from 'next/navigation'

export default async function Page({ params }) {
  const data = await getPage(params.slug)

  if (!data) {
    notFound()
  }

  return <PageView data={data} />
}

With an explicit redirect or alternate render path:

import { redirect } from 'next/navigation'

export default async function Page({ params }) {
  const data = await getPage(params.slug)

  if (!data) {
    redirect(`/fallback/${params.slug}`)
  }

  return <PageView data={data} />
}

Use this only if a visible URL change is acceptable.

2. Use middleware for true pre-route fallback rewrites

If you need a real rewrite that preserves the browser URL, move the decision earlier into middleware.ts.

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname

  if (pathname.startsWith('/blog/')) {
    const slug = pathname.replace('/blog/', '')

    const exists = await doesBlogEntryExist(slug)

    if (!exists) {
      const url = request.nextUrl.clone()
      url.pathname = `/legacy/blog/${slug}`
      return NextResponse.rewrite(url)
    }
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/blog/:path*'],
}

async function doesBlogEntryExist(slug: string) {
  return false
}

This works because the rewrite decision happens before the request is finalized against the App Router page component.

3. Use a catch-all route to centralize resolution

If your application must resolve content from multiple sources, a catch-all segment can act as a router you own.

import { notFound } from 'next/navigation'
import { redirect } from 'next/navigation'

export default async function Page({ params }) {
  const path = params.slug?.join('/') || ''

  const primary = await getPrimaryContent(path)
  if (primary) {
    return <PrimaryView data={primary} />
  }

  const legacy = await getLegacyMatch(path)
  if (legacy) {
    redirect(legacy.destination)
  }

  notFound()
}

Here, notFound() is only used after all fallback resolution paths have been exhausted.

4. Keep next.config.js rewrites for static routing, not runtime misses inside matched routes

Your next.config.js rewrites still make sense for URL mapping, but they should not be expected to resume after notFound() is thrown from a matched App Router page.

module.exports = {
  async rewrites() {
    return {
      fallback: [
        {
          source: '/:path*',
          destination: 'https://example-legacy-app.com/:path*',
        },
      ],
    }
  },
}

This configuration helps when no higher-priority route has fully consumed the request. It does not act as a second-pass resolver after route-level not-found handling.

For most teams, the most reliable fix is:

  1. Check whether the requested entity exists in middleware or in a dedicated resolver layer.
  2. If it exists, allow the App Router page to render normally.
  3. If it does not exist but has a fallback destination, rewrite or redirect there explicitly.
  4. Only call notFound() when you truly want the request to end as a 404.

Common Edge Cases

Static generation and cached results

If the route is statically generated or aggressively cached, you may think your fallback logic is broken when the real issue is stale output. Verify behavior with dynamic rendering settings where appropriate, and test in both development and production.

Middleware performance

Doing database or API existence checks in middleware can become expensive. If you need low-latency routing decisions, prefer a fast key-value lookup, edge-friendly metadata, or a compact routing manifest.

Infinite rewrite loops

If middleware rewrites to a path that also matches the same middleware condition, you can accidentally create a loop. Always exclude the fallback destination or add a guard condition.

if (pathname.startsWith('/legacy/')) {
  return NextResponse.next()
}

Confusing redirect vs rewrite behavior

A redirect changes the URL in the browser. A rewrite keeps the original URL visible while serving content from another destination. Pick the one that matches your SEO, analytics, and user experience requirements.

Nested not-found boundaries

In the App Router, segment-level not-found handling can make debugging harder. If a parent or child segment defines custom not-found UI, the final result may look like a general routing miss even though the request actually matched and failed during rendering.

FAQ

Can Next.js fallback rewrites run after notFound()?

No. Once notFound() is called in a matched App Router route, Next.js enters its not-found rendering flow. It does not go back and re-run fallback rewrites.

Should I use redirect() instead of notFound()?

Use redirect() when a missing resource should resolve to another known route. Use notFound() only when the correct final state is truly a 404.

What is the best workaround for legacy fallback systems?

The best workaround is usually middleware-based rewriting or a custom resolution layer before route rendering. That preserves control over fallback behavior and avoids relying on post-match 404 control flow.

The practical takeaway is simple: notFound() is a terminal rendering decision, not a routing fallback mechanism. If your app needs another destination after a lookup failure, make that decision before throwing the not-found boundary.

Leave a Reply

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