Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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:

PhaseRunsBeats your own pages?Typical use
beforeFilesBefore the filesystem is consultedYesShadowing a real route (A/B tests, proxying an analytics path)
afterFilesAfter pages and public files, before dynamic routesNoAliasing a URL that has no page of its own
fallbackOnly when nothing else matched — including dynamic routesNoIncremental 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:

RewriteConfig redirects()redirect() in code
Where it livesnext.config.tsnext.config.tsA Server Component, Route Handler or Server Action
URL the visitor seesUnchangedChangesChanges
Can it read auth state?NoNoYes
EvaluatedPer request, before renderingPer request, before renderingDuring render
Count in this repo00 (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

MistakeHow it shows upFix
Returning an array and expecting it to shadow a real pageThe rewrite silently never firesAn array is afterFiles; use beforeFiles to beat the filesystem
Using a rewrite to create a second URL for one pageDuplicate content; the wrong URL gets indexedPick one canonical URL, or make the second address a real page with its own content
Rewriting to an external host without checking CSPRequests blocked in the browser, fine in curlThe destination host still needs a connect-src/img-src entry unless it is same-origin
Expecting a config rewrite to read a cookieIt cannot — next.config.ts is evaluated without a requestUse NextResponse.rewrite() in the proxy, and accept the per-request cost
Debugging a 308 you never configuredNo matching entry in next.config.tsCheck .next/routes-manifest.json for "internal": true — trailing-slash normalisation is built in
Reaching for a rewrite when the URL set is small and knownA pattern typo becomes a production 404Real 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.

Keep reading

Tutorial9 min read

Next.js Route Groups: Why This 24-Route App Uses Zero

Route groups hide a folder from the URL. Every one of this app's 24 top-level folders needs to be in the URL — which is the exact case they're not for.

Read more
Tutorial12 min read

Next.js Routing: 27 Page Files, 412 URLs, Three Conventions Unused

A census of one production app/ tree: which of the nine reserved filenames actually earn a file, and the 40 links, 13 redirects and 2 routers that move between them.

Read more
Tutorial10 min read

An RSS Feed in the App Router, Without an RSS Library

One route file, force-static, and eight lines of helpers. RFC-822 dates, five escaped characters, and the absolute-URL bug that reached our production markup.

Read more