Skip to content
Chapter 34Lesson 3

Edge redirects and rewrites

Next.js 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: same screens, new address, and the change is permanent. The old URLs don’t vanish on rename day, though. They’re in months of emails, in bookmarks, in whatever Google indexed last quarter. Every one of them needs to land the visitor on the new path, for everyone, forever.

You already know how to write this. In the last chapter you built proxy.ts and sent exactly this kind of redirect from it: read the path, return a 308 to the new one. So a fair question is why a redirect you can already write deserves a whole new lesson.

The reason is that this redirect is always true. It doesn’t read the cookie, the header, the session, or anything about who’s asking: /account goes to /settings no matter what. The moment you put an always-true rule in proxy.ts, you’ve signed the platform up to run a function on every matched request just to send back the same redirect it could have served from the CDN edge for free. The proxy is the right home for rules that depend on the request, and this rule 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. By the end you’ll know its pattern syntax, the one question that decides which of the three homes a rule belongs in, and two mistakes that are costly to make: one loops your redirect forever, and one you can’t take back after launch.

This lesson reuses concepts from earlier chapters rather than re-teaching them, so here is a quick recap of where they came from. You learned redirect-versus-rewrite and the 307-versus-308 distinction in the previous chapter, building rules in proxy.ts, and you met redirect() and permanentRedirect() from next/navigation back in the routing chapter. What’s new here is the config home itself, and the cost reason it’s the default when a rule reads nothing about the request.

Let’s start with the cost, because the cost is the entire reason this home exists.

A redirect in next.config.ts is an async function named redirects that returns an array of rules. Each rule is 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* part, where /account/:path* matches the whole sub-tree under /account, is pattern syntax we’ll pull apart in the next section. For now, focus on the headline: this rule is applied at the CDN edge, with zero function invocation. No proxy.ts runs, and no Server Component renders. Next reads this rule table once at build time and hands it to the edge. From then on, the edge answers /account/... with a redirect to /settings/... before any of your code runs. The redirect is served from the same layer that serves your cached assets, about as fast and as cheap as the web gets.

Compare that to the proxy. In the last chapter you learned the proxy’s cost model: every request the matcher selects pays a function round trip. That’s the right price for a rule that has to read the request. But for a rule that reads nothing and sends the same redirect to everyone, paying a function invocation is pure waste, since you’re spending compute to produce a constant.

Here are the two homes, with the exact 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 on every matched request. The proxy is code, so the platform has to run it on each matched request just to find out it returned the same 308 again. That’s the right home when a rule reads the request, and pure overhead when it doesn’t.

That permanent field is the 308-versus-307 distinction you derived last chapter, 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, which is temporary, so the old URL stays canonical. Both preserve the request method, which is why Next uses this pair and never 301/302. The rebrand is a genuine permanent move, so true is the right choice.

One constraint shapes everything else in this lesson. These config rules are global and request-blind by default: the same for every visitor, with no per-route override and no view into the session. That’s both the feature and the limit. It’s the feature because reading nothing about the request is exactly what lets the edge cache and apply the rule without running your code. It’s the limit because a rule that genuinely needs to know who’s asking can’t live here, and has to move to the proxy. Keep that line in mind, because the whole lesson hangs on it.

The source field is a pattern, not a literal path, and learning to read it is the one genuinely new mechanical skill in this lesson. It’s worth a few minutes, because two patterns that look almost identical can behave very differently: one forwards your whole rebranded section, while the other forwards only the top level and leaves every nested URL behind.

Next compiles source with a library called path-to-regexp , the same :param and * grammar Express routes use. Let’s build it up one step at a time, simplest first.

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. The simplest rule is one literal path in, one literal path out, with no capture and no nesting.

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 path segment. /account/:slug matches /account/billing but not /account/billing/history, because a colon param is one segment, never a sub-tree. The captured :slug 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. Add * and :path* captures zero-or-more segments: /account, /account/billing, and /account/billing/history all match. This is the rebrand workhorse, forwarding the entire sub-tree in one rule. (+ means one-or-more, ? means 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. Note the double backslash: \\d in the TypeScript string is the single \d the matcher sees. Use this to keep a rule from firing on the wrong shape of input.

1 / 1

Two things are worth pinning down from that walkthrough. First, the destination references captures by name. Whatever :slug or :path* captured in source gets substituted back into destination, which is how /account/:path*/settings/:path* carries the entire sub-tree across. Query strings come along automatically too: a request to /account/billing?ref=email lands on /settings/billing?ref=email without you touching the query. Second, the single-segment versus catch-all boundary is the one to watch. :slug stops at the first slash, while :path* keeps going. Pick :slug for the rebrand and every nested URL under /account quietly fails to redirect, and the gap usually surfaces only when a customer reports a dead bookmark.

There’s one mistake here that takes the whole route down, so it’s worth a close look. The forward slash must come before the colon. Write '/account/:path*', never 'account/:path*'. It looks like a typo too small to matter, but it isn’t.

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

This redirects in an infinite loop. The leading / is what anchors a pattern to the start of the path. Drop it and the pattern is no longer pinned to the root, so it keeps matching the path it just redirected to. The browser arrives at the destination, the unanchored rule matches again, and it bounces back and forth until it gives up with a “too many redirects” error. The Next.js docs name this missing slash as the most common cause of redirect loops.

Reading patterns is a skill that only sticks with practice, so put a few concrete paths through the two rules that catch people. The drill below gives you a handful of incoming paths and asks which source matches each one, the same judgment you’ll make every time you write a rule.

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

So far a config rule has been fully request-blind. But there’s a narrow band where a rule can read part of the request without leaving the edge, and knowing exactly where that band ends is worth getting right.

Every rule can carry a has array, a missing array, or both. Each is a list of conditions on the request: a cookie, a header, the host, or a query parameter.

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

Read that rule as one sentence: when someone hits /app/... and there’s no session_token cookie, send them to /welcome instead. For the rule to fire, every entry in has must match and every entry in missing must not match. The value field is optional. Omit it, as above, to match on the presence of the cookie at all, or supply a string to require a specific value. That string is regex-capable, and named groups can flow into destination. Beyond cookies, type: 'host' is the common one, routing an apex domain and a www subdomain to different destinations, since the host is something the edge can read directly.

Here is the line that matters. has and missing test presence and literal value at the edge. They do not decode, verify, or trust. A has: cookie check can tell you a session_token cookie exists, but it cannot tell you the session is valid, who the user is, or what they’re allowed to see. It’s a coarser version of the cheap presence check you do in the proxy. So the rule above, gating the marketing page on a missing cookie, is fine: it’s a UX nudge (“you look logged out, here’s the welcome page”), and if it’s wrong, the worst case is that the real app re-checks and corrects course. But anything that validates a session, reads the current user, or makes a genuine authorization decision still belongs in proxy.ts for the cheap presence check, and then in the route for the authoritative one.

Rewrites: changing the implementation, not the URL

Section titled “Rewrites: changing the implementation, not the URL”

Everything so far has been a redirect, where the address bar changes. A rewrite is the other operation you met last chapter: same source/destination shape, but the URL the visitor sees stays exactly where it is while the server quietly renders something else behind it. A redirect is visible and costs two round trips, while a rewrite is invisible and costs one. You already know that distinction, and here it’s the same idea expressed in config.

The shape is almost identical to redirects(), with one field removed:

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

There’s no permanent field, because a rewrite isn’t an HTTP redirect status at all. It’s an internal swap, so permanence doesn’t apply. The other difference from redirects() is the return shape. Returning a flat array, as above, is the common case, and those rules run after Next has tried to match a real route. For the rare time you need rewrites to run before route matching, or to fall through only when nothing else matched, rewrites() can instead return an object with beforeFiles, afterFiles, and fallback stages that order your rules against the filesystem. Most app teams never need it, so default to the flat array and reach for the object form only when ordering against real routes becomes the problem. External proxies typically live in its fallback stage.

The canonical use is serving content that lives somewhere else under a URL on your own domain, such as a marketing CMS, a docs site, or a help center, so the visitor stays on app.example.com/docs/... while the bytes come from an upstream origin. That brings us to the one risk rewrites carry that redirects don’t.

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

Cheap, because the destination is a route on your own app. Next just renders /cms/blog/... while the URL stays /blog/.... No external hop, no proxy cost. It’s the same invisible swap you’d do for multi-tenancy, applied to internal routes.

When the destination is an external URL like https://marketing.example.com/:path*, you’ve built a reverse proxy . Every matched request now flows through your deployment to the upstream and back. This is no longer free at the edge: it spends real compute and bandwidth on every hit. That’s fine when the matcher is scoped to the exact prefix you mean to proxy, and a serious problem when it isn’t.

One last note on precedence, so you don’t define the same rewrite twice. If both a proxy.ts rewrite and a config rewrite match the same request, the proxy wins, because it runs first. Pick one home per rule rather than splitting it across both files. A request-blind rewrite, such as serving the docs site, belongs in config, while a request-dependent one, such as the subdomain-to-org rewrite from last chapter, belongs in the proxy.

trailingSlash: a launch-time decision you can’t take back

Section titled “trailingSlash: a launch-time decision you can’t take back”

There’s one more config flag in this family, and it’s unusual because its cost isn’t compute, it’s permanence. trailingSlash decides one thing for your entire app: whether the canonical URL form has a trailing slash or not.

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

The default, false, serves /about and treats it as canonical. Flip it to true and the app serves /about/ instead, redirecting /about to /about/. That’s the whole behavior: it picks one of two URL shapes for every route in the app.

Here’s why it’s worth a careful look rather than a passing mention. That choice reaches into every URL your app touches: every internal link the app generates, every external backlink someone else points at you, every URL search engines have indexed, every link a customer has shared in Slack. Once you pick a form, the entire web settles around it. Flip it after launch and you issue a redirect on every previously-canonical URL. Every backlink and bookmark now pays a redirect hop, forever, and search engines re-crawl the whole site to learn the new shape. It’s not a crash, but it’s a lasting cost on links you don’t control, and there’s no clean way to take it back.

So the rule is simple: pick one form at the start of the project and never touch it. The default, false, is the right pick for almost every SaaS. It gives cleaner URLs, and since it’s the framework default, it’s what every tool and every link assumes anyway. Name it once in the config, lock it, and move on. Notice why this lives in config and not the proxy: it’s request-independent and global, the same rule for every visitor on every route, which is exactly the kind of rule this home is for.

You now have all three homes in view, so it’s time to assemble the decision that routes any URL rule to the right place. Last chapter you sketched this same tree, but its config branch pointed at a home you hadn’t built yet. Now you have, so here the tree is complete, including the one subtlety the edge adds: a has/missing presence check that a static rule can make without leaving the edge.

It comes down to two questions, asked in a fixed order. The order matters: ask them in this sequence and each rule sorts itself into one home.

The first question is about the request: does this rule depend on who’s asking (the session, the geography, an A/B bucket, the host beyond simple matching)? If no, because it’s the same for everyone, it belongs in next.config.ts, applied at the edge with zero invocation. That covers the rebrand, the legacy URL migration, the marketing rename, the docs rewrite. If yes, you move to the second question.

The second question is about timing: does it need to happen before the route renders, conditionally, on every request (an auth gate, subdomain tenancy, A/B bucketing)? If yes, it’s a proxy.ts rule: it runs per request, reads cookies and headers and geo, and pays its invocation for the privilege. If no, because it’s the outcome of an action or a per-page check, it’s redirect() / permanentRedirect() from next/navigation: after a Server Action finishes, or inside a single page that looks something up and bounces.

Walk a few real rules through it. Each leaf names the home and the one reason it lives there.

Which home does this URL rule belong in?

The sentence to keep is the one you met last chapter, now with its first branch filled in: static-and-known goes in the config, request-conditional goes in the proxy, and after-an-action goes in redirect(). The one subtlety this lesson adds is that has/missing blur the first question slightly. A presence check (does a cookie exist?) can stay in config, but a validation check (is this session actually valid?) has to move to the proxy. Presence is request-blind enough for the edge, and validation is not.

Sort the rules below into their homes. This is the judgment you’ll make every time a new URL rule comes up, so it’s worth getting fluent here.

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

Let’s consolidate everything into one config you could actually ship at this stage. It carries the always-on flags from the start of this chapter, the rebrand redirect, a cookie-gated redirect, the external docs rewrite, and the locked trailingSlash, all in under thirty lines. Step through it, and notice how each block ties back to the home decision.

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

That’s the file to carry out of here. Read top to bottom, it’s the chapter-wide story in one place: the rules the platform applies before any of your code runs.

Here is the synthesis the whole lesson was building toward. The config file owns your app’s request-independent URL shape: the rules that are the same for every visitor, applied at the edge for free. The proxy owns the request-dependent shape: the rules that read the session, the host, or the bucket, and pay an invocation to do it. redirect() owns the action-time shape: the rules that fire after application code finishes its work. Three homes, one question to route between them: does this rule depend on who’s asking? Ask it first, and the rest follows.