Skip to content
intermediate Phase 15 · Middleware and Edge Runtime

Redirects and Rewrites

Send users elsewhere or serve another path invisibly.

40m
0 problems
Topic Progress 0%

307 or 308? Choosing a Redirect That Will Not Corrupt Your POSTs

Marketing renames /pricing to /plans. Two questions decide everything: does the client end up at the new URL, and which status code tells it how to behave? A redirect answers both — the server returns a status plus Location, and the browser re-issues the request there.

Next.js uses 307 (temporary) and 308 (permanent) rather than the classic 301/302, and the reason is a real browser behavior: many clients rewrite a redirected POST into a bodyless GET when they see 301 or 302. A form submitted to /checkout that bounces through a 302 reaches /cart as a GET with no payload, and the order vanishes without an error. 307 and 308 preserve the method and the body — that is why the docs call the choice out explicitly.

Status Meaning Method + body Client caching
307 Temporary moved for now preserved not cached
308 Permanent moved for good preserved cached effectively forever

That last column is the trap: browsers and crawlers remember a 308 until the user clears data, so rolling one back is painful. Ship a 308 only for renames that will never come back.

Where you declare the redirect determines how it reads:

// next.config.js — declarative, evaluated before your middleware
module.exports = {
  redirects() {
    return [
      { source: '/pricing', destination: '/plans', permanent: true },  // → 308
      { source: '/promo',   destination: '/',      permanent: false }, // → 307
    ]
  },
}

permanent: true maps to 308 and false to 307; for legacy clients you may set statusCode instead — never both (Next.js even appends a Refresh header alongside 308 responses so old IE11-era clients follow it). Query strings survive automatically: a request for /old-blog/post-1?hello=world matching source: '/old-blog/:path*' arrives at /blog/post-1?hello=world. Both patterns must start with / and are anchored, so /about does not match /blog/about.

Per-request decisions belong in middleware, where you pick the code on the spot:

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

export default function middleware(request: NextRequest) {
  const country = request.headers.get('x-vercel-ip-country')
  if (country === 'DE') {
    return NextResponse.redirect(new URL('/de/pricing', request.url))      // 307
  }
  return NextResponse.next()
}

Inside Server Components and route handlers the API is redirect() from next/navigation. It throws a special NEXT_REDIRECT error that the framework catches while unwinding the render — so a try/catch that swallows the exception swallows the redirect with it. permanentRedirect() uses the same mechanism and produces a 308.

Two failure modes are worth recognizing on sight. First, infinite loops: a source that resolves to its own destination — usually from a missing leading slash on a path parameter (/:path* versus the literal :path), a trailing-slash disagreement, or a config redirect fighting a middleware redirect — makes the browser report a "too many redirects" error. Second, method loss during migration: if you must redirect an API POST, verify the status is 307/308 with a curl replay, not a browser click, because a browser will happily mask a broken GET conversion.

Remember the layering from Writing Middleware: config redirects are applied before middleware runs, so a permanent rule declared there needs no matcher, no invocation, and no cold start — it simply answers. Save your function for conditions that only exist at request time. When the URL must stay the same while the serving route changes, you want a rewrite instead — the next chapter.

beforeFiles, afterFiles, fallback: The Exact Order Rewrites Are Tried

A rewrite answers the other question: which route should serve this request while the browser keeps displaying the original URL. No second round trip, no Location header, no SEO signal — the outside world cannot tell the difference. That makes rewrites the tool for masking internal structure: serving /docs/* from a legacy CMS, exposing /api/report through an internal handler, or parking an unmigrated section at an old domain.

Two declaration sites, very different costs. Rules in next.config.js are declarative — Next.js evaluates them in the router without invoking your JavaScript on every request, which is exactly why the static cases belong there. A rewrite returned from middleware runs your function for each matched request, which is what you want only when the destination depends on session state, geography, or an experiment bucket.

// next.config.js
module.exports = {
  rewrites() {
    return {
      beforeFiles: [
        { source: '/override', destination: '/somewhere-else',
          has: [{ type: 'query', key: 'overrideMe' }] },
      ],
      afterFiles: [
        { source: '/non-existent', destination: '/somewhere-else' },
      ],
      fallback: [
        { source: '/:path*', destination: 'https://my-old-site.com/:path*' },
      ],
    }
  },
}

The three phases sit at fixed points in the routing chain, and the order is the interview answer:

Phase Evaluated Match semantics Typical use
beforeFiles after headers/redirects, before all files including _next/static and public/ can shadow a real file; Next finishes checking the remaining beforeFiles entries before touching the filesystem conditional override of an existing route
afterFiles after static pages and public/, before dynamic routes first rewrite whose destination resolves to a real file, page, or dynamic route wins aliasing a path onto an existing route
fallback after dynamic routes, before the 404 page last resort for anything still unmatched incremental migration proxying to an old site

Full chain: headers → redirects → middleware/proxy → beforeFiles → filesystem → afterFiles → dynamic routes → fallback → 404. Entries inside each phase are checked in the order you write them, and the first one that applies wins.

Parameter passing has a rule people get wrong: any matched parameter not used in the destination is appended to the query string automatically. source: '/old-about/:path*' with destination: '/about' yields /about?path=about-us. Use the parameter in the destination ('/docs/:path*' → '/:path*') and the automatic passing switches off — add it manually ('/:first?second=:second') if you still need it. External destinations (https://old-site/:path*) are allowed and are how a fallback proxy migrates page by page; basePath: false applies only to external entries.

One App Router subtlety separates a correct rewrite from a subtly broken one: returning NextResponse.rewrite() propagates the internal RSC rewrite headers upstream automatically, so the RSC payload request and the document request stay in sync. If you hand-roll the equivalent with fetch() and return the bytes yourself, those headers are missing and client-side navigation misbehaves unless you forward them (or enable skipProxyUrlNormalize so your code sees the original URL shape). Reach for the framework primitive first.

The sharp edges: rewriting to a route that returns 404 keeps the original URL on screen — a soft-404 that looks fine to users and confusing to crawlers; rewriting to an external host leaks your internal naming if the destination renders; and a rewrite plus a redirect on the same path can loop. Runtime choices — which environment executes that rewrite — is Edge vs Node Runtimes, and the layer-selection logic is next.

Config Rule, Middleware Rule, or In-Render Redirect: Choosing the Layer

Every URL change request — "move this page", "hide this path", "send German users there" — reduces to two axes: does the URL the user sees change, and is the decision static or request-dependent? Fill in both and the layer picks itself.

Situation Tool Status seen by client Per-request cost
Permanent rename, known at build time redirects() in next.config.js 308 none — router handles it
Temporary route move, known at build time redirects() in next.config.js 307 none
Conditional on cookie/host/query, still static config rule with has/missing 307/308 none
Depends on session, geo, experiment, rate limit NextResponse.redirect() in middleware 307 (or 308) one middleware invocation
Discovered while rendering (user has no orders) redirect() in a Server Component or handler 307 / permanentRedirect() → 308 runs during render
Mask an internal or legacy destination NextResponse.rewrite() or config rewrites() none — URL unchanged as above
User-initiated client navigation router.push() / <Link> no server status at all client-side

Two columns deserve the emphasis. "Does the URL change?" separates redirect from rewrite: a redirect hands the browser a new location and the old URL stops existing (308 aside, permanently); a rewrite serves different content under the same URL, which is what you want when the path is part of your public contract — a partner integration hitting /api/export must not break because you reorganized internals. And "Per-request cost" separates config from middleware: declarative rules run in the router without invoking your code, so the exhaustive redirect table for a thousand legacy paths belongs in next.config.js, not in a matcher-scoped function that ships, parses, and executes for every hit.

Work a concrete case interviewers like: move /blog to /news and keep old post URLs alive. The answer in layers: a config redirects() entry { source: '/blog/:slug*', destination: '/news/:slug*', permanent: true } for the renames (308, method-preserving, zero runtime cost); query parameters pass through automatically; then verify with curl -I that the status is 308 and not a 302 your tooling added; and only if the mapping needs per-request data — say, serving deprecated posts from an archive only for logged-in subscribers — does it move into middleware with a matcher scoped to /blog/:path*. Anything needing data you don't have until the page renders (a user with no saved addresses) uses redirect() in the Server Component, and you accept that it unwinds the render via NEXT_REDIRECT.

Common failure signatures, so you can diagnose fast:

  • "Too many redirects" — a source resolving to itself, usually a missing leading slash before :path, a trailing-slash mismatch (check skipTrailingSlashRedirect), or config and middleware each redirecting the other's destination.
  • POST arrives as GET with no body — a 301/302 entered the chain from a proxy or legacy rule; Next's own rules are 307/308 for exactly this reason.
  • Soft 404 — a rewrite landing on a route that renders not-found while the original URL stays; crawlers index an empty page.
  • Redirect works locally, 404s in production — config redirects() are read at build time in some deployments; they must be present in the build that ships.

Performance framing closes the loop with Writing Middleware: every request your matcher lets through pays your function's latency, while every rule you move to next.config.js pays nothing. Push decisions up the chain until they genuinely require the request.

The remaining question is where that code executes when it does require the request — the subject of Edge vs Node Runtimes.

Quiz

1. Which statement about Next.js redirect status codes is accurate?

Question 1 options

2. A request matches a next.config redirect, a middleware rewrite, an afterFiles rewrite, and a fallback rewrite. In what order does Next.js evaluate them?

Question 2 options

3. A config rewrite has `source: '/old-about/:path*'` and `destination: '/about'`. A request arrives for `/old-about/launch`. What does Next.js serve?

Question 3 options

4. What separates a next.config rewrite from a rewrite returned by middleware?

Question 4 options

Flashcards

Question

Why does Next.js use 307/308 instead of 301/302, and which is which?

Answer

301/302 make many clients convert a redirected POST into a bodyless GET, corrupting form and API submissions. 307 = temporary (method and body preserved, not cached), 308 = permanent (method and body preserved, cached effectively forever by browsers and crawlers). In next.config, `permanent: false` → 307, `permanent: true` → 308, or set `statusCode` (never both).

Question

Redirect vs rewrite — what changes for the client and for SEO?

Answer

A redirect returns a status plus Location and the browser re-requests the new URL — the old URL stops being served. A rewrite serves a different route while the browser keeps displaying the original URL: no extra round trip, no SEO change, and a risk of soft-404s if the rewritten route renders not-found under the original URL.

Question

The exact order of Next.js request handling, including all three rewrite phases

Answer

1 headers → 2 redirects (next.config) → 3 middleware/proxy → 4 beforeFiles rewrites → 5 filesystem (public/, _next/static, static pages) → 6 afterFiles rewrites → 7 dynamic routes → 8 fallback rewrites → 404. beforeFiles can shadow real files, afterFiles wins only if the destination resolves to a real route, fallback is the last resort before 404.

Question

What happens to matched parameters that the rewrite destination does not use?

Answer

They are appended to the query string automatically: `/old-about/:path*` → `/about` yields `/about?path=launch`. Using any parameter in the destination disables that behavior for all of them, so re-add what you need manually (`/:first?second=:second`). Forgetting this shows up as routes receiving unexpected query keys.

Question

How do you redirect from a Server Component or route handler, and what must you never do with it?

Answer

Call `redirect()` (307) or `permanentRedirect()` (308) from `next/navigation`. Both throw a NEXT_REDIRECT control error that Next.js catches while unwinding the render — so wrapping them in try/catch that swallows the exception silently cancels the redirect. Deep request-time decisions belong in middleware instead.

Revision Notes

Key Takeaways

  • 1. Redirects change the URL (307 temporary, 308 permanent, both method-preserving); rewrites change which route serves while the URL stays — 301/302 are avoided because they can turn POSTs into bodyless GETs
  • 2. Routing order is fixed: headers → redirects → middleware → beforeFiles → filesystem → afterFiles → dynamic routes → fallback → 404; config redirects therefore beat middleware
  • 3. Config rewrites are declarative and cost zero function invocations per request, so known-at-build-time rules belong there; middleware rewrites are for session/geo/experiment-dependent destinations
  • 4. Unused path parameters are auto-appended to the query string; using a parameter in the destination switches that off
  • 5. redirect()/permanentRedirect() throw NEXT_REDIRECT during render, and a rewrite landing on not-found produces a soft-404 under the original URL

Interview Tips

  • • "Move /blog to /news, keep old post URLs" — 308 config redirect with `:slug*`, query pass-through, verify with `curl -I`, and explain why 308 not 301 (method preservation) and why config not middleware (zero invocations)
  • • "Walk me through the routing pipeline" — headers, redirects, middleware/proxy, beforeFiles, filesystem, afterFiles, dynamic routes, fallback; then say what each rewrite phase is for and that middleware runs before the filesystem
  • • "Redirect vs rewrite" — URL change vs internal substitution, with the SEO and round-trip consequences; add the soft-404 failure mode for rewrites onto not-found routes
  • • "Why is my redirect looping?" — source equal to destination, missing leading slash before a path parameter, trailing-slash normalization conflicts, or two layers redirecting each other's output; mention skipTrailingSlashRedirect as the escape hatch

Cheat Sheet

Redirects and Rewrites

Redirect vs rewrite

Redirect Rewrite
Client sees new URL (Location) original URL, unchanged
Extra round trip yes (browser re-requests) no — server fetches internally
Status 307 temporary / 308 permanent none (200 from the served route)
SEO effect old URL retires both URLs serve content (soft-404 risk)
Use when the move is public and permanent the path is a public contract, internals move

Status codes

  • Next uses 307/308 because 301/302 make many clients convert POST → bodyless GET.
  • permanent: true → 308 (cached forever by clients — hard to undo), permanent: false → 307.
  • statusCode may replace permanent (never both).
  • redirect() / permanentRedirect() from next/navigation throw NEXT_REDIRECT — do not swallow it in try/catch.

Full routing chain

1 headers → 2 redirects (next.config) → 3 middleware/proxy → 4 beforeFiles
→ 5 filesystem (public/, _next/static, static pages) → 6 afterFiles
→ 7 dynamic routes → 8 fallback → 404

Rewrite phases

Phase Position Semantics
beforeFiles before all files can shadow a real file; all beforeFiles entries checked before the filesystem
afterFiles after static, before dynamic first rewrite that resolves to a real route wins
fallback after dynamic, before 404 catch-all last resort (proxy to old site)
// next.config.js
redirects: [{ source: '/pricing', destination: '/plans', permanent: true }]
rewrites: { beforeFiles: [...], afterFiles: [...], fallback: [...] }

Parameter rules: unused params in destination are auto-appended to the query (/old-about/:path* → /about?path=...); using a param in the destination disables auto-passing (/:first?second=:second re-adds manually). Sources must start with / — a :path without a leading slash is literal and can cause infinite redirects.

In middleware/proxy:

return NextResponse.redirect(new URL('/de/pricing', request.url))  // 307 default
return NextResponse.redirect(new URL('/plans', request.url), 308)  // deliberate permanent
return NextResponse.rewrite(new URL('/legacy', request.url))       // URL stays put

RSC note: NextResponse.rewrite() forwards internal RSC rewrite headers automatically; a hand-rolled fetch() rewrite must forward them (or set skipProxyUrlNormalize).

Layer rule: static/known rules → next.config.js (zero invocations); request-dependent rules → middleware; data-discovered during render → redirect() in the Server Component; client navigation → router.push().

Continue Learning

Before You Start

Next Topics

Related Reading