Skip to content
Shenzhen · The Greater Bay Area · Earth

Dynamic Routing on a Static Site: Rewrite the Short URL Internally, 308 the Duplicate

Static output and short URLs are not in conflict, but only if the mechanism between them is a rewrite. This is the URL contract I run on a statically prerendered Next.js site: bare paths are rewritten onto a single [locale] route tree and served from the prerender cache at 200, while /en/... and trailing-slash forms 308 to the canonical path in one hop.

8 min read1,839 words
Next.js ArchitectureSystemsNot yet translated.

What has to be true before a static build can answer a short URL?

Dynamic routing on a statically generated site works because two decisions happen in different places: the route tree is fixed at build time, and the public URL is decided per request, before the filesystem is consulted. On the site I run, the build behind this page prerendered 89 static documents, 67 of them article routes (64 English, 3 Chinese), all from one app/[locale] tree — a count that changes with every article and has no bearing on the routing question. The public URL space is a separate layer on top of that tree: /about is the URL people share, /en/about is the internal path that produced the HTML, and only the first one is ever canonical.

The claim I would defend in review is narrower than "use rewrites". The rewrite has to stay internal, and every other spelling of the same URL has to leave with a 308 in one hop. Get one of those wrong and you have built a second live URL for one document, which is a duplicate-content problem wearing the costume of a routing feature.

An internal rewrite is what lets a statically prerendered site serve short public URLs from a single route tree: the rewrite must resolve at 200 without the client ever seeing the internal path, and every non-canonical form of the URL must 308 to the canonical one.

I checked the served bytes rather than assuming them. curl -s http://127.0.0.1:3411/about | shasum -a 256 matches shasum -a 256 .next/server/app/en/about.html exactly. The rewrite is not re-rendering anything at request time; it is handing back the file the build wrote, under a shorter name.

Which mechanism is doing the routing, and at what point?

Next.js 16 runs proxy.ts (the renamed middleware.ts) before filesystem routes, which is the part that makes this work on static output. The proxy sees the request first, rewrites the path, and the prerendered entry for the rewritten path is then served as a normal static response. Two operations live in that file, and the order between them matters:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

/** Locales that own a real public prefix. `en` is bare, so it is absent here. */
const PREFIXED_LOCALE = /^\/zh(?=\/|$)/;

export function proxy(request: NextRequest): NextResponse {
  const { pathname, search } = request.nextUrl;

  // 1. `/en/*` is never canonical: this is a redirect, not a rewrite.
  //    308 keeps the method and body and tells caches the move is permanent.
  if (pathname === '/en' || pathname.startsWith('/en/')) {
    const target = new URL(pathname.slice(3) || '/', request.url);
    target.search = search;
    return NextResponse.redirect(target, 308);
  }

  // 2. `/zh/*` is a real URL that already matches the route tree.
  if (PREFIXED_LOCALE.test(pathname)) return NextResponse.next();

  // 3. Bare path -> internal `[locale]` segment, invisibly, still a 200.
  const rewritten = request.nextUrl.clone();
  rewritten.pathname = pathname === '/' ? '/en' : `/en${pathname}`;
  return NextResponse.rewrite(rewritten);
}

export const config = {
  matcher: ['/((?!_next/|api/|.*\\..*|robots\\.txt|sitemap|llms.*\\.txt|rss\\.xml|atom\\.xml).*)'],
};

Two details in there are load-bearing. The /en branch is first and returns early, so the rewrite can never re-create the URL it just removed; the rewritten target for the root is /en, which is a route that exists, so there is no second hop. And the rewrite goes through NextResponse.rewrite rather than a hand-rolled fetch on the internal path, because the framework re-propagates the React Server Component rewrite headers for you, and a manual fetch drops them.

Note what is absent: no geo-IP, no cookie sniffing, no Accept-Language negotiation. A crawler and a browser requesting the same URL must receive the same bytes, or the URL stops being a stable identifier. Language choice on this site belongs to the /zh prefix, which is part of the URL, not to a request header.

What separates a rewrite from a redirect in the crawl record?

The status code, and everything downstream of it: which URL the index holds, whether link equity consolidates, and whether the crawler ever sees the internal path.

MechanismStatus the client seesDocuments at that URLWhere it runs
Internal rewrite200OneProxy, before filesystem routes
Permanent redirect in the proxy308 plus LocationOne, after consolidationProxy, first branch
redirects() in next.config308 (or 301 if you ask)One, after consolidationBefore the proxy
Client-side router.replace()200, then a second navigationTwo until JavaScript runsBrowser
Serving both slugs from a catch-all page200 twiceTwo, competingRoute tree
output: 'export' plus a host-level ruleDepends on the hostVaries, often twoHost configuration

The row that costs money is the catch-all page. It is the version people reach for when the framework's rewrite does not exist on their host: a page component that accepts either the short or the prefixed slug and normalises it in the UI. The HTML is well-formed, the page works, and the crawler now has two 200s holding the same text. I have watched that shape survive several quarters because nothing in the application layer reports it; the only signal is what the index does with the duplicate pair, and what a second live URL costs a document in a language cluster is the part of that cost that surfaces a quarter later.

A redirect is the right tool when the two URLs are genuinely the same resource, which is the case here. It is the wrong tool for the initial canonical form, because a redirect means the short URL you advertised does not resolve on its own.

Where does the canonical URL come from when the route tree does not contain it?

Here the route tree and the public URL space have to be reconciled in code, and the place that happens is the metadata function plus one helper. The page renders under /[locale]/blog/[slug], so its params describe /en/blog/..., while the canonical must describe /blog/.... Deriving the canonical from the route would publish the internal path; hardcoding both would let them drift.

import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { isLocale, localeHref } from '@/lib/i18n';
import { absoluteUrl } from '@/lib/site';
import { getPost, canonicalSlug } from '@/lib/mdx';

type PostPageProps = { params: Promise<{ locale: string; slug: string }> };

export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
  const { locale, slug } = await params;
  if (!isLocale(locale)) notFound();

  const post = getPost(slug, locale);
  if (!post) {
    return { title: 'Not found', robots: { index: false, follow: false } };
  }

  // `localeHref` maps ('en', '/blog/x') -> '/blog/x' and ('zh', '/blog/x')
  // -> '/zh/blog/x'. The canonical is therefore a function of the slug, not of
  // the segment the page happened to render under.
  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: absoluteUrl(localeHref('en', `/blog/${canonicalSlug(post)}`)) },
  };
}

The served HTML confirms the join: <link rel="canonical" href="https://willchan.me/about"/> comes back from a request to /about, with no /en anywhere in it. The same helper feeds the sitemap and the structured data, so the three surfaces cannot disagree about the public path, because only one function decides it.

The other half of the reconciliation is what happens to paths that do not exist. generateStaticParams enumerates locale and slug pairs and dynamicParams = false closes the set, so /blog/does-not-exist is a 404 (21,211 bytes of not-found page on this build) rather than an on-demand render. Without that, a rewrite can quietly convert a typo into a runtime render and a soft duplicate.

What does the rewrite cost, measured?

A rewrite is not free, but its cost is one proxy invocation, not a render. I measured the contract on a local next start rather than reasoning about it, because the interesting failure is in the hop count, not the latency.

RequestStatusBytesHops to a 200
/about20095,4980
/about/308 to /about61
/en/about308 to /about61
/en/about/308 to /about/, then 308 to /about62
/zh/about20090,9060
/blog/does-not-exist40421,2110

The bytes are identical between the rewritten path and its direct equivalent, and the response headers say why: x-middleware-rewrite: /en/about sits alongside x-nextjs-prerender: 1 and x-nextjs-cache: HIT. The rewritten request is a prerender cache hit, not a dynamic render. Per-request timing over 20 sequential local requests was 16.3 ms for /about against 15.0 ms for /zh/about; that gap is inside the noise floor of a local loopback server, which is the honest version of "the proxy adds roughly nothing" — I cannot resolve it on this machine, and I would not quote it as a production figure.

Two costs are real and worth stating. The first row vs the fourth: a trailing slash plus the prefix is two round trips, because the proxy redirect and Next's own trailing-slash normalisation are separate operations. That is a crawl-budget tax on bad links, not on good ones, and it is the reason I check inbound links rather than only canonical ones. The second cost is that this is a single file in front of every HTML route on the site. A mistake in proxy.ts is a site-wide HTML outage while /llms.txt and /sitemap.xml keep serving, since the matcher excludes them.

That exclusion is worth naming explicitly, because it is a coupling most people discover late: the machine-readable endpoints are matched out of the proxy and served directly from their route handlers. That is what you want for /robots.txt and /rss.xml, and it also means the rewrite never applies to them. If a machine endpoint ever needs per-locale variants, that logic has to live in the route handler, because the proxy will not see the request.

How do I verify the contract after a deploy?

The URL contract is small enough to assert mechanically, and the check runs in a fraction of a second. This is the script I run against a build before and after it ships; the expectations live in one place so a new route cannot be added without stating its status.

{
  "/": { "status": 200, "location": null },
  "/about": { "status": 200, "location": null },
  "/about/": { "status": 308, "location": "/about" },
  "/en/about": { "status": 308, "location": "/about" },
  "/en/blog": { "status": 308, "location": "/blog" },
  "/zh/about": { "status": 200, "location": null },
  "/blog/does-not-exist": { "status": 404, "location": null }
}
#!/usr/bin/env bash
# usage: ./check-url-contract.sh http://127.0.0.1:3411
set -euo pipefail
BASE="${1:?base url required}"
failed=0

while IFS=$'\t' read -r path want_status want_location; do
  headers="$(curl -sS -o /dev/null -D - "$BASE$path" | tr -d '\r')"
  got_status="$(printf '%s' "$headers" | head -1 | awk '{print $2}')"
  got_location="$(printf '%s' "$headers" | awk 'tolower($1)=="location:"{print $2}')"

  if [ "$got_status" != "$want_status" ]; then
    printf 'FAIL %-24s status %s (want %s)\n' "$path" "$got_status" "$want_status"
    failed=1
  elif [ -n "$want_location" ] && [ "$got_location" != "$want_location" ]; then
    printf 'FAIL %-24s location %s (want %s)\n' "$path" "$got_location" "$want_location"
    failed=1
  else
    printf 'ok   %-24s %s\n' "$path" "$got_status"
  fi
done < <(node -e 'const c = require("./url-contract.json");
for (const [p, v] of Object.entries(c)) console.log([p, v.status, v.location ?? ""].join("\t"))')

# The rewrite must serve the prerendered document, not a runtime render.
served="$(curl -sS "$BASE/about" | shasum -a 256 | cut -d' ' -f1)"
prerendered="$(shasum -a 256 .next/server/app/en/about.html | cut -d' ' -f1)"
[ "$served" = "$prerendered" ] || { echo 'FAIL rewrite is not serving prerendered HTML'; failed=1; }

exit "$failed"

Run as written, it prints ok for each row and exits non-zero on the first mismatch. What the header dump catches, and reading the source does not, is the hop count: /en/about/ leaves through the proxy and then leaves again on the trailing-slash normalisation, so the form with both mistakes in it costs two round trips instead of one. A status code alone hides that, and so does a green link checker that treats any 3xx as a working link.

When is an internal rewrite the wrong instrument?

Four cases, and the first two are disqualifying rather than marginal.

If the site is built with output: 'export', there is no proxy at all — the mechanism does not exist, and the honest options are physical directories (/en/about/index.html and a host-level rule) or accepting the prefixed URLs as canonical. I would not contort the URL space to keep a rewrite that the build target cannot support.

If the host cannot execute anything per request — a plain object store, a bare static host without functions — then a rewrite is not available either, and the same choice applies. The tradeoff is explicit: you either pay for an edge layer whose only job is URL normalisation, or you publish longer URLs. Both are defensible; pretending the 200-rewrite exists without one is not.

Third: if the URL genuinely depends on the request rather than on the resource — personalisation, A/B splits, per-tenant paths — a proxy rewrite is the wrong layer, and you want a dynamic route with a cache strategy of its own. Rewrites are for rewriting the name of a document, not for selecting which document a user gets.

Fourth, and the one I would apply most often: if the URL set is small, fixed and unlikely to change, redirects() in next.config plus a canonical page needs less machinery and no runtime component. The internal rewrite pays for itself when the tree is large, generated from content, and would otherwise force a physical en/ directory layer into every public URL — 89 prerendered documents are comfortably past that line; a five-page site is not.

What do I check on a site that already shipped both URLs?

Start with the header dump, not the source: request both spellings of one page and compare the status codes, because a 200 on the prefixed form means the duplicate is live and every subsequent fix is about consolidation rather than prevention. Then compare the canonical tag against the requested path, since a canonical that echoes the internal route publishes the URL you were trying to hide, and confirm the sitemap lists only the short form. Finally, hash the served body against the prerendered file on disk; if they differ, the route is rendering at request time and the static build is no longer the thing being served. I run that sequence on every deploy now, and the only thing it has ever caught is the class of mistake I could not see by reading the code.

Keep reading

More in Engineering

Ready to build a system?[ Book a Call ]