Skip to content

Optional catch-all [[...slug]] does not serve the root / (empty params) → 404 #3

Closed

Description

@hyfdev

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

  1. hyfdev commented on Jun 29, 2026

    @hyfdev
    OwnerAuthor

    🔎 Triage — Next.js behavior vs vinext behavior

    Method: grounded in the installed Next.js 15.1.12 source (node_modules/next/dist) and the exact vinext 0.1.8 dist (node_modules/vinext/dist) this issue was filed against; each conclusion independently re-checked by an adversarial second pass. vinext targets the Next.js 16 API surface, so a possible version difference was tested explicitly.

    Verdict: 🐞 vinext bug (parity gap — fix needed) · should vinext fix: yes · scope: runtime-parity · confidence: high

    Next.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, so if (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 sets params[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 evaluates Array.isArray(actual) && value.join('/') === actual.join('/') where actual = params['markdownPath'] = undefined, so Array.isArray(undefined) is false → the entry does not match → isValidPath = false. With fallback === 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.

  2. hyfdev commented on Jul 1, 2026

    @hyfdev
    OwnerAuthor

    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.js shape (valid in both Next.js and vinext), so no revert is required. Closing as fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions