Configuring Next.js Redirects During Domain Migration

Problem Statement

You are migrating a Next.js application to a new domain or route scheme and need the old paths to 301 to their new homes. Next.js gives you two native mechanisms — the static redirects() array in next.config.js and dynamic redirects from middleware — and choosing wrong leaves you with stale 308s or redirects that cannot inspect the request. This page sits under CMS & Framework Routing Changes and covers when to use each.

Next.js redirect decision Decision tree choosing between static redirects() in next.config.js and middleware based on whether the redirect needs request state. Static redirects() or Middleware? Needs request state? cookie / header / geo No Yes redirects() in config cacheable, 308 / 307, has/missing middleware NextResponse.redirect
Keep redirects in config unless the rule must read request state, which forces middleware.

When to Use This Approach

The framework is the right home for a redirect when the rule is about route shape; it is the wrong home when the rule is about the host.

  • Your front end is Next.js (App Router or Pages Router) and you control next.config.js.
  • The path changes are mostly static and known at build time.
  • You need wildcard or named-parameter source matching (:slug, :path*).
  • Some redirects must depend on request state — cookies, headers, or geo — which forces middleware.
  • You are also moving domains and want the redirect to ship with the application build.

Step-by-Step Instructions

1. Define Static Redirects in next.config.js

The redirects() async function returns an array of rules evaluated at the edge before rendering. permanent: true emits a 308; permanent: false emits a 307. Use these for predictable path changes.

// next.config.js — static rules, evaluated before the page renders
module.exports = {
  async redirects() {
    return [
      { source: '/blog/:slug', destination: '/articles/:slug', permanent: true }, // 308 keeps method
      { source: '/promo', destination: '/offers', permanent: false },             // 307 temporary
    ];
  },
};

Generate these entries from the mapping inventory rather than writing them by hand. The config is JavaScript, which invites people to express the map as a loop over an imported array — fine — or as clever logic that computes destinations at build time, which is where it goes wrong: a computed redirect cannot be reviewed by reading the diff, and nobody can tell from the config what any given legacy URL will do. Emit a flat list, sorted so exact matches precede patterns, and let the generator own the cleverness.

2. Match Path Segments with Patterns

Use :name for a single segment and :name* to capture the rest of the path. Wildcards let one rule cover an entire legacy prefix instead of listing every URL.

// Capture an entire legacy section in one rule
{ source: '/docs/:path*', destination: '/help/:path*', permanent: true }

Patterns in the config use a path-to-regexp style rather than raw regular expressions, which is friendlier and has its own scoping trap: a named segment matches one path component, while a wildcard matches everything after it including slashes. The first is what you usually want for a mechanical path transformation; the second is what you write when you are in a hurry, and it silently absorbs every deeper route under the prefix. Decide breadth deliberately per rule and test both a shallow and a deep path against each pattern.

3. Add Conditional Rules with has and missing

The has and missing arrays gate a redirect on a header, cookie, query param, or host. This is how you scope a redirect to one domain during a multi-host migration.

// Only redirect requests arriving on the OLD host
{
  source: '/:path*',
  has: [{ type: 'host', value: 'old.example.com' }],
  destination: 'https://www.example.com/:path*',
  permanent: true,
}

Conditional entries are the useful middle ground and are worth reaching for before middleware. Matching on a header, a cookie or the host lets you express things like “redirect only requests arriving on the legacy domain” declaratively, evaluated before the application does any work, and reviewable as data rather than as code. Most redirects people put in middleware turn out to be expressible this way once the condition is written down explicitly.

4. Use Middleware Only for Request-Dependent Redirects

When a redirect must read something redirects() cannot — a session cookie, A/B bucket, or geo header — handle it in middleware. Keep static redirects in config so they stay cacheable.

// middleware.ts — redirect logged-in users away from the legacy login path
import { NextResponse } from 'next/server';
export function middleware(request) {
  if (request.cookies.get('session') && request.nextUrl.pathname === '/login') {
    return NextResponse.redirect(new URL('/dashboard', request.url), 308);
  }
  return NextResponse.next();
}

The three mechanisms Next.js offers are not interchangeable, and choosing between them is really a question about what the redirect decision depends on.

Next.js redirect mechanisms compared Static redirects in next.config.js, conditional entries using has and missing, and middleware, compared by what each can inspect, when it runs, and its per-request cost. Three mechanisms, chosen by what the decision needs Mechanism Decides on Runs Cost per request redirects() entry the path only before the app effectively none has / missing headers, cookies, host before the app negligible middleware anything, incl. geo per request paid on every route server rule instead the path only before the framework none, and stageable A bulk legacy map in middleware works and taxes every request on the site, including the ones that never redirect.
The bottom row is the one to reach for first: most legacy mapping does not need the framework at all.

Where middleware genuinely is necessary, keep it small and keep the bulk map out of it. Middleware executes for every matching request, so a large lookup table inside it becomes a per-request cost paid by the whole site in exchange for handling a set of paths a server rule would have answered for nothing. Scope the matcher as narrowly as the condition allows, and treat any growth in what middleware handles as a signal that a rule belongs at a lower layer.

Worked Example

A site moving old.example.com to www.example.com with a /blog/articles rename. With the host-gated wildcard plus the slug rule deployed:

$ curl -sIL https://old.example.com/blog/dns-cutover
HTTP/2 308
location: https://www.example.com/articles/dns-cutover
HTTP/2 200

A single 308 carries the request to the new host and new path together, preserving the method and avoiding a host-then-path two-hop chain. Note that permanent: true produces 308, not 301 — confirm your analytics and search reporting treat them equivalently, which they do for ranking purposes.

Config entries are evaluated in declaration order, which makes generation order part of the behaviour rather than a stylistic choice.

Ordering and deployment traps in framework redirect config Four common causes of a Next.js redirect that appears not to work: declaration order, a redirect versus a rewrite, the permanent flag emitting an unexpected status, and rules only taking effect on deploy. Four reasons a rule that looks right does nothing Symptom Actual cause Specific rule never fires a broader entry is declared above it URL never changes in the bar it is a rewrite, not a redirect — no status issued POST arrives without its body permanent: true emitted 301, not 308 Works locally, not in production config redirects only apply after a deploy All four produce a working site and a rule that quietly does not do what its author intended.
Confirm with curl -IL against the deployed environment — the config alone cannot tell you which layer answered.

Confirm the emitted status code rather than trusting the flag. permanent: true maps to different status codes across framework versions and across similar-looking frameworks — some emit 308, some 301 — and the difference only matters for routes carrying a request body, which is exactly where it matters most. Send a real POST through the deployed rule and read what comes back, once, at the start of the project; the answer holds for every rule thereafter.

Verification

Run every check against the deployed environment. Development serves everything from one process, which hides both the layering and the deploy-timing behaviour that cause most of the surprises here.

  • Confirm the status and final target: curl -sIL https://old.example.com/blog/dns-cutover | grep -iE '^HTTP|^location'.
  • Check no chain forms when both a host rule and a path rule apply — the trace should show one 308, not two.
  • After deploy, query a few middleware-gated paths with and without the cookie to confirm the conditional fires only when expected.

FAQ

Why does permanent: true return 308 instead of 301? Next.js intentionally emits 308 for permanent redirects so the HTTP method and body are preserved. Search engines treat 308 like 301 for ranking; see 301 vs 302 decision trees if you specifically need a 301.

When should I use middleware instead of redirects()? Use middleware only when the redirect depends on request state the config cannot see — cookies, headers, geo, or auth. Keep everything static in redirects() so it stays cacheable and easy to audit.

Should the domain change itself be handled in next.config.js? No — put it at the edge or the web server. A host-level redirect is the simplest possible rule, it needs no application context, and handling it in the framework means every request to the old domain boots the application before being told to go elsewhere. It also means the rule cannot be staged before the new build deploys, which removes exactly the flexibility a cutover needs. Reserve framework config for path-shape changes within a host.

Why do my redirects work in development but not after deploy? Almost always because something else handled them locally. In development a single process serves everything, so a rule that would be answered by a CDN or a web server in production appears to be working when in fact nothing is testing it. Deploy to a real environment and check with curl -IL, which shows both the status and the number of hops — the count is what reveals a second layer joining in.

How do I keep the config reviewable once there are thousands of entries? Keep the generated block in its own module, imported into the config rather than inlined, and commit the mapping inventory alongside it so a reviewer can diff the source of truth rather than the output. Past a few hundred entries the config file stops being something anyone reads line by line, at which point the property that matters is that the diff is meaningful — a change to twenty rules should show as twenty changed rows in the inventory, not as a wholesale rewrite of a generated file. Sorting the output deterministically is what makes that possible.

Related

← Back to CMS & Framework Routing Changes