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.
| Mechanism | Runs | Can see | Result |
|---|---|---|---|
|
Before the Proxy and before the filesystem |
The path, query, headers, cookies and host, matched declaratively ( |
307 or 308 (or a |
|
Before files ( |
The same declarative matchers |
The destination’s content under the original URL |
|
First, before everything else |
The same declarative matchers |
Extra response headers |
|
After |
The whole request, in code: |
Redirect (any status), rewrite, headers, cookies or a direct |
|
While rendering a Server Component, in a Server Action or in a Route Handler |
Anything your code has read: a database row, a session, |
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.
(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:
| Step | What runs | Notes |
|---|---|---|
1 |
|
Checked first. A later |
2 |
|
Checked before the filesystem, |
3 |
|
Runs for every request its |
4 |
|
Run after the Proxy and before any file, so they can shadow a file in |
5 |
The filesystem |
|
6 |
|
Run only when step 5 found nothing. A plain array returned from |
7 |
Dynamic routes |
For example |
8 |
|
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.
Source: proxy.js: execution order
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).
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
Source: next.config.js: redirects
The behaviours worth knowing, each confirmed with curl -I:
-
Status codes.
permanent: trueanswers 308 andpermanent: falseanswers 307. A 308 response also carries aRefresh: 0;url=…header, which the documentation says exists for old browsers (IE11).statusCodereplacespermanentwhen a client needs 301 or 302; you cannot set both. -
Query strings pass through.
/old-blog/2024/post-1?hello=worldkepthello=worldon the destination. -
Path patterns follow path-to-regexp.
:slugmatches 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/abcdid 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 thedestination. The characters(,),{,},:,*,+and?must be escaped with a double backslash when they are meant literally. -
Always put a
/before the colon./old-blog/:slugis a pattern;old-blog:slugis a literal string and risks an infinite redirect. -
The destination may be absolute.
destination: "https://example.com/store"redirects out of the site (the/storerule in the scratch app answered307with thatlocation).
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.
Source: redirects: basePath support and redirects: i18n support
Limits and pitfalls
-
Config redirect responses carry no
headersentries (observed). Althoughheadersis step 1 of the order, the 308 and 307 responses produced byredirectsrules in the scratch app did not include the security headers that a catch-allheadersrule 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: trueis hard to undo. Start withpermanent: falseand switch once the mapping is final. -
In the Pages Router,
redirectsdoes not apply to client-side routing. The documentation saysLinkandrouter.pushdo not get redirected, as they navigate without a request unless a Proxy matches the path. -
Platform limits. The guide notes that
redirectscan 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_REDIRECTSin the browser.curl -IL --max-redirs 5finds 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.
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
Source: next.config.js: rewrites
Three things the examples show:
-
The URL does not change.
/api/v1/booksis served by/api/books, which is the usual way to keep a public URL stable while the implementation moves, and/en/shelf/2renders the book page. A rewrite also applies to client-side navigation:<Link href="/en/shelf/2">shows the book page and keeps/en/shelf/2in the address bar. -
An external destination makes Next.js a reverse proxy. The
fallbackrule forwarded/legacy/shop/cartto 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 infallback, a page added later inapp/automatically takes over, as it is found before the fallback is reached. Usefallbackrather thanbeforeFilesfor 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/:qto the page/en/rqdelivered{"q":"dune"}assearchParamsin the page. A Route Handler is different: for the same kind of rewrite to/api/echo,request.nextUrl.searchParamsin the handler was empty andrequest.urlstill showed the original path. To hand a value to a Route Handler, put it in the destination path, or useNextResponse.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
Source: next.config.js: headers
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. AContent-Security-Policywith a nonce changes on every request, so it is built in the Proxy (see the content security policy guide in the references); a fixedX-Content-Type-Optionsneeds no code at all. -
Do not override
Cache-Controlon routes Next.js caches. TheCache-Controlset by a rule above replaced thes-maxage=31536000that the prerendered page would otherwise have, which is the intention only for the preview cookie. The self-hosting guide says Next.js setsCache-Controlitself and, for immutable assets, cannot be overridden. -
Remember the redirect caveat above. The headers rule did not appear on
redirectsresponses.
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.
| Mechanism | Status | Notes |
|---|---|---|
|
307 |
Temporary; not cached. |
|
308 |
Permanent; adds a |
|
As given |
301 was observed. Use it only for clients that need the legacy codes. |
|
307 |
The |
|
200 |
The response is already on its way, so Next.js emits a |
|
None (client navigation) |
The action’s result tells the client router to navigate. |
|
303 |
"See Other": the browser follows with a |
|
307 by default |
Pass a status as the second argument (the guide uses |
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.
Source: redirect, permanentRedirect
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.
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
Source: permanentRedirect
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:
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.
Source: redirect, blocking-prerender-dynamic
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.
"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.
Source: redirect: Client Component
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.
"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
}
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.
Source: Mutating data, redirect
Pitfalls specific to redirect()
-
try/catchswallows it.redirect()throwsNEXT_REDIRECT; acatch (error)that does not rethrow turns the redirect into a silent no-op. Call it after thetryblock, or rethrow the error in thecatch. -
Not in event handlers.
onClick={() ⇒ redirect("/x")}is an error; useuseRouter().push("/x"), aLink, 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. -
revalidatePathbeforeredirect. 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 returningNextResponse.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:
// 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*" };
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
sourcemust start with/, is anchored to the start of the path, and may use named parameters (/about/:pathmatches/about/abut 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,hasandmissing, 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.
Source: proxy.js: matcher
NextRequest and NextResponse
NextRequest extends the web Request. NextResponse extends the web Response. Together they are the whole API of
the Proxy.
| API | What it does |
|---|---|
|
A |
|
|
|
The web |
|
Continue routing. Accepts |
|
A redirect response; 307 unless you pass another status. |
|
Serve |
|
A JSON response. |
|
|
|
The web |
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.
Source: NextRequest, NextResponse
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.
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|.*\\..*).*)"],
};
Source: proxy.js: examples
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
nextUrland stores the original path and query infrom, the pattern of theNextResponsereference. 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.cookiesreads the incoming cookie;response.cookies.set()writesSet-Cookieon the response, here to keep a visitor in the same experiment bucket. Setpath,maxAgeandsameSiteexplicitly, andhttpOnlyandsecurefor anything sensitive; an experiment bucket is not sensitive. -
Rewrite.
NextResponse.rewrite()served/en/about-bwhile the URL stayed/en/about. The response carries anx-middleware-rewriteheader; 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 withheaders()(locale=en id=<uuid>in the scratch app). The Proxy overwrote a client-suppliedx-request-id: spoofed, which is the point: overwrite or delete any header the application trusts, never merge. The docs warn thatNextResponse.next({ headers })is a different thing, it sends headers to the client and can override headers such as theContent-Typethat Server Actions rely on. Prefer an allow-list when forwarding request headers, so cookies andauthorizationdo not leak to upstream services. -
basePath. UnderbasePath: "/shop",request.nextUrl.pathnamehas no/shop, butnew URL("/en/login", request.url)does not add it back: the first version of this Proxy answeredlocation: /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 helperwithPath()above producedlocation: /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 })).
Source: proxy.js: producing a response
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.
Source: redirects: other redirects and Pages Router: redirecting
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.
| 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 |
15.0 |
|
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 |
What the scratch builds showed on 16.3.8 (a helper header recorded whether the EdgeRuntime global existed in the
function):
| Setup | Result |
|---|---|
|
Runs on Node.js. No deprecation message. |
|
|
|
Builds with a warning that the |
|
Builds with the same warning and runs on Node.js (the stable 15.5 behaviour). |
both |
|
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
fetchcall 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/orpages/, or insrc/.
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-subrequestheader (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.
fetchoptions such ascache,next.revalidateandnext.tagshave 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
proxyClientMaxBodySizeoption, 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 usingredirectsorrewriteswithhasandmissingfor 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:
{
"/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):
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`);
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);
}
}
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 withfetch; 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 ofrequest.url. The first version usednew URL("/api/redirects", request.nextUrl.origin)andnew URL(entry.destination, request.url). WithbasePath: "/shop"the lookup went to the wrong URL and redirected to a destination without/shop; building both fromnextUrl.clone()fixed it (/shop/catalogue/duneanswered308 location: /shop/en/books/1). -
Same-origin
fetchis a design smell. Behind a reverse proxy,request.nextUrl.originmay 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
| Case | Use | Why |
|---|---|---|
|
|
A known pattern, no code, evaluated before anything else. |
Old URL shapes with a language prefix |
|
The locale is a path segment in the App Router. |
|
|
Same URL, new implementation. |
Pages not migrated yet live on the old site |
|
The old site answers only when Next.js has no page. |
|
Proxy |
It needs the |
Anonymous visitors to |
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 keeps the URL; the cookie keeps the bucket. |
After adding a book, show it |
|
The action has the new id; progressive enhancement yields 303. |
An author slug was renamed in the database |
|
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 |
A header on every response |
|
Static values need no code; nonces do. |
What Changed Recently
| Version | Change |
|---|---|
15.0 |
|
15.1 |
|
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 |
16.0 |
Middleware deprecated and renamed to Proxy ( |
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 |
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-sideuseRouterhook, and describes redirecting anonymous users away on the server before the page renders. The current equivalents areNextResponse.redirect()orredirect()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,useSessionorrouter.pushafter render) protects nothing; the check must run on the server. -
redirectToCheckout. Both books send customers to Stripe with the client-sidestripe.redirectToCheckout, which Stripe has deprecated; the current pattern is to redirect to the Checkout Session’surl(a 303 from a Server Action or Route Handler, or embedded Checkout). -
Internationalisation by redirect. The 2023 book localises with the
i18nkey innext.config.jsand next-i18next, which the Pages Router supports and the App Router replaced withapp/[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.jsis nowproxy.tson Node.js;middleware.tsis deprecated (16.0), and the Edge runtime is no longer the default. -
Redirect URLs built from the request. One book uses
req.headers.originto 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.configredirects answer 307 and 308 on purpose so the request method is preserved, and a Server Action form posted without JavaScript answers 303.