Skip to content
Chapter 34Lesson 3

Edge redirects and rewrites

Redirects and rewrites in next.config.ts, the edge-applied home for URL rules that are the same for every visitor.

The product just renamed a whole section: what used to live under /account now lives under /settings, permanently. The old URLs don’t vanish on rename day. They sit in months of emails, in bookmarks, in whatever Google indexed last quarter, and every one needs to land the visitor on the new path.

You already know how to write this. In the last chapter you built proxy.ts and sent exactly this kind of redirect: read the path, return a 308 to the new one. But this redirect is always true. It never reads the cookie, the header, or the session; /account goes to /settings no matter who’s asking. Put an always-true rule in proxy.ts and you’ve signed the platform up to run a function on every matched request, just to return a redirect the CDN edge could have served for free. The proxy is the right home for rules that depend on the request, and this one depends on nothing.

So this lesson adds the third home for URL rules: redirects() and rewrites() in next.config.ts, the static, edge-applied place for rules that are the same for every visitor. You keep the redirect-versus-rewrite and 307-versus-308 distinctions from the last chapter; what’s new is the config home and why it’s the default when a rule reads nothing about the request. Two mistakes here are costly: one loops your redirect forever, and one you can’t take back after launch.

Redirecting at the edge, with no function call

Section titled “Redirecting at the edge, with no function call”

A redirect in next.config.ts is an async function named redirects that returns an array of rules. Each rule has three fields: where to match (source), where to send (destination), and whether it’s permanent. Here’s the rebrand in full:

next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async redirects() {
return [
{
source: '/account/:path*',
destination: '/settings/:path*',
permanent: true,
},
];
},
};
export default nextConfig;

The :path* in /account/:path* matches the whole sub-tree under /account; it’s pattern syntax we’ll pull apart in the next section. The headline is the cost: this rule is applied at the CDN edge, with zero function invocation. Next reads the rule table once at build time and hands it to the edge, which from then on answers /account/... with a redirect to /settings/... before any of your code runs. No proxy.ts, no Server Component. It’s served from the same layer as your cached assets, about as fast and cheap as the web gets.

The proxy’s cost model is the opposite: a function round trip on every request the matcher selects, the right price for a rule that has to read the request. A rule that reads nothing and sends the same redirect to everyone spends compute to produce a constant, so that round trip is pure waste. Here’s the same rebrand redirect written both ways:

proxy.ts
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith('/account')) {
const settings = pathname.replace('/account', '/settings');
return NextResponse.redirect(new URL(settings, request.url), 308);
}
return NextResponse.next();
}

Pays a function invocation per matched request, just to learn it returned the same 308 again.

The permanent field is the 308-versus-307 distinction surfaced as a boolean. permanent: true sends a 308, the indexed redirect that tells search engines the page moved for good and forwards its link equity to the new URL. permanent: false sends a 307, temporary, so the old URL stays canonical. Both preserve the request method, which is why Next uses this pair rather than 301/302. The rebrand is a real permanent move, so true is right.

One constraint shapes the rest of the lesson: these config rules are global and request-blind, the same for every visitor, with no per-route override and no view into the session. Reading nothing about the request is exactly what lets the edge apply the rule without running your code, but a rule that needs to know who’s asking has to move to the proxy.

The source field is a pattern, not a literal path, and two patterns that look alike can behave very differently: one forwards a whole rebranded section, the other forwards only its top level and strands every nested URL.

Next compiles source with path-to-regexp .

async redirects() {
return [
{ source: '/account', destination: '/settings', permanent: true },
{ source: '/account/:slug', destination: '/settings/:slug', permanent: true },
{ source: '/account/:path*', destination: '/settings/:path*', permanent: true },
{ source: '/invoices/:id(\\d+)', destination: '/bills/:id', permanent: true },
];
}

Static path. /account matches exactly /account, anchored to the start, so it won’t match /team/account.

async redirects() {
return [
{ source: '/account', destination: '/settings', permanent: true },
{ source: '/account/:slug', destination: '/settings/:slug', permanent: true },
{ source: '/account/:path*', destination: '/settings/:path*', permanent: true },
{ source: '/invoices/:id(\\d+)', destination: '/bills/:id', permanent: true },
];
}

Named single segment. :slug captures exactly one segment, so /account/:slug matches /account/billing but not /account/billing/history. The capture is reused by name in destination.

async redirects() {
return [
{ source: '/account', destination: '/settings', permanent: true },
{ source: '/account/:slug', destination: '/settings/:slug', permanent: true },
{ source: '/account/:path*', destination: '/settings/:path*', permanent: true },
{ source: '/invoices/:id(\\d+)', destination: '/bills/:id', permanent: true },
];
}

Catch-all. :path* captures zero or more segments, so /account, /account/billing, and /account/billing/history all match. This is the rebrand workhorse: one rule forwards the entire sub-tree. (+ means one-or-more, ? optional.)

async redirects() {
return [
{ source: '/account', destination: '/settings', permanent: true },
{ source: '/account/:slug', destination: '/settings/:slug', permanent: true },
{ source: '/account/:path*', destination: '/settings/:path*', permanent: true },
{ source: '/invoices/:id(\\d+)', destination: '/bills/:id', permanent: true },
];
}

Regex-constrained. :id(\\d+) matches only digits, so /invoices/123 matches and /invoices/abc does not. The double backslash is a TypeScript string escape: \\d in source is the single \d the matcher sees.

1 / 1

Two facts the steps don’t show. Query strings come along automatically, so /account/billing?ref=email lands on /settings/billing?ref=email untouched. And mind the boundary between :slug and :path*: :slug stops at the first slash while :path* keeps going, so picking :slug for a rebrand silently leaves every nested URL behind.

One mistake takes the whole route down: the forward slash must come before the colon. Write '/account/:path*', never 'account/:path*'.

{
source: 'account/:path*',
destination: '/settings/:path*',
permanent: true,
}

This redirects in an infinite loop. The leading / anchors a pattern to the start of the path. Drop it and the pattern still matches the path it just redirected to: the browser lands on the destination, the unanchored rule matches again, and it bounces until the browser gives up with “too many redirects.”

Run a few paths through the two rules people confuse.

Each path arrives at the edge. Sort it by which `source` pattern would catch it. Remember: `:slug` stops at one segment; `:path*` goes as deep as you like. Drag each item into the bucket it belongs to, then press Check.

Matches /account/:slug One segment only
Matches /account/:path* only Nested — too deep for :slug
Matches neither Wrong prefix or wrong shape
/account/billing
/account/profile
/account/billing/history
/account/team/members/2024
/team/account
/accounts/billing

A config rule can read part of the request without leaving the edge. Every rule can carry a has array, a missing array, or both: lists of conditions on a cookie, header, host, or query parameter.

next.config.ts
{
source: '/app/:path*',
missing: [{ type: 'cookie', key: 'session_token' }],
destination: '/welcome',
permanent: false,
}

That rule reads: when someone hits /app/... without a session_token cookie, send them to /welcome. It fires only when every has entry matches and every missing entry does not. The value field is optional: omit it to match on presence, or give a string (regex-capable, with named groups that can flow into destination) to require a specific value. The other common condition is type: 'host', which routes an apex domain and its www subdomain to different destinations.

The limit is the point. has and missing test presence and literal value; they never decode, verify, or trust. A has: cookie check confirms a session_token exists, not that the session is valid or who the user is. Gating the marketing page on a missing cookie is fine, since it’s a UX nudge that the real app re-checks and corrects if it’s wrong. But anything that validates a session, reads the current user, or makes an authorization decision belongs in proxy.ts for the cheap presence check, then in the route for the authoritative one.

Rewrites: keep the URL, change what serves it

Section titled “Rewrites: keep the URL, change what serves it”

A rewrite is the other operation from last chapter: same source/destination shape, but the URL in the address bar stays put while the server renders something else behind it. A redirect is visible and costs two round trips; a rewrite is invisible and costs one.

The config shape matches redirects() with one field removed:

next.config.ts
async rewrites() {
return [
{ source: '/docs/:path*', destination: '/external-docs/:path*' },
];
}

There’s no permanent: a rewrite isn’t an HTTP redirect status, it’s an internal swap, so permanence doesn’t apply. A flat array, as above, is the common case; those rules run after Next has tried to match a real route. For finer ordering, rewrites() can instead return an object with beforeFiles, afterFiles, and fallback stages that position your rules around the filesystem; external proxies usually go in fallback.

The canonical use is serving content that lives elsewhere under your own domain, such as a marketing CMS, a docs site, or a help center: the visitor stays on app.example.com/docs/... while the bytes come from an upstream origin. That upstream is where the one risk rewrites carry shows up.

async rewrites() {
return [
{ source: '/blog/:path*', destination: '/cms/blog/:path*' },
];
}

Cheap: the destination is a route on your own app. Next renders /cms/blog/... while the URL stays /blog/.... No external hop, no proxy cost, the same invisible swap you’d use for multi-tenancy.

An external destination makes your app a reverse proxy : every matched request spends real compute and bandwidth instead of being free at the edge. That’s fine when the matcher hits only the prefix you mean to proxy, and a problem when it doesn’t.

If both a proxy.ts rewrite and a config rewrite match the same request, the proxy wins, because it runs first. Give each rule one home: a request-blind rewrite like serving the docs site belongs in config, while a request-dependent one like the subdomain-to-org rewrite from last chapter belongs in the proxy.

trailingSlash: lock your canonical URL form before launch

Section titled “trailingSlash: lock your canonical URL form before launch”

One more flag belongs in this family, and its cost is permanence, not compute. trailingSlash decides one thing for the whole app: whether the canonical URL carries a trailing slash.

next.config.ts
const nextConfig: NextConfig = {
trailingSlash: false,
};

The default, false, serves /about as canonical. Set it to true and the app serves /about/, redirecting /about to it. One shape for every route.

That choice reaches every URL your app touches: the links it generates, the backlinks others point at you, the URLs search engines have indexed. Once you pick a form, the web settles around it. Flip it after launch and every previously-canonical URL now redirects, so every backlink and bookmark pays a redirect hop forever and search engines re-crawl to learn the new shape. No crash, just a lasting tax on links you don’t control.

So decide once and never touch it. false is right for almost every web app: cleaner URLs, and as the framework default, it’s what every tool and link already assumes. It belongs in config because it’s global and request-independent, the same rule for every visitor on every route.

With all three homes in view, you can complete the decision tree you sketched last chapter, whose config branch then pointed at a home you hadn’t built. The edge adds one subtlety: a has/missing presence check that a static rule can make without leaving the edge.

It comes down to two questions in a fixed order.

First, does the rule depend on who’s asking (session, geo, A/B bucket, host beyond simple matching)? If no, it’s the same for everyone and belongs in next.config.ts, applied at the edge with zero invocation. If yes, ask the second.

Second, must it run before the route renders, conditionally, on every request? If yes, it’s a proxy.ts rule, paying an invocation to read cookies, headers, and geo per request. If no, it’s the outcome of an action or a per-page check: redirect() / permanentRedirect() from next/navigation.

Walk a few real rules through it.

Which home does this URL rule belong in?

This lesson’s one addition to last chapter’s spine: a presence check (does a cookie exist?) stays in config, but a validation check (is this session valid?) moves to the proxy.

Sort the rules below into their homes.

Each is a real URL rule. Ask the two questions in order — does it depend on who's asking? does it run before the route renders? — and drop it in its home. Drag each item into the bucket it belongs to, then press Check.

next.config.ts Request-blind, edge-applied
proxy.ts Request-conditional, before render
redirect() / permanentRedirect() After an action, or one page
Rebranded /account/settings for everyone, permanently
Serve /docs/* from an upstream docs site, same URL
Permanently move /pricing/plans after a launch
Bounce logged-out users off /dashboard to sign-in
Route the request into an A/B variant based on a bucket cookie
Send the user to the new invoice’s page right after creating it

Here is everything in one config you could ship at this stage: the always-on flags, the rebrand redirect, a cookie-gated redirect, the external docs rewrite, and the locked trailingSlash, in under thirty lines.

import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
typedRoutes: true,
trailingSlash: false,
async redirects() {
return [
{ source: '/account/:path*', destination: '/settings/:path*', permanent: true },
{
source: '/app/:path*',
missing: [{ type: 'cookie', key: 'session_token' }],
destination: '/welcome',
permanent: false,
},
];
},
async rewrites() {
return [
{ source: '/docs/:path*', destination: 'https://docs.acme-marketing.com/:path*' },
];
},
// Security headers (CSP, HSTS) go here once the hardening pass lands.
// async headers() { ... },
};
export default nextConfig;

The always-on flags, plus the locked URL shape. cacheComponents and typedRoutes are on for every project (lesson 1). trailingSlash: false is the one-time launch decision: request-independent and global, which is exactly why it lives here.

import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
typedRoutes: true,
trailingSlash: false,
async redirects() {
return [
{ source: '/account/:path*', destination: '/settings/:path*', permanent: true },
{
source: '/app/:path*',
missing: [{ type: 'cookie', key: 'session_token' }],
destination: '/welcome',
permanent: false,
},
];
},
async rewrites() {
return [
{ source: '/docs/:path*', destination: 'https://docs.acme-marketing.com/:path*' },
];
},
// Security headers (CSP, HSTS) go here once the hardening pass lands.
// async headers() { ... },
};
export default nextConfig;

The rebrand, a request-blind 308. /account/:path*/settings/:path*, permanent: true. Same for everyone, forever, so it lands in config and applies at the edge for free. The catch-all forwards the whole sub-tree.

import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
typedRoutes: true,
trailingSlash: false,
async redirects() {
return [
{ source: '/account/:path*', destination: '/settings/:path*', permanent: true },
{
source: '/app/:path*',
missing: [{ type: 'cookie', key: 'session_token' }],
destination: '/welcome',
permanent: false,
},
];
},
async rewrites() {
return [
{ source: '/docs/:path*', destination: 'https://docs.acme-marketing.com/:path*' },
];
},
// Security headers (CSP, HSTS) go here once the hardening pass lands.
// async headers() { ... },
};
export default nextConfig;

A presence-gated redirect. When /app/... is hit with no session_token cookie, bounce to /welcome with permanent: false, because it’s a temporary UX nudge, not a permanent move. missing only checks the cookie exists; the real session check still happens downstream.

import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
typedRoutes: true,
trailingSlash: false,
async redirects() {
return [
{ source: '/account/:path*', destination: '/settings/:path*', permanent: true },
{
source: '/app/:path*',
missing: [{ type: 'cookie', key: 'session_token' }],
destination: '/welcome',
permanent: false,
},
];
},
async rewrites() {
return [
{ source: '/docs/:path*', destination: 'https://docs.acme-marketing.com/:path*' },
];
},
// Security headers (CSP, HSTS) go here once the hardening pass lands.
// async headers() { ... },
};
export default nextConfig;

The external docs rewrite. /docs/:path* streams from the upstream marketing site while the URL stays on your domain. The matcher is scoped to /docs, never the bare root, so assets and API routes don’t get proxied along with it.

import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
typedRoutes: true,
trailingSlash: false,
async redirects() {
return [
{ source: '/account/:path*', destination: '/settings/:path*', permanent: true },
{
source: '/app/:path*',
missing: [{ type: 'cookie', key: 'session_token' }],
destination: '/welcome',
permanent: false,
},
];
},
async rewrites() {
return [
{ source: '/docs/:path*', destination: 'https://docs.acme-marketing.com/:path*' },
];
},
// Security headers (CSP, HSTS) go here once the hardening pass lands.
// async headers() { ... },
};
export default nextConfig;

A signpost, not code. The commented headers() marks where the security baseline (CSP, HSTS) lands in the hardening pass later in the course, carried as a comment so the file is honest about what it doesn’t do yet.

1 / 1

This is the file to carry out of here. The config owns the request-independent URL shape, applied at the edge for free; the proxy owns the request-dependent shape, paying an invocation to read the session, host, or bucket; redirect() owns the action-time shape, firing after application code finishes. One question routes between them: does this rule depend on who’s asking?