Skip to main content
System.Return('/blog')
5 min read

The Redirects That Vanished: vercel.json, Prebuilt Deploys, and 308s Rebuilt as Routes

vercel deploy --prebuilt reads .vercel/output/config.json, not vercel.json — so six redirect rules silently stopped firing and /syslog answered 404. Commit 4e238be re-emitted them as routes[] entries, and 19d1b23 taught the probes about trailing slashes. Real code, real ordering constraints.

For an unknown number of days, /syslog, /case-studies, and /projects answered 404. The redirects were right there in vercel.json: six rules, permanent: true, nothing exotic. The problem was that nothing in production was reading vercel.json at all. Two commits (4e238be and 19d1b23) rebuilt Vercel redirects as prebuilt routes, and the ordering constraint is what makes the whole thing work.

The setup: vercel.json is documentation under —prebuilt

This repo deploys with vercel deploy --prebuilt, which uploads .vercel/output/ wholesale, and the edge reads .vercel/output/config.json, not vercel.json. Build Output API v3 has neither a top-level headers key nor a top-level redirects key. A normal (non-prebuilt) vercel deploy translates vercel.json server-side; a prebuilt one never looks at it.

The redirect set itself is unremarkable: legacy path aliases kept for SEO:

{
  "redirects": [
    { "source": "/syslog/:path*", "destination": "/blog/:path*", "permanent": true },
    { "source": "/syslog", "destination": "/blog", "permanent": true },
    { "source": "/case-studies/:path*", "destination": "/work/:path*", "permanent": true },
    { "source": "/case-studies", "destination": "/work", "permanent": true },
    { "source": "/projects/:path*", "destination": "/work/:path*", "permanent": true },
    { "source": "/projects", "destination": "/work", "permanent": true }
  ]
}

The failure mode was silent: after the old stub pages were deleted there was no static file to serve either, so /syslog didn’t fall back to anything. It answered 404 instead of 308. No error, no warning, just dead legacy URLs. If you’ve read the CSP outage postmortem, you’ll recognize the shape: a config the edge ignores fails exactly like a config that doesn’t exist.

Commit 4e238be: emit redirects as routes[]

The fix (commit 4e238be, fix(deploy): emit vercel redirects into prebuilt config + verify live) extends the existing header-emission machinery in scripts/finalize-csp.mjs. That is the same script that already translated vercel.json headers into routes[] entries; it now also translates redirects[]. The core translator lives in scripts/lib/vercel-headers.mjs:

export function toRedirectRoute(entry) {
  // ...validates has/missing, maps :params to $N captures...
  let status;
  if (typeof entry.permanent === 'boolean') {
    status = entry.permanent ? 308 : 307;
  } else if (entry.statusCode !== undefined) {
    status = entry.statusCode;
  } else {
    status = 308;
  }
  // No `continue`: like the canonical converter, a redirect route terminates
  // matching. Header routes placed AHEAD of it still apply — their
  // `continue: true` headers merge into the redirect response.
  return { src, headers: { Location: location }, status };
}

Three deliberate choices here. First, permanent: true becomes 308, false becomes 307: byte-identical to what non-prebuilt vercel deploy derives server-side, so the two deploy modes can’t drift. Second, entries using has/missing conditions are rejected with an error rather than approximated, refusing to ship a route that matches more broadly than intended. Third, redirect routes carry no continue: they terminate matching, exactly like the canonical converter.

The merge order is the whole game, and the comment in mergePrebuiltRoutes states it outright:

export function mergePrebuiltRoutes(config, headerRoutes, redirectRoutes) {
  // ...
  return { ...config, routes: [...headerRoutes, ...redirectRoutes, ...kept] };
}

Headers first, redirects second, filesystem handler last. Headers ahead so redirect responses keep the security headers (their continue: true merges into the terminating redirect response, verified live by verify-prod-csp.mjs, which asserts security headers on redirect responses too). Redirects before the filesystem handler so they fire instead of falling through to static files. The merge is idempotent. Re-emitted srcs are dropped first so re-running never duplicates routes, and finalize-csp.mjs asserts contiguity, count parity with vercel.json, handler position, and no unmapped :param leakage into Location targets. Any violation fails the build loudly rather than shipping a silent SEO regression.

Commit 19d1b23: the trailing-slash probe fix

With redirects emitting, the live verifier (scripts/verify-prod-csp.mjs) probes each rule with redirect: 'manual' so the 308 itself is observed rather than followed. Wildcard :path* sources get exercised with a sample /probe-depth sub-path. That verifier immediately caught a real edge behavior: Vercel normalizes bare-destination Locations with a trailing slash, so /syslog answers Location: /blog/ even though vercel.json says /blog. Commit 19d1b23 (fix(deploy): trailing-slash-tolerant redirect probes) made the comparison slash-insensitive:

// The edge normalizes bare-destination Locations with a trailing slash
// (/syslog -> /blog/ even though vercel.json says /blog), so compare
// slash-insensitively: strip one trailing "/" from both sides, except
// the root "/" itself.
const normalizeLocation = (value) =>
  value.length > 1 ? value.replace(/\/$/, '') : value;

This is a probe fix, not a behavior fix. The redirect was correct all along; the assertion was too strict. Distinguishing those two cases is the entire value of a live verifier: without it, you’d “fix” a working redirect to satisfy a wrong test.

Honest limitations

  • Unknown dark window. I don’t know how long the redirects were dead. There was no alerting on 404s for legacy paths, only the post-fix verifier. The slim CI rebuild added pipeline efficiency but no synthetic monitoring; a scheduled probe of /syslog would have caught this on day one.
  • Emitter covers a subset of vercel.json. has/missing conditions, locale-aware redirects, and anything beyond source/destination/permanent/statusCode throws rather than translates. Today that’s correct (the six rules use none of it), but the next person to add a conditional redirect gets a build failure with no migration path. The error message explains what but not how to extend the emitter.
  • Probe depth is one. Wildcards are exercised with a single /probe-depth sample; a regex regression that breaks only multi-segment paths (/syslog/a/b) would pass verification. Cheap to extend, not yet extended.
  • No measured SEO impact. I have no Search Console data quantifying lost crawl equity during the outage. Only the mechanism (404 where 308 belonged) and the fix. Like the KV teardown, the dollar figure I’d want doesn’t exist; I’m reporting the diff, not the damage.
  • vercel.json is still the source of truth by convention only. Nothing stops a future edit from adding headers or rewrites that the emitter doesn’t translate. The count-parity assertion covers redirects; headers have their own assertions; rewrites aren’t used at all. But the coupling is maintained by discipline plus assertions, not by a single code path.
Share this log: Share on X Share on LinkedIn Share on Reddit

Related logs

↑↓ navigate Tab complete Enter open Esc close View all →