Description
Problem
vinext does not serve the root / of an optional catch-all route pages/[[...slug]].js.
react.dev serves nearly everything from src/pages/[[...markdownPath]].js. Its getStaticPaths emits { markdownPath: [] } for src/content/index.md (the homepage). Under Next this serves /. Under vinext, the optional catch-all is converted to /:markdownPath* but the empty-params root is not served.
Repro
curl -i http://localhost:3000/→ 404 (serves the 404 page;og:title="Not Found").curl -i http://localhost:3000/_next/data/<id>/index.json→ 404.- Every non-root path (
/learn,/reference/...) → 200, so it's specific to the empty-params root.
Workaround used
Convert the optional catch-all to a required catch-all [...markdownPath].js (which no longer matches /), filter the empty entry out of getStaticPaths, add an explicit pages/index.js re-exporting default + getStaticProps, and make context.params?.markdownPath null-safe (vinext passes params === null for the static / route).
Suggested fix
An optional catch-all [[...slug]] should match / with empty params, like Next.js.
Activity
hyfdev commentedon Jun 29, 2026
🔎 Triage — Next.js behavior vs vinext behavior
Verdict: 🐞 vinext bug (parity gap — fix needed) · should vinext fix: yes · scope:
runtime-parity· confidence: highNext.js behavior
Next.js's optional catch-all [[...slug]] matches the root "/". getRouteRegex emits the parameterized route
(?:/(.+?))?wrapped as^(?:/(.+?))?(?:/)?$, which matches "/". At runtime its matcher leaves the slug param UNSET for the empty root (the capture group is undefined, soif (m !== undefined)skips it) — i.e. Next's runtime params for "/" are also{}. For SSG, getStaticPaths returning{ params: { slug: [] } }produces the prerendered pathname "/" (empty array → no segments), and Next serves that path by matching the request PATHNAME against its prerendered/static set — it does not re-run getStaticPaths and compare param objects at request time. Net result: GET / → 200 served by the optional catch-all with empty params. This has been stable since ~Next 9.Evidence: node_modules/next/dist/shared/lib/router/utils/route-regex.js:100 (optional catch-all -> '(?:/(.+?))?') · node_modules/next/dist/shared/lib/router/utils/route-regex.js:108-114 (getRouteRegex wraps as ^...(?:/)?$, matches '/') · node_modules/next/dist/shared/lib/router/utils/route-matcher.js:30 (param only set when capture !== undefined; root yields no slug key)
vinext behavior
vinext's request-time route trie correctly MATCHES the root "/" to the optional catch-all route (route-trie.js:105-108 returns optionalCatchAllChild.route), but that terminal branch returns
buildParams([])={}WITHOUT setting the catch-all key to[](unlike the non-root branch at route-trie.js:132-138 which setsparams[paramName] = urlParts.slice(index)). The matched params{}flow unchanged through pages-page-handler.js:122,218 into resolvePagesPageData. There (pages-page-data.js:234-241) vinext re-runs getStaticPaths at request time and validates via matchesPagesStaticPath: for entry{ params: { markdownPath: [] } }and actual params{}, it evaluatesArray.isArray(actual) && value.join('/') === actual.join('/')whereactual = params['markdownPath'] = undefined, so Array.isArray(undefined) is false → the entry does not match → isValidPath = false. Withfallback === false, line 241 returns buildPagesNotFoundResult → 404 for the HTML page (kind:'notFound' → 404 page) and for /_next/data//index.json (kind:'response' → 404 JSON). Every non-root path works because the trie's non-terminal branch sets markdownPath to a non-empty array that join-compares equal to the getStaticPaths entry.Evidence:
node_modules/vinext/dist/routing/route-trie.js:100-109 (terminal/root optional-catch-all match returns buildParams(entries) with no empty-array param)·node_modules/vinext/dist/routing/route-trie.js:132-138 (non-root branch DOES set params[paramName]=urlParts.slice(index))·node_modules/vinext/dist/server/pages-page-data.js:240-241 (isValidPath via matchesPagesStaticPath; fallback===false && !isValidPath returns 404)·node_modules/vinext/dist/server/pages-page-data.js:146-155 (matchesPagesStaticPath: Array.isArray(actual) check fails when actual is undefined for empty root)·node_modules/vinext/dist/server/pages-page-handler.js:122 and 218 (matched params passed unchanged into resolvePagesPageData)·node_modules/vinext/dist/routing/route-pattern.js:4 (([[...x]]) -> :x*) and 27-33 (fillRoutePatternSegments yields '/' for empty params)Is it a version difference (vinext → Next 16 vs react.dev → Next 15)?
No. Next.js optional catch-all matching the root '/' with empty params is stable since ~Next 9 — the route-regex
(?:/(.+?))?form and the prerender-by-pathname SSG model are unchanged across Next 13-16. The installed Next 15.1.12 source (route-regex.js:100, route-matcher.js:30) shows exactly this behavior, and there is no Next 15->16 changelog entry altering optional-catch-all root matching. The divergence is not from vinext targeting Next 16; it is a vinext implementation defect in request-time getStaticPaths param re-validation.Fix
dist/server/pages-page-data.js — matchesPagesStaticPath (146-155) / the isValidPath check at resolvePagesPageData (240-241): treat a missing/undefined actual param as equivalent to an empty array when the getStaticPaths entry value is []. Equivalently/additionally dist/routing/route-trie.js:105-108: populate params[optionalCatchAllChild.paramName]=[] for the empty-root optional-catch-all match so the matched param representation matches the getStaticPaths entry.
🤖 Triaged with Claude Code (Opus 4.8) — read-only analysis against installed Next 15.1.12 + vinext 0.1.8 sources, adversarially verified.
hyfdev commentedon Jul 1, 2026
Fixed upstream: cloudflare/vinext#2238 (
fix(pages): match optional catch-all prerender paths), released in vinext 0.2.0 — the optional catch-all now serves the empty-params root/.This port keeps the required-catch-all + explicit
index.jsshape (valid in both Next.js and vinext), so no revert is required. Closing as fixed.