Redirects, Rewrites & Proxy

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0 and Node.js 20.9.0 or later. It covers the App Router, where proxy.ts, redirect() and permanentRedirect() live; the three next.config keys work the same way in both routers, and the Pages Router differences are called out where they exist. Where caching changes the outcome, the page labels the two models: the previous model (the default, cacheComponents off) and Cache Components (cacheComponents: true, opt-in). Every example was built and run with Next.js 16.3.8 and Node.js 24.16 on macOS in a scratch copy of the Bookshelf catalogue that the rest of this section grows, and the status codes and headers quoted below are the output of curl -I against next start, not paraphrases of the documentation.

Moving a request somewhere else is the oldest job of a web server, and Next.js has five different places to do it, which differ in when they run, what they can see and what status code the browser receives. The page starts with a map of the five, then the order in which they are evaluated, then each in turn, and ends with the question the official documentation itself raises: how much should the Proxy do. The files named here (next.config.ts, proxy.ts, the app folder) are described in Project Structure and Configuration, TypeScript and Environment Variables; the request flow they take part in is drawn in Introduction and Architecture.

The Five Places to Redirect or Rewrite

A redirect answers with a 3xx status and a Location header, so the browser’s address bar changes and the browser issues a second request. A rewrite serves the content of a different path while the URL in the address bar stays what the user asked for: it is a proxy behind the scenes, and no second request is made. Both can be declared in five places.

Table 1. Where requests can be redirected or rewritten in Next.js 16.3
Mechanism Runs Can see Result

redirects in next.config.ts

Before the Proxy and before the filesystem

The path, query, headers, cookies and host, matched declaratively (has, missing)

307 or 308 (or a statusCode you choose)

rewrites in next.config.ts

Before files (beforeFiles), after files (afterFiles) or last (fallback)

The same declarative matchers

The destination’s content under the original URL

headers in next.config.ts

First, before everything else

The same declarative matchers

Extra response headers

proxy.ts (formerly middleware.ts)

After headers and redirects, before rewrites and rendering

The whole request, in code: NextRequest with cookies, headers and nextUrl

Redirect (any status), rewrite, headers, cookies or a direct Response

redirect() and permanentRedirect()

While rendering a Server Component, in a Server Action or in a Route Handler

Anything your code has read: a database row, a session, params

307 or 308; 303 for a progressively enhanced form submission; or a meta tag when the response is already streaming

The practical rule from the official documentation is to use the first option that fits: a static list of known paths goes in redirects; a decision that needs request data but no slow work goes in the Proxy; and a decision that needs your data goes in the component or action that already has it. The three redirect* APIs and the two config keys are sections below; the decision table at the end of the page repeats the rule with the Bookshelf’s own cases.

Order of Evaluation

When a request arrives, Next.js runs the five mechanisms in a fixed order. The order matters because an earlier step can end the request before a later one sees it: a redirects entry wins over a Proxy that redirects the same path, and a Proxy redirect wins over any rewrite.

flowchart TD R["Incoming request"] --> H["1. headers from next.config
(added to the response)"] H --> RD{"2. redirects from next.config
match?"} RD -- yes --> E1["307 / 308 response, done"] RD -- no --> P{"3. proxy.ts
(if the matcher matches)"} P -- "redirect or direct Response" --> E2["Response, done"] P -- "rewrite, next()" --> BF{"4. beforeFiles rewrites"} BF --> FS{"5. Filesystem: public/, _next/static,
static pages and app routes"} FS -- "found" --> OK["Serve it"] FS -- "not found" --> AF{"6. afterFiles rewrites"} AF -- "resolves to a page or file" --> OK AF -- "no" --> DYN{"7. Dynamic routes
such as /books/[id]"} DYN -- "match" --> OK DYN -- "no" --> FB{"8. fallback rewrites"} FB -- "match" --> OK FB -- "no" --> NF["404 page"]

The same order as a list, with what each step can and cannot do:

Table 2. Evaluation order in Next.js 16.3
Step What runs Notes

1

headers from next.config.ts

Checked first. A later headers entry that sets the same key overrides an earlier one.

2

redirects from next.config.ts

Checked before the filesystem, public/ files and the Proxy.

3

proxy.ts

Runs for every request its matcher allows. It can rewrite, redirect, change headers or answer directly.

4

beforeFiles rewrites

Run after the Proxy and before any file, so they can shadow a file in public/ or a page.

5

The filesystem

public/, _next/static and non-dynamic routes.

6

afterFiles rewrites

Run only when step 5 found nothing. A plain array returned from rewrites() is treated as afterFiles.

7

Dynamic routes

For example app/[locale]/books/[id]/page.tsx.

8

fallback rewrites

The last resort, run before the 404 page. This is where an external legacy site goes.

The Bookshelf’s next.config.ts and proxy.ts were built so that the order can be observed. The path /probe is redirected by both redirects (to /from-config) and the Proxy (to /from-proxy); /probe2 is redirected by the Proxy and also has a beforeFiles rewrite; /hello.txt exists in public/ and has a beforeFiles rewrite as well.

$ curl -I localhost:3306/probe
HTTP/1.1 307 Temporary Redirect
location: /from-config

$ curl -I localhost:3306/probe2
HTTP/1.1 307 Temporary Redirect
location: /from-proxy

$ curl localhost:3306/hello.txt
{"status":"ok"}

redirects beat the Proxy on /probe; the Proxy beat the rewrite on /probe2; and the beforeFiles rewrite beat the static file on /hello.txt, because the response is the JSON of the Route Handler it points to (/api/health) and not the text in public/hello.txt. One more consequence of the order is easy to miss: the Proxy runs before the afterFiles and fallback rewrites, so a Proxy that redirects every path to a locale prefix also redirects the source path of a rewrite before the rewrite is ever consulted. In the scratch app a rewrite from /shelf/:id was shadowed that way (the response was a 307 to /en/shelf/2) until the source was written as /en/shelf/:id. Either write rewrite sources the way the Proxy leaves them, or exclude their paths from the Proxy matcher, as the legacy prefix is below.

sequenceDiagram participant B as Browser participant N as next start B->>N: GET /catalog/7 Note over N: 1. headers, 2. redirects: /catalog/:id(\d+) matches N-->>B: 308 Location: /en/books/7 B->>N: GET /en/books/7 Note over N: Proxy matches and calls next(), then route /[locale]/books/[id] renders N-->>B: 200 HTML

The redirects Key

redirects is an async (or plain) function in next.config.ts that returns an array of rules. Each rule has a source pattern, a destination and either permanent or statusCode. The rules are compiled into the routing table at build time, so a change needs a new build (or a restart of next dev).

Redirect rules in the Bookshelf’s next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async redirects() {
    return [
      // 308: the old catalogue moved for good
      { source: "/catalog", destination: "/en", permanent: true },
      // regex parameter: digits only; the id is reused in the destination
      { source: "/catalog/:id(\\d+)", destination: "/en/books/:id", permanent: true },
      // wildcard: everything below /old-blog moves, the query string is passed through
      { source: "/old-blog/:slug*", destination: "/en/blog/:slug*", permanent: false },
      // custom status code instead of permanent (never both)
      { source: "/gone-for-now", destination: "/en", statusCode: 301 },
    ];
  },
};

export default nextConfig;
$ curl -I localhost:3306/catalog/7
HTTP/1.1 308 Permanent Redirect
location: /en/books/7
Refresh: 0;url=/en/books/7

$ curl -I "localhost:3306/old-blog/2024/post-1?hello=world"
HTTP/1.1 307 Temporary Redirect
location: /en/blog/2024/post-1?hello=world

$ curl -I localhost:3306/gone-for-now
HTTP/1.1 301 Moved Permanently
location: /en

The behaviours worth knowing, each confirmed with curl -I:

  • Status codes. permanent: true answers 308 and permanent: false answers 307. A 308 response also carries a Refresh: 0;url=…​ header, which the documentation says exists for old browsers (IE11). statusCode replaces permanent when a client needs 301 or 302; you cannot set both.

  • Query strings pass through. /old-blog/2024/post-1?hello=world kept hello=world on the destination.

  • Path patterns follow path-to-regexp. :slug matches one segment, :slug* zero or more, :slug+ one or more, :slug? zero or one, and :id(\\d+) restricts a parameter with a regular expression. A request for /catalog/abc did not match the digits-only rule and fell through to the Proxy (which redirected it to /en/catalog/abc). Patterns are anchored at the start of the path, and the parameters can be reused in the destination. The characters (, ), {, }, :, *, + and ? must be escaped with a double backslash when they are meant literally.

  • Always put a / before the colon. /old-blog/:slug is a pattern; old-blog:slug is a literal string and risks an infinite redirect.

  • The destination may be absolute. destination: "https://example.com/store" redirects out of the site (the /store rule in the scratch app answered 307 with that location).

has and missing: matching on more than the path

A rule can be restricted to requests that carry (or lack) a header, cookie, query parameter or host. Every has item must match and every missing item must not, in addition to the source. A value is a regular-expression-like string, and a named capture group makes the captured text available in the destination as :name.

async redirects() {
  return [
    // only when the cookie beta=off is present
    {
      source: "/beta",
      has: [{ type: "cookie", key: "beta", value: "off" }],
      destination: "/en",
      permanent: false,
    },
    // only when the header x-no-redirect is absent
    {
      source: "/store",
      missing: [{ type: "header", key: "x-no-redirect" }],
      destination: "https://example.com/store",
      permanent: false,
    },
    // named capture group in a query value, reused in the destination
    {
      source: "/search",
      has: [{ type: "query", key: "term", value: "(?<t>.+)" }],
      destination: "/en?q=:t",
      permanent: false,
    },
    // host matching
    {
      source: "/hostcheck",
      has: [{ type: "host", value: "shop.example.test" }],
      destination: "/en",
      permanent: false,
    },
  ];
},
$ curl -I localhost:3306/beta
location: /en/beta                    # no cookie: rule skipped, the Proxy added the locale

$ curl -I -H 'Cookie: beta=off' localhost:3306/beta
location: /en                         # the has rule applied

$ curl -I "localhost:3306/search?term=dune"
location: /en?term=dune&q=dune        # captured value used, original query kept

$ curl -I -H 'Host: shop.example.test' localhost:3306/hostcheck
location: /en

basePath and locale

When basePath is set (for example /shop when the app is served below a sub-path), every source and destination is prefixed with it automatically; a rule can opt out with basePath: false, which the documentation limits to external redirects. With basePath: "/shop", the scratch app answered as follows (the config reads the base path from an environment variable, which therefore has to be set for both next build and next start: with the variable missing at start-up, Next.js ran with no base path, and every /shop/…​ request fell through to the Proxy’s locale redirect):

$ curl -I localhost:3306/shop/catalog/7
location: /shop/en/books/7            # source and destination both prefixed

$ curl -I localhost:3306/outside      # rule with basePath: false, no /shop in the source
location: https://example.com/

For internationalised sites, the App Router has no i18n config key (that is a Pages Router feature, together with the locale: false option on a rule). Locales are ordinary path segments, so a rule can mention them as hard-coded text or as a parameter. The scratch rule /:locale(en|es)/libros/:id(\\d+) redirected /en/libros/3 to /en/books/3 and /es/libros/3 to /es/books/3. Per-request language detection belongs in the Proxy; see the internationalisation page of this section.

Limits and pitfalls

  • Config redirect responses carry no headers entries (observed). Although headers is step 1 of the order, the 308 and 307 responses produced by redirects rules in the scratch app did not include the security headers that a catch-all headers rule adds to every other response, while redirects produced by the Proxy did. If a header must be on every response, set it in the Proxy as well, or at the CDN.

  • A 308 is cached for good. Browsers and search engines keep a permanent redirect; a wrong permanent: true is hard to undo. Start with permanent: false and switch once the mapping is final.

  • In the Pages Router, redirects does not apply to client-side routing. The documentation says Link and router.push do not get redirected, as they navigate without a request unless a Proxy matches the path.

  • Platform limits. The guide notes that redirects can be limited by the platform (1,024 entries on Vercel). Beyond a few hundred rules, see Redirect Maps at Scale.

  • Loops. A rule whose destination matches its own source, or a Proxy and a config rule that point at each other, produces ERR_TOO_MANY_REDIRECTS in the browser. curl -IL --max-redirs 5 finds them quickly.

The rewrites Key

rewrites has the same shape as redirects without the status code: a source and a destination, with the same path patterns and the same has/missing/basePath options. A plain array is applied after the filesystem and before dynamic routes (afterFiles); returning an object with beforeFiles, afterFiles and fallback arrays places rules at the three points of the order of evaluation.

Rewrites in the Bookshelf’s next.config.ts
async rewrites() {
  return {
    beforeFiles: [
      // checked before public/ files, so it can shadow a static file
      { source: "/hello.txt", destination: "/api/health" },
    ],
    afterFiles: [
      // checked after public/ and pages, before dynamic routes
      { source: "/api/v1/books", destination: "/api/books" },
      { source: "/en/shelf/:id", destination: "/en/books/:id" },
    ],
    fallback: [
      // checked last: only reached when nothing else matched
      { source: "/legacy/:path*", destination: "http://localhost:3399/:path*" },
    ],
  };
},
$ curl -s "localhost:3306/api/v1/books?q=dune"
[{"id":"1","title":"Dune","author":"Frank Herbert","priceCents":999}]

$ curl -I localhost:3306/en/shelf/2
HTTP/1.1 200 OK
X-Bookshelf-Book: 2                   # the header rule of /en/books/:id, as the path was rewritten

$ curl localhost:3306/legacy/shop/cart
legacy site says hello from /shop/cart

Three things the examples show:

  • The URL does not change. /api/v1/books is served by /api/books, which is the usual way to keep a public URL stable while the implementation moves, and /en/shelf/2 renders the book page. A rewrite also applies to client-side navigation: <Link href="/en/shelf/2"> shows the book page and keeps /en/shelf/2 in the address bar.

  • An external destination makes Next.js a reverse proxy. The fallback rule forwarded /legacy/shop/cart to a small Node.js server on port 3399 and returned its body. This is the documented way to adopt Next.js incrementally: pages that exist are served by Next.js, and everything else falls through to the old site. Because the rule is in fallback, a page added later in app/ automatically takes over, as it is found before the fallback is reached. Use fallback rather than beforeFiles for this job. The same mechanism, with several Next.js applications behind one domain, is what the multi-zones guide describes.

  • Rewrite parameters become the query when the destination does not use them (observed with a page). A rewrite from /en/find4/:q to the page /en/rq delivered {"q":"dune"} as searchParams in the page. A Route Handler is different: for the same kind of rewrite to /api/echo, request.nextUrl.searchParams in the handler was empty and request.url still showed the original path. To hand a value to a Route Handler, put it in the destination path, or use NextResponse.rewrite() in the Proxy with the full URL you want.

The Pages Router additionally parses the rewrite parameters on the client after hydration for statically optimised pages. A static export (output: "export") has no server, so the static-exports guide lists rewrites, redirects, headers and the Proxy among the unsupported features.

The headers Key

headers adds response headers for matching paths: a source pattern, a headers array of key/value pairs and the same has, missing and basePath options. Path parameters can be used in the key and the value. When several entries match and set the same key, the last one wins.

async headers() {
  return [
    {
      source: "/:path*",
      headers: [
        { key: "X-Content-Type-Options", value: "nosniff" },
        { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
      ],
    },
    {
      // :id is captured and reused in the value
      source: "/en/books/:id",
      headers: [{ key: "X-Bookshelf-Book", value: ":id" }],
    },
    {
      // only when the preview cookie is present
      source: "/en/books/:id",
      has: [{ type: "cookie", key: "preview" }],
      headers: [{ key: "Cache-Control", value: "private, no-store" }],
    },
  ];
},
$ curl -I localhost:3306/en/books/2
HTTP/1.1 200 OK
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Bookshelf-Book: 2
Cache-Control: s-maxage=31536000

$ curl -I -H 'Cookie: preview=1' localhost:3306/en/books/2
X-Bookshelf-Book: 2
Cache-Control: private, no-store

The documentation lists the common security headers (X-DNS-Prefetch-Control, Strict-Transport-Security, X-Frame-Options, Permissions-Policy, X-Content-Type-Options, Referrer-Policy) and a CORS recipe. Three points of judgement:

  • Static headers belong in headers; per-request values belong in the Proxy. A Content-Security-Policy with a nonce changes on every request, so it is built in the Proxy (see the content security policy guide in the references); a fixed X-Content-Type-Options needs no code at all.

  • Do not override Cache-Control on routes Next.js caches. The Cache-Control set by a rule above replaced the s-maxage=31536000 that the prerendered page would otherwise have, which is the intention only for the preview cookie. The self-hosting guide says Next.js sets Cache-Control itself and, for immutable assets, cannot be overridden.

  • Remember the redirect caveat above. The headers rule did not appear on redirects responses.

Status Codes: 307, 308, 303 and the Meta Tag

Why does Next.js talk about 307 and 308 instead of the 302 and 301 that every tutorial uses? Because many browsers rewrote the request method to GET after a 301 or 302, so a POST /v1/users redirected to /v2/users could arrive as GET /v2/users. 307 and 308 say explicitly that the method (and body) must be preserved. The cost of that precision is a third code, 303, that you meet in Server Actions.

Table 3. Which status code each mechanism sends (observed on 16.3.8)
Mechanism Status Notes

redirects with permanent: false

307

Temporary; not cached.

redirects with permanent: true

308

Permanent; adds a Refresh header; cached by clients "forever".

redirects with statusCode

As given

301 was observed. Use it only for clients that need the legacy codes.

redirect() in a Server Component, before streaming starts

307

The permanentRedirect() equivalent answers 308.

redirect() or permanentRedirect() after streaming started

200

The response is already on its way, so Next.js emits a <meta http-equiv="refresh"> tag and an internal NEXT_REDIRECT marker, and the browser redirects on the client.

redirect() in a Server Action, with JavaScript

None (client navigation)

The action’s result tells the client router to navigate.

redirect() in a Server Action, form posted without JavaScript

303

"See Other": the browser follows with a GET, which is what a post-redirect-get flow needs.

NextResponse.redirect(url) in the Proxy

307 by default

Pass a status as the second argument (the guide uses 308 and 307) to change it.

The meta-tag case deserves its own warning, because it is the one place where a redirect does not look like one to a crawler or to curl -I. The next section shows it with a request.

redirect() and permanentRedirect()

redirect(path) and permanentRedirect(path) come from next/navigation. They work while rendering Server and Client Components, in Route Handlers and in Server Functions (the Server Actions of the React Forms and Actions page). Both throw a special NEXT_REDIRECT error that unwinds the render, which has two consequences: the call never returns (its TypeScript return type is never, so no return is needed), and it must not sit inside a try block that swallows errors.

In a Server Component

The scenario: the catalogue renamed the author slug stanislaw-lem to lem, and old links must keep working.

app/[locale]/authors/[slug]/page.tsx (previous model)
import { notFound, permanentRedirect } from "next/navigation";

const renamed: Record<string, string> = { "stanislaw-lem": "lem", "u-le-guin": "le-guin" };
const current = new Set(["lem", "le-guin"]);

export default async function Author({ params }: PageProps<"/[locale]/authors/[slug]">) {
  const { locale, slug } = await params;
  if (slug in renamed) permanentRedirect(`/${locale}/authors/${renamed[slug]}`);
  if (!current.has(slug)) notFound();
  return <h1>Author {slug}</h1>;
}
$ curl -I localhost:3306/en/authors/stanislaw-lem
HTTP/1.1 308 Permanent Redirect
location: /en/authors/lem

$ curl -I localhost:3306/en/authors/lem
HTTP/1.1 200 OK

$ curl -I localhost:3306/en/authors/zzz
HTTP/1.1 404 Not Found

This is a real HTTP redirect only because nothing has been sent yet. Add a loading.tsx next to the page (or wrap the read in <Suspense>) and the shell is streamed before the component runs. The same code under a sibling folder with a loading.tsx answered:

$ curl -s localhost:3306/en/writers/stanislaw-lem | grep -o -E '<meta id="__next-page-redirect"[^>]*>'
<meta id="__next-page-redirect" http-equiv="refresh" content="0;url=/en/writers/lem"/>
# the status line of that response: HTTP/1.1 200 OK

The status is 200 and the redirect is a meta refresh, so a browser follows it but a crawler may treat the 200 page as the content. The same happens with Cache Components: there, reading params outside <Suspense> fails the build (blocking-prerender-dynamic), so the component has to be wrapped, and the redirect is therefore always streamed. The Cache Components version that was built and run:

The same page under Cache Components (the read of params moves under Suspense)
import { Suspense } from "react";
import { notFound, permanentRedirect } from "next/navigation";

const renamed: Record<string, string> = { "stanislaw-lem": "lem", "u-le-guin": "le-guin" };
const current = new Set(["lem", "le-guin"]);

export default function Page(props: PageProps<"/[locale]/authors/[slug]">) {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Author params={props.params} />
    </Suspense>
  );
}

async function Author({ params }: { params: PageProps<"/[locale]/authors/[slug]">["params"] }) {
  const { locale, slug } = await params;
  if (slug in renamed) permanentRedirect(`/${locale}/authors/${renamed[slug]}`);
  if (!current.has(slug)) notFound();
  return <h1>Author {slug}</h1>;
}
$ curl -I localhost:3306/en/authors/stanislaw-lem     # Cache Components build
HTTP/1.1 200 OK
$ curl -s localhost:3306/en/authors/stanislaw-lem | grep -o -E '<meta id="__next-page-redirect"[^>]*>'
<meta id="__next-page-redirect" http-equiv="refresh" content="0;url=/en/authors/lem"/>
$ curl -I localhost:3306/en/authors/zzz               # unknown slug, notFound() inside the boundary
HTTP/1.1 200 OK

The takeaway: inside a streamed boundary the status is already 200, for notFound() just as for a redirect. When the redirect is an SEO-relevant, permanent move (a renamed slug, a changed URL scheme), do it before rendering: in redirects if the pairs are known, or in the Proxy if they need code. Use redirect() for what it is good at: user-specific flow control after you have read data.

In a Client Component

redirect() can also be called in a Client Component while it renders, not in an event handler. The documentation’s rule for handlers is to use useRouter().push(), or to call a Server Action that redirects.

app/[locale]/admin/client-redirect.tsx
"use client";

import { redirect, usePathname } from "next/navigation";

export function ClientRedirect({ locale }: { locale: string }) {
  const pathname = usePathname();

  // Called while rendering, not in an event handler (use useRouter().push there).
  if (!pathname.endsWith("/login")) {
    redirect(`/${locale}/login`);
  }

  return <p>Sign in</p>;
}
$ curl -I localhost:3306/en/admin
HTTP/1.1 307 Temporary Redirect
location: /en/login

During server rendering (here, the prerender of a page whose locale segment is in generateStaticParams) the call turns into a server-side redirect; after hydration the same call performs a client-side navigation. A redirect that depends on the user does not belong in a component that is prerendered, though: the 307 above was produced once, at build time, and is served with Cache-Control: s-maxage=31536000. User-dependent redirects go in the Proxy or behind a request-time API.

In a Server Action

After a mutation, the usual reply is to send the user to the page that shows the result. The action below validates the form, creates a book (a stand-in id here), revalidates the catalogue and redirects. The redirect() call is the last statement and is outside any try/catch.

app/[locale]/books/new/actions.ts
"use server";

import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { isLocale, defaultLocale } from "@/lib/i18n";

export async function createBook(formData: FormData) {
  const title = String(formData.get("title") ?? "").trim();
  const requested = String(formData.get("locale") ?? "");
  const locale = isLocale(requested) ? requested : defaultLocale;
  if (!title) redirect(`/${locale}/books/new?error=title`);

  const id = "1"; // pretend we inserted a row and got its id
  revalidatePath(`/${locale}/books`);
  redirect(`/${locale}/books/${id}`); // outside any try/catch: redirect() throws
}
app/[locale]/books/new/page.tsx
import { createBook } from "./actions";

export default async function NewBook({ params }: PageProps<"/[locale]/books/new">) {
  const { locale } = await params;
  return (
    <form action={createBook}>
      <input type="hidden" name="locale" value={locale} />
      <input name="title" />
      <button>Add</button>
    </form>
  );
}

With JavaScript, React submits the form through the router and the client navigates. Without JavaScript the browser posts a multipart form and the server answers with a 303. That second path can be exercised from the shell: the form contains a hidden $ACTION_ID_…​ field that identifies the action, which curl sends back as a form field.

$ ID=$(curl -s localhost:3306/en/books/new | grep -o 'name="\$ACTION_ID_[0-9a-f]*"' | head -1 | sed 's/name="//;s/"//')
$ curl -si -X POST -F "$ID=" -F locale=en -F "title=Roadside Picnic" localhost:3306/en/books/new | grep -i -E "^HTTP|^location"
HTTP/1.1 303 See Other
Location: /en/books/1

$ curl -si -X POST -F "$ID=" -F locale=es -F "title=" localhost:3306/en/books/new | grep -i -E "^HTTP|^location"
HTTP/1.1 303 See Other
Location: /es/books/new?error=title

$ curl -si -X POST -F "$ID=" -F locale=//evil.example -F "title=x" localhost:3306/en/books/new | grep -i -E "^HTTP|^location"
HTTP/1.1 303 See Other
Location: /en/books/1

The third request is the security point of the example: locale comes from the client, so the action validates it against the list of known locales before using it in a path. Building a redirect target from request data is how open redirects happen (one of the books this section draws on builds redirect URLs from req.headers.origin, which is caller-controlled); validate against an allow-list, or accept only same-site relative paths, as the helper in A safe ?from= parameter does.

redirect() takes a second argument, type: "push" (the default in Server Actions) adds a history entry and "replace" (the default elsewhere) replaces the current one. Import RedirectType from next/navigation for the named values. The argument has no effect in Server Components. Passing an absolute URL redirects to another site.

Pitfalls specific to redirect()

  • try/catch swallows it. redirect() throws NEXT_REDIRECT; a catch (error) that does not rethrow turns the redirect into a silent no-op. Call it after the try block, or rethrow the error in the catch.

  • Not in event handlers. onClick={() ⇒ redirect("/x")} is an error; use useRouter().push("/x"), a Link, or a Server Action.

  • Mutating GETs. A redirect after a mutation belongs to a POST (a Server Action or a mutating Route Handler); a redirect() in a Server Component that also changes data runs again on every refresh and on every prefetch.

  • revalidatePath before redirect. If the target page shows the changed data, revalidate first, as the example does; the redirect is the last statement.

  • In Route Handlers redirect() also works, as does returning NextResponse.redirect(new URL("/x", request.url)); both answer 307 unless you pass another status to the second form.

A safe ?from= parameter

The Proxy below sends anonymous visitors to /en/login?from=/en/account. When the login page later sends the user back, it must not trust from: ?from=//evil.example and ?from=https://evil.example are valid-looking values. The helper accepts only a path on the same site and was run against both attack shapes:

lib/safe-next.ts
// Accept only same-site relative paths from a ?from= parameter.
export function safeNext(from: string | null | undefined, fallback: string): string {
  if (!from) return fallback;
  // must start with a single slash; "//host" and "/\host" are protocol-relative tricks
  if (!from.startsWith("/") || from.startsWith("//") || from.startsWith("/\\")) return fallback;
  try {
    const url = new URL(from, "http://localhost"); // resolves against a dummy origin
    if (url.origin !== "http://localhost") return fallback;
    return url.pathname + url.search;
  } catch {
    return fallback;
  }
}
"/en/account?x=1"      -> /en/account?x=1
"//evil.example/x"     -> /en
"https://evil.example" -> /en
"/\\evil.example"      -> /en
"javascript:alert(1)"  -> /en
null                   -> /en

proxy.ts

The Proxy is a single file, proxy.ts (or .js), at the project root or in src/ next to app/ and pages/, that exports one function. Next.js calls it for every request the matcher allows, before routes are rendered. From it you can redirect, rewrite, set request or response headers and cookies, or answer with a Response of your own. Next.js 16 renamed the feature from Middleware; the functionality is the same and the reason for the new name is that "middleware" suggested Express-style middleware chains, while the feature behaves like a network boundary in front of the app.

The module must export a function named proxy or a default function. It may export a config object. It receives the NextRequest and, optionally, a NextFetchEvent whose waitUntil(promise) keeps the invocation alive for background work such as logging after the response is sent. A NextProxy type infers both parameter types.

import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith("/about")) {
    return NextResponse.rewrite(new URL("/about-2", request.url));
  }
}

export const config = { matcher: "/about/:path*" };

Source: Proxy and proxy.js

A few file rules: there is one Proxy per project (split the logic into modules and import them from proxy.ts); when pageExtensions is customised the file is proxy.page.ts; and the function can be async. The ƒ Proxy (Middleware) line in the next build route table confirms that the Proxy was found.

The matcher

Without a matcher, the Proxy runs on every request, including _next/static, _next/image and the files in public/. A redirect or an authentication check then blocks the CSS and JavaScript of the login page itself. Always declare a matcher, usually as a negative lookahead.

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|legacy|favicon.ico|.*\\..*).*)"],
};

The pattern syntax is path-to-regexp, as in redirects:

  • a source must start with /, is anchored to the start of the path, and may use named parameters (/about/:path matches /about/a but not /about/a/c), modifiers (:path*, :path+, :path?) and a parenthesised regular expression;

  • the value is an array for several paths and must be a constant: the matcher is analysed at build time, and variables are ignored;

  • an entry can be an object with source, locale, has and missing, which adds header, cookie, query and host conditions.

The object form is how to skip prefetch requests, which would otherwise run the Proxy on every Link in the viewport. The following matcher was built and tested with curl against a Proxy that adds an x-proxy-ran header:

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      // skip prefetches
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
$ curl -I localhost:3306/en/login
x-proxy-ran: /en/login
$ curl -I -H 'next-router-prefetch: 1' localhost:3306/en/login      # no x-proxy-ran header
$ curl -I -H 'purpose: prefetch' localhost:3306/en/login            # no x-proxy-ran header
$ curl -I localhost:3306/api/health                                 # excluded by the lookahead: no header

Three details from the reference page that bite in practice. First, even if a negative matcher excludes _next/data, the Proxy still runs for those routes; that is intentional, so that protecting a page also protects its data route. Second, a matcher excluding a path also excludes the Server Function calls on that path, because a Server Function is not a separate route but a POST to the route where it is used; moving a function to another route can silently drop Proxy coverage, so every Server Function checks authorisation itself. Third, the pattern api in the lookahead above also excludes /apiary, because it matches a prefix; write api/ plus a separate api$ entry if that matters.

NextRequest and NextResponse

NextRequest extends the web Request. NextResponse extends the web Response. Together they are the whole API of the Proxy.

Table 4. The parts of NextRequest and NextResponse the Proxy uses
API What it does

request.nextUrl

A URL with extras: pathname (without the basePath), searchParams, basePath and buildId. clone() returns a mutable copy.

request.cookies

get, getAll, has, set, delete and clear on the incoming Cookie header.

request.headers

The web Headers. Internal Flight headers (rsc, next-router-state-tree, next-router-prefetch) are stripped for RSC requests so that the HTML and RSC variants of a request are handled alike.

NextResponse.next()

Continue routing. Accepts { request: { headers } } to forward changed request headers to the application.

NextResponse.redirect(url, status?)

A redirect response; 307 unless you pass another status.

NextResponse.rewrite(url)

Serve url while the address bar keeps the original URL.

NextResponse.json(body, init)

A JSON response.

response.cookies

get, getAll, has, set and delete on the outgoing Set-Cookie header.

response.headers

The web Headers of the outgoing response.

request.ip and request.geo were removed in Next.js 15; read the client address from the platform’s header instead (for example x-forwarded-for behind your reverse proxy) and treat it as untrusted unless the proxy sets it.

The Bookshelf Proxy

The scratch project’s proxy.ts combines the usual jobs: an optimistic authentication redirect, a cookie-based A/B rewrite, a locale redirect, and a request header forwarded to the app. Each part is explained after the listing.

proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { locales, defaultLocale, isLocale } from "@/lib/i18n";

function pickLocale(request: NextRequest): string {
  const preferred = request.headers.get("accept-language")?.slice(0, 2) ?? "";
  return isLocale(preferred) ? preferred : defaultLocale;
}

// Clone nextUrl (not `new URL(path, request.url)`) so basePath is re-applied.
function withPath(request: NextRequest, pathname: string, search = request.nextUrl.search) {
  const url = request.nextUrl.clone();
  url.pathname = pathname;
  url.search = search;
  return url;
}

export async function proxy(request: NextRequest) {
  const { pathname, search } = request.nextUrl;

  // Optimistic check only: the real authorisation lives in the Data Access Layer.
  const account = /^\/(en|es)\/account(\/|$)/.exec(pathname);
  if (account && !request.cookies.has("bookshelf_session")) {
    const login = withPath(request, `/${account[1]}/login`, "");
    login.searchParams.set("from", pathname + search);
    return NextResponse.redirect(login);
  }

  // A/B test: sticky bucket in a cookie, rewrite keeps the URL.
  if (/^\/(en|es)\/about$/.test(pathname)) {
    const bucket = request.cookies.get("bucket")?.value ?? (Math.random() < 0.5 ? "a" : "b");
    const response =
      bucket === "b" ? NextResponse.rewrite(withPath(request, `${pathname}-b`)) : NextResponse.next();
    if (!request.cookies.has("bucket")) {
      response.cookies.set("bucket", bucket, { path: "/", maxAge: 60 * 60 * 24 * 30, sameSite: "lax" });
    }
    return response;
  }

  // Locale redirect.
  if (!locales.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`))) {
    return NextResponse.redirect(withPath(request, `/${pickLocale(request)}${pathname}`));
  }

  // Pass data to the app: request headers (upstream only) + a response header for the demo.
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-request-id", crypto.randomUUID());
  requestHeaders.set("x-locale", pathname.split("/")[1]);
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("x-bookshelf-proxy", "ran");
  return response;
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|legacy|favicon.ico|.*\\..*).*)"],
};

The results, with curl -I unless noted:

$ curl -I localhost:3306/en/account
HTTP/1.1 307 Temporary Redirect
location: /en/login?from=%2Fen%2Faccount

$ curl -I -H 'Cookie: bookshelf_session=x' localhost:3306/en/account
HTTP/1.1 200 OK

$ curl -I -H 'accept-language: es' localhost:3306/books/2
location: /es/books/2

$ curl -I localhost:3306/en/about                           # first visit: bucket assigned
set-cookie: bucket=a; Path=/; Expires=...; Max-Age=2592000; SameSite=lax

$ curl -I -H 'Cookie: bucket=b' localhost:3306/en/about
x-middleware-rewrite: /en/about-b

$ curl -s -H 'Cookie: bucket=b' localhost:3306/en/about | grep -o 'About (variant [AB])'
About (variant B)
  • Redirect with a return path. The authentication branch builds the login URL from nextUrl and stores the original path and query in from, the pattern of the NextResponse reference. The check reads only the cookie’s presence: it is an optimistic check, which the authentication guide recommends for the Proxy because it runs on every request, prefetches included. It never proves the session is valid.

  • Cookies. request.cookies reads the incoming cookie; response.cookies.set() writes Set-Cookie on the response, here to keep a visitor in the same experiment bucket. Set path, maxAge and sameSite explicitly, and httpOnly and secure for anything sensitive; an experiment bucket is not sensitive.

  • Rewrite. NextResponse.rewrite() served /en/about-b while the URL stayed /en/about. The response carries an x-middleware-rewrite header; the name still says "middleware" in 16.3.8 and is visible to clients, so never put anything secret in a rewrite URL.

  • Forwarding data to the app. NextResponse.next({ request: { headers } }) makes the new request headers available to the page, route or action, and only to them: the client does not receive them. A Server Component read them with headers() (locale=en id=<uuid> in the scratch app). The Proxy overwrote a client-supplied x-request-id: spoofed, which is the point: overwrite or delete any header the application trusts, never merge. The docs warn that NextResponse.next({ headers }) is a different thing, it sends headers to the client and can override headers such as the Content-Type that Server Actions rely on. Prefer an allow-list when forwarding request headers, so cookies and authorization do not leak to upstream services.

  • basePath. Under basePath: "/shop", request.nextUrl.pathname has no /shop, but new URL("/en/login", request.url) does not add it back: the first version of this Proxy answered location: /en/login?from=…​ for a request to /shop/en/account, a redirect that left the base path. nextUrl.clone() keeps the base path, and the helper withPath() above produced location: /shop/en/login?from=%2Fen%2Faccount.

Answering directly

The Proxy may return a Response instead of continuing. This guards a write API that must refuse anonymous callers before any handler code runs:

import type { NextRequest } from "next/server";

// Only the Bookshelf write API; reads stay public.
export const config = { matcher: "/api/admin/:path*" };

export function proxy(request: NextRequest) {
  const token = request.headers.get("authorization");
  // Optimistic: only checks that a bearer token is present. Verify it in the handler.
  if (!token?.startsWith("Bearer ")) {
    return Response.json({ success: false, message: "authentication required" }, { status: 401 });
  }
}
$ curl -i localhost:3306/api/admin/books
HTTP/1.1 401 Unauthorized
content-type: application/json

{"success":false,"message":"authentication required"}

$ curl -i -H 'Authorization: Bearer test-token' localhost:3306/api/admin/books
HTTP/1.1 200 OK

{"ok":true}

Returning nothing (or NextResponse.next()) lets the request continue. The second request passed the Proxy only because a bearer token was present; the handler still has to verify it. The documentation has similar recipes for CORS headers (a preflight OPTIONS answered with NextResponse.json({}, { headers })).

Background work with waitUntil

A NextFetchEvent is the second argument. Its waitUntil() keeps the invocation alive after the response has been sent, which is where audit logging and analytics belong. In the scratch app the response to GET /api/health came back in 2 ms while the audit line was written about 300 ms later:

import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server";

export function proxy(request: NextRequest, event: NextFetchEvent) {
  const started = Date.now();
  // runs after the response has been sent; must not block it
  event.waitUntil(
    new Promise<void>((resolve) =>
      setTimeout(() => {
        console.log(`AUDIT ${request.method} ${request.nextUrl.pathname} after ${Date.now() - started}ms`);
        resolve();
      }, 300),
    ),
  );
  return NextResponse.next();
}

export const config = { matcher: "/api/health" };
$ curl -s -o /dev/null -w "response in %{time_total}s\n" localhost:3306/api/health
response in 0.001907s
# server log, a moment later:
AUDIT GET /api/health after 302ms

The Pages Router

The three next.config keys, proxy.ts (or middleware.ts) and NextResponse behave the same in a Pages Router application; the Proxy file sits next to pages/. What differs is the code-level redirect. The documentation lists three: returning a redirect object (destination plus permanent) from getStaticProps or getServerSideProps, calling res.redirect() in an API Route, and useRouter().push() on the client. The redirect() and permanentRedirect() functions of this page are App Router APIs. None of the Pages Router forms was exercised in the App Router scratch project, so only the documentation backs this paragraph.

Node.js Runtime, the Deprecated Edge middleware.ts and the Codemod

The Proxy’s headline change in Next.js 16 is the runtime. Middleware ran on the Edge runtime, a restricted environment that supports only a subset of the Node.js APIs, so some npm packages (database drivers and parts of auth libraries among them) did not work in it. The Proxy runs on the Node.js runtime, with the full Node.js API, in the same version of Node as the rest of the server.

Table 5. How the runtime of Middleware and Proxy evolved
Version Change

12.0 / 12.2

Middleware introduced in beta (12.0.0) and stable (12.2.0), Edge only.

13.0 / 13.1

Middleware can set request and response headers and send responses (13.0); advanced flags skipMiddlewareUrlNormalize and skipTrailingSlashRedirect (13.1).

15.0

request.ip and request.geo removed.

15.2

Node.js runtime for Middleware, experimental.

15.5

Node.js runtime for Middleware, stable.

16.0

Middleware deprecated and renamed to Proxy; the Proxy defaults to the Node.js runtime and the edge runtime is not supported in it.

What the scratch builds showed on 16.3.8 (a helper header recorded whether the EdgeRuntime global existed in the function):

Table 6. Observed behaviour of proxy.ts and middleware.ts
Setup Result

proxy.ts exporting proxy

Runs on Node.js. No deprecation message.

proxy.ts with export const config = { runtime: "edge", …​ }

next build fails with "Next.js can’t recognize the exported config field in route. Proxy does not support Edge runtime."

middleware.ts exporting middleware, no runtime

Builds with a warning that the middleware file convention is deprecated and runs on the Edge runtime.

middleware.ts with runtime: "nodejs"

Builds with the same warning and runs on Node.js (the stable 15.5 behaviour).

both middleware.ts and proxy.ts

next build fails: both files are detected, "Please use ./proxy.ts only".

So a project that must stay on the Edge runtime keeps using middleware.ts for now: the upgrade guide says the runtime of the Proxy is nodejs and cannot be configured, and that Edge instructions will follow in a minor release. The documentation is not fully consistent here: the Edge Runtime reference page still describes the Edge runtime as "used in Proxy", while the Proxy reference says that setting runtime in a Proxy file throws. This page follows the Proxy reference and the build behaviour above, which is what Next.js does on 16.3.8. The Proxy is not available for a static export, and on other platforms it is "platform-specific" through adapters, as the platform table in the reference shows.

The migration

For an application that has only a Node-compatible middleware.ts, the migration is a rename. Next.js ships a codemod:

$ npx @next/codemod@canary middleware-to-proxy .

Run on the scratch project (whose only change was a middleware.ts exporting middleware), it removed middleware.ts, created proxy.ts and renamed the exported function from middleware to proxy:

// middleware.ts -> proxy.ts

- export function middleware() {
+ export function proxy() {

The version 16 upgrade codemod includes the same step, together with the renamed flags (skipMiddlewareUrlNormalize is now skipProxyUrlNormalize). Things the codemod does not decide for you:

  • Edge-only code. If the middleware relied on the Edge runtime (for example, a library with an Edge build only), moving it to the Proxy needs a Node-compatible alternative, or you keep middleware.ts.

  • Dependencies that now work. Code that read data over a fetch call to an API route only because the Edge runtime could not import a database driver can now import it directly (within the guidance in the next section).

  • Where the file lives. Still next to app/ or pages/, or in src/.

What the Proxy Should and Should Not Do

The official documentation is unusually blunt: the Proxy is meant to be used as a last resort, and the rename is partly meant to discourage reaching for it. The reasons are structural: it runs on every matching request (prefetches included), before the render, in a unit that is invoked separately from your render code and, in optimised cases, deployed to a CDN.

Good uses

  • Cheap redirects that need request data: locale detection from Accept-Language, a cookie, a legacy-path map.

  • Rewrites for experiments: A/B buckets in a cookie, as above, so that cached variants do not collide.

  • Optimistic checks: is there a session cookie at all, then send the visitor to /login. It centralises the redirect and protects static routes that share data between users, such as paywalled content.

  • Headers for every response: a nonce-based Content-Security-Policy, security headers, request IDs.

  • Refusing obviously invalid requests early (the 401 above), before any rendering work.

Things to avoid

  • Authorisation as the only line of defence. The Proxy is no full session management or authorisation layer. Verify the session where the data is read (a Data Access Layer used by Server Components, Route Handlers and Server Actions), and in every Server Function: the matcher is easy to get wrong, and the documentation shows how a matcher that skips a path silently skips the Server Function calls on it. In March 2025 the Middleware of Next.js 12 to 15 could be bypassed with an internal x-middleware-subrequest header (CVE-2025-29927, fixed in 15.2.3, 14.2.25, 13.5.9 and 12.3.5); any application that relied on Middleware alone was open. Keep the application patched and do not make the Proxy the only check.

  • Slow work. Database queries, calls to remote services and anything with high latency delay every request and every prefetch. fetch options such as cache, next.revalidate and next.tags have no effect in the Proxy.

  • Shared state. Do not rely on module globals or shared modules between the Proxy and the application: they run separately, and the Proxy may run on a CDN. Pass information through headers, cookies, rewrites, redirects or the URL.

  • Large request bodies. When a Proxy exists, Next.js buffers and clones each request body (10 MB by default) so that it can be read twice; the proxyClientMaxBodySize option, which the documentation labels experimental, sets the limit.

  • Business logic. Anything that could be an ordinary function in a Route Handler, a layout or a Server Action (redirect() after reading data) should be one. The self-hosting guide even suggests moving logic that needs all of Node.js to a layout, as a Server Component, and using redirects or rewrites with has and missing for cookie and header conditions.

  • Secrets in rewrite URLs and headers. Both can be visible to the client (x-middleware-rewrite).

Redirect Maps at Scale

A site migration can leave thousands of old URLs that must forward to new ones. redirects in next.config.ts is simple and fast, but it is part of the build, the rule list is held in memory by the server, and the guide warns that a platform may cap it (1,024 entries on Vercel). Past roughly a thousand rules, or when the list changes without a deploy, the guide’s advice is a redirect map handled by the Proxy, with two design questions: where the map lives and how to look it up cheaply.

1. Creating and storing the map. A redirect map is a key-value structure from old path to destination, kept in a JSON file or a store such as Redis or a KV service:

redirects/redirects.json
{
  "/catalogue/dune": { "destination": "/en/books/1", "permanent": true },
  "/catalogue/neuromancer": { "destination": "/en/books/2", "permanent": true },
  "/summer-sale": { "destination": "/en", "permanent": false }
}

2. Cheap lookup. Reading a large map on every request is slow. The documented technique puts a Bloom filter (a compact structure that answers "definitely not present" or "possibly present") in the Proxy, so that only the paths the filter reports as present trigger the real lookup in a Route Handler. The filter is generated at build time from the map; the example uses the bloom-filters package (3.0.4 was installed):

scripts/build-bloom.mjs
import { readFileSync, writeFileSync } from "node:fs";
import { ScalableBloomFilter } from "bloom-filters";

const redirects = JSON.parse(readFileSync("redirects/redirects.json", "utf8"));
const filter = new ScalableBloomFilter(); // default options; tune the error rate for big maps
for (const source of Object.keys(redirects)) filter.add(source);
writeFileSync("redirects/bloom-filter.json", JSON.stringify(filter.saveAsJSON()));
console.log(`${Object.keys(redirects).length} redirects in the filter`);
The redirect-map branch of proxy.ts
import { ScalableBloomFilter } from "bloom-filters";
import bloomJson from "@/redirects/bloom-filter.json";

type RedirectEntry = { destination: string; permanent: boolean };
const bloom = ScalableBloomFilter.fromJSON(bloomJson as any);

// inside proxy(request):
if (bloom.has(pathname)) {
  const lookup = withPath(request, "/api/redirects", "");
  lookup.searchParams.set("pathname", pathname);
  const res = await fetch(lookup);
  if (res.ok) {
    const entry: RedirectEntry = await res.json();
    const status = entry.permanent ? 308 : 307;
    return NextResponse.redirect(withPath(request, entry.destination, ""), status);
  }
}
app/api/redirects/route.ts
import type { NextRequest } from "next/server";
import redirects from "@/redirects/redirects.json";

type RedirectEntry = { destination: string; permanent: boolean };

export function GET(request: NextRequest) {
  const pathname = request.nextUrl.searchParams.get("pathname");
  if (!pathname) return new Response("Bad Request", { status: 400 });

  const entry = (redirects as Record<string, RedirectEntry>)[pathname];
  // The Bloom filter can report false positives, so a miss is normal here.
  if (!entry) return new Response(null, { status: 404 });
  return Response.json(entry);
}
$ node scripts/build-bloom.mjs
3 redirects in the filter

$ curl -I localhost:3306/catalogue/dune
HTTP/1.1 308 Permanent Redirect
location: /en/books/1

$ curl -I localhost:3306/summer-sale
HTTP/1.1 307 Temporary Redirect
location: /en

$ curl -I localhost:3306/catalogue/unknown          # not in the filter: no lookup, the locale redirect applies
location: /en/catalogue/unknown

Choices made in the scratch version, and why:

  • The matcher excludes api. The Proxy calls its own Route Handler with fetch; without the exclusion the lookup request would run through the Proxy again.

  • The handler answers 404 for a miss. The Bloom filter has false positives by design, so "no such redirect" is an expected answer; the official example returns 400 for it, which makes a harmless miss look like a client error in your logs.

  • withPath() instead of request.url. The first version used new URL("/api/redirects", request.nextUrl.origin) and new URL(entry.destination, request.url). With basePath: "/shop" the lookup went to the wrong URL and redirected to a destination without /shop; building both from nextUrl.clone() fixed it (/shop/catalogue/dune answered 308 location: /shop/en/books/1).

  • Same-origin fetch is a design smell. Behind a reverse proxy, request.nextUrl.origin may not be reachable from inside the container. The Proxy runs on Node.js in 16, so it can import a data client or a bigger lookup structure and skip the extra HTTP hop; the Route Handler pattern exists mainly because earlier Middleware ran on the Edge runtime and could not do that. Measure before choosing, and keep the Bloom filter if the map file is large.

  • The map is trusted data. The destinations in the example are written by you. If editors can add rows to a database-backed map, validate each destination (a relative path or an allowed host) when the row is saved, for the same open-redirect reason as in A safe ?from= parameter.

  • Rebuild the filter when the map changes. A stale filter hides new entries (it reports "absent"), so generate it in the build and deploy the map and the filter together.

Which One for Which Redirect

Table 7. Bookshelf redirect cases and the tool for each
Case Use Why

/catalog/7 moved to /en/books/7 for every visitor

redirects, permanent: true

A known pattern, no code, evaluated before anything else.

Old URL shapes with a language prefix

redirects with a locale parameter

The locale is a path segment in the App Router.

/api/v1/books should still work while the code moved

rewrites, afterFiles

Same URL, new implementation.

Pages not migrated yet live on the old site

rewrites, fallback, external destination

The old site answers only when Next.js has no page.

/ goes to the visitor’s language

Proxy

It needs the Accept-Language header and a cookie.

Anonymous visitors to /account go to /login

Proxy (optimistic) plus a check in the data layer

Cheap and early; the real check is where the data is read.

An experiment shows variant B to half the visitors

Proxy rewrite with a cookie

Rewrite keeps the URL; the cookie keeps the bucket.

After adding a book, show it

redirect() in the Server Action

The action has the new id; progressive enhancement yields 303.

An author slug was renamed in the database

permanentRedirect() (or the Proxy for SEO)

The rename is data; the redirect must still happen before streaming for a real 308.

Several thousand migrated URLs

Proxy with a redirect map and a Bloom filter

Beyond the build-time limits of redirects.

A header on every response

headers (static) or Proxy (dynamic)

Static values need no code; nonces do.

What Changed Recently

Table 8. Changes to redirects, rewrites and Proxy by version
Version Change

15.0

request.ip and request.geo removed from NextRequest. Async request APIs (cookies(), headers(), params) make Server Components and Server Actions read them with await, which matters for the redirect examples above.

15.1

unstable_doesProxyMatch and related helpers for unit testing Middleware (now Proxy) files appear in next/experimental/testing/server; still experimental.

15.2 and 15.5

The Node.js runtime for Middleware: experimental in 15.2, stable in 15.5.

15.2.3, 14.2.25, 13.5.9, 12.3.5

Fixes for CVE-2025-29927, the Middleware bypass through the internal x-middleware-subrequest header.

16.0

Middleware deprecated and renamed to Proxy (proxy.ts, export function proxy); the Proxy runs on Node.js and does not support the Edge runtime; skipMiddlewareUrlNormalize renamed skipProxyUrlNormalize; the middleware-to-proxy codemod. middleware.ts keeps working, with a deprecation warning, for Edge users.

16.0 to 16.3

The upgrade guide says an update on Edge runtime instructions for the Proxy will follow in a minor release; as of 16.3.8 the Proxy still rejects runtime: "edge".

The official statements to check when upgrading are the version history tables at the end of the proxy.js reference and the redirects reference, which list has (10.2.0), missing (13.3.0) and the original redirects (9.5.0).

Where the Books Are Outdated

The two books this section was prepared with (Packt, 2022 and Apress, 2023) are about the Pages Router, and searching both for redirects, rewrites and middleware finds none of the mechanisms on this page: neither teaches the redirects, rewrites or headers keys of next.config.js, and neither has a middleware or Proxy file (the word "middleware" appears only for Express-style, Redux and API-route "middlewares").

  • Redirects are written as Pages Router code. The 2022 book redirects inside API Routes with res.redirect(…​) and after sign-in with the client-side useRouter hook, and describes redirecting anonymous users away on the server before the page renders. The current equivalents are NextResponse.redirect() or redirect() in a Route Handler, redirect() in a Server Action or Server Component, and a Proxy for the early, optimistic version of the same check. A client-side hook (useAuth, useSession or router.push after render) protects nothing; the check must run on the server.

  • redirectToCheckout. Both books send customers to Stripe with the client-side stripe.redirectToCheckout, which Stripe has deprecated; the current pattern is to redirect to the Checkout Session’s url (a 303 from a Server Action or Route Handler, or embedded Checkout).

  • Internationalisation by redirect. The 2023 book localises with the i18n key in next.config.js and next-i18next, which the Pages Router supports and the App Router replaced with app/[locale] segments and a locale redirect in the Proxy, as in the example above.

  • Middleware. Anything the books or older tutorials call "middleware" or _middleware.js is now proxy.ts on Node.js; middleware.ts is deprecated (16.0), and the Edge runtime is no longer the default.

  • Redirect URLs built from the request. One book uses req.headers.origin to build redirect URLs: the header is caller-controlled, so the redirect target can be forged. Use a fixed base URL from configuration, a relative path, or an allow-list.

  • Status codes. Tutorials of that era talk about 301 and 302. next.config redirects answer 307 and 308 on purpose so the request method is preserved, and a Server Action form posted without JavaScript answers 303.