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

  • 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
    ];
  },
};

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 }

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,
}

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();
}

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.

Verification

  • 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.

Related

← Back to CMS & Framework Routing Changes