Redirects vs Rewrites: Who Sees the New URL?
next.config.ts (or .js / .mjs) is where request shaping happens before your code runs. The two functions people confuse are redirects() and rewrites(), and the difference is one question: does the browser learn about the change?
// next.config.ts
export default {
async redirects() {
return [
{ source: '/old-blog', destination: '/new-blog', permanent: true }, // 308
{ source: '/promo/:path*', destination: '/sale/:path*', permanent: false }, // 307
]
},
async rewrites() {
return [
// browser keeps calling /api/stripe, server proxies upstream
{ source: '/api/stripe/:path*', destination: 'https://api.stripe.com/:path*' },
]
},
}
A redirect returns 307 (temporary) or 308 (permanent) and the address bar updates — use it when a page moved and you want crawlers and bookmarks to follow. A rewrite returns the content of another path with a 200 and the URL the user typed stays untouched — use it for proxying, for hiding internal paths, and for serving an internal page under an external-looking URL.
Rewrites carry a second dimension most candidates miss: when they run, expressed as three arrays instead of one list.
| Phase | Runs | Use it for |
|---|---|---|
beforeFiles |
before the filesystem is inspected (public assets, static, pages) | rare, e.g. geo-based remapping that must beat a real file |
afterFiles |
after real files, before dynamic routes | proxying a concrete path that does not exist as a file |
fallback |
after dynamic routes, before 404 | catch-alls such as CMS-driven pages |
async rewrites() {
return {
beforeFiles: [{ source: '/sitemap.xml', destination: 'https://cms.example.com/sitemap' }],
afterFiles: [{ source: '/api/:path*', destination: 'https://backend.internal/:path*' }],
fallback: [{ source: '/:slug', destination: '/cms-page?slug=:slug' }],
}
}
The order matters more than the syntax: headers, then redirects, then the three rewrite phases interleaved with filesystem and dynamic-route matching. Put a fallback rule where afterFiles belongs and it will shadow real pages.
All three functions accept conditions, which is how you make a redirect behave differently per user without writing middleware. has matches a header, cookie, or query parameter; missing is its negation — for example sending logged-out traffic to /login while leaving an authenticated request alone:
{ source: '/dashboard/:path*', has: [{ type: 'cookie', key: 'session', value: '(?<value>.*)' }],
destination: '/login?next=/:path*', permanent: false }
Named capture groups in value or in source (:path* style) are reusable in destination, so one rule can preserve the original URL. Redirect objects also honor basePath: false and locale: false when you need to opt out of those prefixes.
headers() completes the trio and is where configuration earns its keep — security and caching headers per path:
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Cache-Control', value: 'no-store' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
],
},
]
}
Two real-world failure modes. First, the redirect loop: a rule whose source also matches its destination (/ → /) makes the browser die with ERR_TOO_MANY_REDIRECTS and your site looks down to everyone but you, because your dev session still has the cached redirect. Second, the invisible proxy: rewrites happen on your server, so an upstream timeout or DNS failure surfaces as a blank page rather than a browser network error — check the server logs before blaming the frontend.
Config cannot decide per-request logic (auth from a cookie, a redirect based on a database row); that belongs in Middleware or Route Handlers, covered by App Router Fundamentals. And all three functions share one operational property that trips up every newcomer: the file is read once when the dev server starts, so a change needs a full next dev restart to take effect — editing redirects() while the server runs does nothing.
Whitelisting Image Hosts so next/image Stops Throwing
Point next/image at an image your config does not know about and you get one of the most recognizable errors in the framework:
Invalid src prop (https://cdn.shop.example.com/shoe.png) on `next/image`,
hostname "cdn.shop.example.com" is not configured under images in your
next.config.js. See https://nextjs.org/docs/messages/invalid-image-src
The check exists because the image optimizer is, by design, a fetch-and-resize service running on your infrastructure. Without an allowlist, anyone could make your server download arbitrary URLs — an SSRF and bandwidth-abuse vector. The whitelist is not bureaucracy; it is the security boundary of /next/image.
export default {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.shop.example.com',
pathname: '/products/**',
},
{ hostname: '**.supabase.co' }, // any subdomain, any path
],
},
}
Two ways to express an allowlist, and only one is still recommended:
images.domains (legacy) |
images.remotePatterns (preferred) |
|
|---|---|---|
| Value shape | plain hostname strings | objects: protocol, hostname, port, pathname |
| Wildcards | none | ** on hostname, /** on path |
| Port / protocol rules | no | yes (http vs https enforced) |
| Path scoping | no | e.g. only /products/** |
remotePatterns wins because a whole-domain allowlist is a wider hole than you need: cdn.example.com admits the user-upload path on the same host, while pathname: '/products/**' does not. Keep each pattern as narrow as the images it must serve.
Local images are exempt — import hero from './hero.png' is bundled by webpack/Turbopack, never fetched, so no config is needed. Remote images always route through the optimizer, which is also why config changes require a restart: the allowlist is validated when the server boots, and a running next dev keeps the old one until you restart it.
What the optimizer does after it is allowed to fetch is worth one mental model, because it explains the performance numbers you see in an audit. Next generates a srcset from deviceSizes/imageSizes, so the browser downloads the smallest width that fits its viewport instead of the original; the server then re-encodes to WebP (or AVIF) and caches the result — .next/cache/images in development, an in-memory/CDN cache in production. The first request for a given size is therefore slow (fetch + encode, often hundreds of milliseconds) and every later one is served from cache. If cold loads dominate your metrics, warm the popular sizes at build time or move the transforms to a CDN loader.
Beyond allowlisting, the images block tunes the optimizer itself:
images: {
formats: ['image/avif', 'image/webp'], // avif first: smaller files, slower encode
deviceSizes: [640, 828, 1080, 1280, 1920], // widths generated for large viewports
imageSizes: [16, 32, 48, 64], // widths for inline/fixed images
qualities: [60, 75], // allowed `quality` values (newer 15.x)
}
The trade-off is quantifiable: AVIF typically shaves a further 15–30% off WebP bytes but costs meaningfully more CPU per encode, which is fine on a CDN and painful on a single small server — if your optimizer runs on one box, formats: ['image/webp'] (the default) is the better answer. qualities narrows what callers may request, so a public page cannot ask your server to do a 100-quality re-encode of a 4 MB photo.
When the optimizer is the wrong tool entirely, you have two exits: a custom loader that rewrites URLs to your image CDN (next-cloudinary, S3 signed derivatives), in which case remotePatterns no longer applies, or unoptimized for tests and screenshots. For interviews, lead with the why: the domain config is an SSRF allowlist in front of a fetch service, and remotePatterns lets you scope it by protocol, host, port, and path instead of trusting an entire domain.
output, Lint, and Type Flags: What next build Produces
Everything so far shaped requests. The last group of options changes what next build produces, and each one is a trade you should be able to defend in a review.
| Option | Default | Set it when… | The cost |
|---|---|---|---|
output: 'standalone' |
– | shipping a Docker/K8s image | you must copy static assets yourself |
output: 'export' |
– | hosting is a plain static file server | no server code, no runtime APIs |
typescript.ignoreBuildErrors |
false |
unblocking a migration briefly | type errors ship silently |
eslint.ignoreDuringBuilds |
false |
legacy codebase floods the build | lint regressions ship silently |
poweredByHeader |
true |
always, in production | none — hides X-Powered-By |
compress |
true |
– | disable only if the CDN already compresses |
trailingSlash |
false |
matching an existing URL convention | changing later is an SEO migration |
basePath |
'' |
app served at example.com/docs |
every link and asset is prefixed |
generateBuildId |
random per build | rolling deploys across replicas | – |
output: 'standalone' is the one with a trap. It emits .next/standalone/server.js plus only the traced node_modules — but .next/static and public/ are deliberately left out, so a Dockerfile that only copies the standalone folder serves a site where every JS chunk and image 404s:
FROM node:20-alpine
WORKDIR /app
COPY .next/standalone ./
COPY .next/static .next/static
COPY public public
EXPOSE 3000
CMD ["node", "server.js"]
output: 'export' turns the build into plain HTML/CSS/JS. Anything requiring a server at request time is rejected at build time, and a dynamic segment without a params generator is explicit about it:
Page "/blog/[slug]" is missing "generateStaticParams()" so it cannot be
used with "output: export"
You either enumerate every route with generateStaticParams() or you drop the export and run a server — there is no third option, which is why export belongs to brochure sites and not to anything with per-user data.
The ignore flags deserve suspicion. eslint: { ignoreDuringBuilds: true } and typescript: { ignoreBuildErrors: true } each convert a hard gate into a suggestion. They are legitimate for a codebase with hundreds of pre-existing violations that you plan to burn down — as a temporary state with a ticket attached. Permanent use means your CI is green while the type checker quietly stops protecting anyone, and the next engineer assumes it still does.
Smaller settings with real effects: poweredByHeader: false stops advertising the framework in every response; compress: false is correct only when a CDN in front already compresses (double compression wastes CPU for no bytes saved); and generateBuildId pinned to your git SHA keeps replicas in a rolling deploy serving matching asset versions instead of each one fetching chunks from whichever pod answers.
Two operational facts apply to the whole file. The config runs in Node before the build, so reading process.env inside it works and is a legitimate place to compute values — the loading rules are in Environment Variables. And because it is read at process start, every change needs a full restart of next dev; a config edit that appears to do nothing is almost always a missing restart. Use next.config.ts when the project is TypeScript, keep export default (or .mjs) when package.json declares "type": "module" — using module.exports there throws ReferenceError: module is not defined in ES module scope. Finally, experimental.* flags are version-pinned features: they can change or vanish between minor releases, so treat adding one as a reason to pin next exactly.
Quiz
1. A user requests /pricing, the server answers 308, and the browser address bar changes to /plans/pricing. Which config produced this?
2. next/image throws: `hostname "cdn.shop.example.com" is not configured under images in your next.config.js`. Why does that check exist?
3. With `output: 'standalone'`, your Dockerfile copies only `.next/standalone`. What breaks at runtime?
4. You edit `redirects()` in next.config.ts while `next dev` is running and the old behavior persists. What is happening?
Flashcards
Question
Redirect or rewrite: which one changes the address bar, and what status codes do they send?
Click to reveal answer
Answer
A redirect changes it: 308 when permanent is true, 307 when false. A rewrite serves the target content with a 200 while the original URL stays in the address bar — the tool for proxies and hidden internal paths.
Question
What are the three rewrite phases and their order?
Click to reveal answer
Answer
beforeFiles (before the filesystem is checked), afterFiles (after real files, before dynamic routes — where API proxies belong), then fallback (after dynamic routes, before 404 — catch-alls only). Placing a catch-all too early shadows real pages.
Question
Why prefer images.remotePatterns over images.domains?
Click to reveal answer
Answer
domains accepts plain hostnames with no wildcards, ports, protocols, or path rules, so the whole domain is trusted. remotePatterns matches on protocol, hostname (with **), port, and pathname (with /**), letting you allow only /products/** on a host that also serves user uploads.
Question
What does output: 'standalone' give you, and what does it leave out?
Click to reveal answer
Answer
A self-contained .next/standalone/server.js with the traced node_modules, runnable with plain `node server.js`. It omits .next/static and public/, so a Dockerfile must copy them explicitly or every chunk and image 404s at runtime.
Question
When is a next.config change picked up, and which flags weaken the CI gate?
Click to reveal answer
Answer
Only at process start — restart next dev after every edit. eslint.ignoreDuringBuilds and typescript.ignoreBuildErrors turn lint and type checking into suggestions; they are defensible as temporary states on a legacy codebase and dangerous as permanent config.
Revision Notes
Key Takeaways
- 1. redirects (307/308) update the address bar, rewrites (200) do not — choose by whether users and crawlers should see the change
- 2. Rewrites run in three phases around the filesystem and dynamic routes; API proxies belong in afterFiles, catch-alls in fallback
- 3. remotePatterns is an SSRF allowlist in front of a fetch-and-resize service, and it scopes trust by protocol, host, port, and path
- 4. output: 'standalone' omits .next/static and public — the classic Docker bug where HTML loads but every asset 404s
- 5. The config file is read once at process start, so every edit needs a full restart, and experimental.* flags can vanish between minor releases
Interview Tips
- • "Redirect or rewrite?" — answer with the observable difference (address bar, 307/308 vs 200), then give a use case for each: moved pages for SEO vs proxying an internal API without exposing the upstream
- • "Why does next/image need a domain list?" — because the optimizer fetches URLs server-side, so an unscoped list is an open proxy; mention remotePatterns path scoping as the hardening step
- • "How would you containerize a standalone build?" — copy .next/standalone, then .next/static into .next/static and public/, then `node server.js`; naming the two omitted directories shows you have actually shipped one
- • "Would you turn off linting in the build?" — as a temporary, ticketed concession on a legacy codebase with a burn-down plan; permanently the CI gate stops meaning anything while staying green
Cheat Sheet
next.config Options
Redirect vs rewrite — who sees it?
| Status | Browser URL | Use for | |
|---|---|---|---|
redirects() |
308 permanent / 307 temporary | updates | moved pages, SEO, short links |
rewrites() |
200 | unchanged | proxies, hidden internal paths, CMS fallback |
Rewrite phases (checked in this order): beforeFiles → filesystem → afterFiles → dynamic routes → fallback (before 404). Proxy /api/:path* in afterFiles; use fallback only for catch-alls that must not shadow real pages.
export default {
async rewrites() { return { beforeFiles: [], afterFiles: [], fallback: [] } },
async redirects() { return [{ source: '/old', destination: '/new', permanent: true }] },
async headers() { return [{ source: '/api/:path*', headers: [{ key: 'Cache-Control', value: 'no-store' }] }] },
}
Images allowlist (SSRF boundary for the optimizer):
images: {
remotePatterns: [{ protocol: 'https', hostname: 'cdn.shop.example.com', pathname: '/products/**' }],
formats: ['image/webp'], // add 'image/avif' first only if CPU budget allows
}
- Unlisted host →
Invalid src prop … hostname "…" is not configured under images. images.domains= legacy, hostname-only;remotePatterns= protocol/host/port/path wildcards.- Local
import img from './x.png'never needs config.
Build-shaping flags:
| Flag | Effect / trap |
|---|---|
output: 'standalone' |
minimal server — you must copy .next/static + public/ into the image |
output: 'export' |
static HTML; dynamic segments need generateStaticParams() or the build fails |
typescript.ignoreBuildErrors / eslint.ignoreDuringBuilds |
turn the CI gate into a suggestion — temporary only |
poweredByHeader: false |
removes X-Powered-By |
generateBuildId |
pin to git SHA so rolling replicas serve matching assets |
basePath / trailingSlash |
URL-shape decisions; changing either later is an SEO migration |
Operational rules:
- Config is read once at process start → restart
next devafter every edit. "type": "module"in package.json → useexport default(.mjs/.ts);module.exportsthrowsReferenceError: module is not defined in ES module scope.experimental.*flags can disappear between minors → pin thenextversion when you use one.- Redirect loop →
ERR_TOO_MANY_REDIRECTS; checksourcevsdestinationoverlap first.