Next.js Rewrites: Three Phases, and the Four We Turned Down
318 pages and zero rewrites, with the compiled manifest to prove it. What each phase beats, why our seven hub pages stayed files, and the 308 redirect nobody configured.
A rewrite maps an incoming path to a different destination while the browser's URL stays put — a redirect changes the address bar, a rewrite does not. Next.js runs them in three ordered phases, and the compiled result is visible in .next/routes-manifest.json after any build. This storefront ships 324 pages and declares zero of them, which is the more useful thing to explain.
The API in one block
Rewrites live in next.config.ts and can return either an array or an object of three arrays:
const nextConfig: NextConfig = {
async rewrites() {
return {
beforeFiles: [{ source: "/docs/:path*", destination: "/help/:path*" }],
afterFiles: [{ source: "/legacy/:slug", destination: "/templates/:slug" }],
fallback: [{ source: "/:path*", destination: "https://old.example.com/:path*" }],
};
},
};
The three phases are the part worth memorising, because they decide what wins when two things could match the same URL:
| Phase | Runs | Beats your own pages? | Typical use |
|---|---|---|---|
beforeFiles | Before the filesystem is consulted | Yes | Shadowing a real route (A/B tests, proxying an analytics path) |
afterFiles | After pages and public files, before dynamic routes | No | Aliasing a URL that has no page of its own |
fallback | Only when nothing else matched — including dynamic routes | No | Incremental migration; proxying to a legacy host |
Returning a plain array is shorthand for afterFiles. That is the default most people get without choosing it, and it is why "my rewrite does nothing" is usually a phase problem rather than a syntax problem: an afterFiles rewrite whose source also matches a real page will never fire, because the real page already answered.
What a build actually compiles them to
You do not have to reason about this from documentation. Every next build writes .next/routes-manifest.json, and the rewrite section is in it verbatim. Here is this site's, from a build run for this post:
"rewrites": { "beforeFiles": [], "afterFiles": [], "fallback": [] }
Three empty phases. The shape is always present even when you declare nothing, which makes the manifest a reliable way to answer "is a rewrite involved in this URL?" on a codebase you did not write — including one you just bought.
The neighbouring key is more interesting:
"redirects": [{
"source": "/:path+/",
"destination": "/:path+",
"internal": true,
"statusCode": 308,
"regex": "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))/$"
}]
next.config.ts in this repo declares no redirects() either — the only thing in that array is Next's own trailing-slash normalisation, flagged "internal": true. Worth knowing before you go hunting for the config entry that produced a 308 you did not write.
The same file reports what the routing table is made of: 37 static routes, 3 dynamic routes, with 316 of the resulting URLs prerendered out of 324 pages generated. All of them are files on disk.
The four places we could have used a rewrite and did not
This is the part the API reference cannot tell you. Each of these is a real decision in this codebase.
1. The seven category hub pages. /nextjs-admin-template, /tailwind-shop-template and five siblings are root-level pSEO landing pages, each backed by a shared template and a data entry. A beforeFiles rewrite mapping /:framework-:category-template onto one dynamic route would have collapsed seven files into one rule. Instead each hub is a real four-line page:
// src/app/nextjs-admin-template/page.tsx
const content = requireFrameworkLandingPage("nextjs-admin-template");
export const metadata: Metadata = frameworkLandingMetadata(content);
export default function NextjsAdminTemplatePage() {
return <FrameworkLandingTemplate content={content} />;
}
requireFrameworkLandingPage throws at build time if the slug is not in the data file. That is the whole argument: a rewrite is a string match evaluated per request, so a typo in the pattern produces a 404 in production. A file that calls a require* helper produces a failed build. When the URL set is small, closed and known at build time — seven, here — the filesystem is a better index than a regex.
2. Flat product URLs. Products live at /templates/<slug>, with no category segment. The obvious rewrite would map /admin/<slug> and /landing/<slug> onto the same page, giving category-shaped URLs for SEO. We do not, because a rewrite keeps the URL the visitor typed while serving one page's HTML — which means two addresses serving identical content, and the canonical tag has to sort out which one Google keeps. Having watched 38 of these pages sit in Search Console's "Discovered — currently not indexed" for reasons documented in the migration post, inventing a second URL for every product to win a keyword was not a trade worth making. Category keywords belong to the hubs above, which are pages of their own with their own content.
3. A/B testing. The canonical Next.js recipe for split tests is a beforeFiles rewrite driven by a cookie, evaluated in the proxy. It works, and this site does not run it — for reasons that are entirely about cost rather than correctness, and which our A/B testing post covers in full: both variants stay prerendered, but the shared CDN cache and every request's critical path pay for the decision. That post owns the analysis; the relevant fact here is that it is the one rewrite we have seriously specified and declined.
4. An analytics reverse proxy. Proxying a third-party analytics endpoint through your own domain — /ingest/:path* rewritten to a vendor host — is one of the most common real-world rewrites, and shows up on the first page of results for this topic. It is unnecessary here: @vercel/analytics v2 posts its beacon to a first-party path on the site's own origin, which is why our Content-Security-Policy needs no extra connect-src host at all. That finding is documented in next.config.ts itself and in the CSP post. A rewrite whose purpose is to make a request first-party is dead weight when the request already is.
Rewrites, redirects and redirect()
Three things get confused with each other constantly, and this codebase uses exactly one of them:
| Rewrite | Config redirects() | redirect() in code | |
|---|---|---|---|
| Where it lives | next.config.ts | next.config.ts | A Server Component, Route Handler or Server Action |
| URL the visitor sees | Unchanged | Changes | Changes |
| Can it read auth state? | No | No | Yes |
| Evaluated | Per request, before rendering | Per request, before rendering | During render |
| Count in this repo | 0 | 0 (one internal) | 13 |
Those thirteen redirect() calls exist because every one of them depends on something a config file cannot see — whether the visitor is signed in, whether they own the product, whether a token is still valid. The redirects post covers that side. The rule that falls out of the table: config-level rules are for URL shape, in-code redirect() is for state. If a rewrite you are writing needs to know who is asking, it is in the wrong layer.
What the proxy does instead
Next 16 renamed middleware to the proxy, and src/proxy.ts is the file that would host a rewrite if we had one. It does not touch the URL:
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
// ... createServerClient wired to request/response cookies ...
await supabase.auth.getClaims();
return response;
}
NextResponse.next() — continue to the route that was asked for. The only routing decision in the file is its matcher, which excludes static assets and the crawler routes so a session refresh never runs for sitemap.xml. The proxy migration post covers what changed in the rename.
This matters for rewrites specifically because the proxy is the only place a dynamic rewrite can live — NextResponse.rewrite() can consult cookies and headers, which next.config.ts cannot. That capability is also its cost: everything the proxy does happens on every matched request, before anything is served.
Mistakes and how they show up
| Mistake | How it shows up | Fix |
|---|---|---|
| Returning an array and expecting it to shadow a real page | The rewrite silently never fires | An array is afterFiles; use beforeFiles to beat the filesystem |
| Using a rewrite to create a second URL for one page | Duplicate content; the wrong URL gets indexed | Pick one canonical URL, or make the second address a real page with its own content |
| Rewriting to an external host without checking CSP | Requests blocked in the browser, fine in curl | The destination host still needs a connect-src/img-src entry unless it is same-origin |
| Expecting a config rewrite to read a cookie | It cannot — next.config.ts is evaluated without a request | Use NextResponse.rewrite() in the proxy, and accept the per-request cost |
| Debugging a 308 you never configured | No matching entry in next.config.ts | Check .next/routes-manifest.json for "internal": true — trailing-slash normalisation is built in |
| Reaching for a rewrite when the URL set is small and known | A pattern typo becomes a production 404 | Real route files fail the build instead |
Frequently asked questions
Do rewrites make my pages dynamic? No. Rewriting in the proxy does not opt a page out of static rendering — both the source and destination stay prerendered. What it costs is the shared CDN cache and time on every request's critical path, which the A/B testing post measures.
Can I rewrite to an external URL?
Yes, and it is one of the few things only a rewrite can do — the destination may be an absolute URL on another host. This is the standard incremental-adoption pattern: fallback everything unmatched to the old site while you port routes one at a time.
How do I tell whether a site I bought uses rewrites?
Build it and read .next/routes-manifest.json. The rewrites object lists all three phases with their compiled regex, so you can see exactly which paths are being intercepted without reading the config.
Rewrite or redirect for a renamed URL? Redirect, essentially always. A rewrite leaves the old URL live and serving the new page's content, so links and rankings stay split across two addresses forever. A 308 consolidates them.
Templates in this post
ASoc Apex Admin is a large multi-purpose dashboard — five dashboards across 115+ pages, a full ecommerce back office, workspace apps and a deep component showcase. ASoc Clover Admin is CRM-focused, with sales, finance and team dashboards plus email, chat and calendar. ASoc Lura is the multi-vertical suite — 11 dashboards over roughly 177 routed pages, in React, Next.js, Vue and Angular editions.
Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates.
