Skip to content

feat(typegen): support the stable typedRoutes config option - #3076

Open
LukeHalby wants to merge 5 commits into
cloudflare:mainfrom
LukeHalby:feat/3075-typed-routes
Open

feat(typegen): support the stable typedRoutes config option#3076
LukeHalby wants to merge 5 commits into
cloudflare:mainfrom
LukeHalby:feat/3075-typed-routes

Conversation

@LukeHalby

@LukeHalby LukeHalby commented Aug 25, 2026

Copy link
Copy Markdown

Closes #3075.

With typedRoutes: true in next.config.* (or inline via vinext({ nextConfig })), dev, build, and vinext typegen now emit .next/types/link.d.ts alongside the existing routes.d.ts, and the generated next-env.d.ts references it. The file is Next 16's statically-typed-links declaration: the __next_route_internal_types__ namespace (StaticRoutes / DynamicRoutes / RouteImpl<T>) plus declare module augmentations for next (a real Route<T>), next/link (typed href), next/navigation (typed useRouter, redirect, permanentRedirect), and next/form (typed action). Invalid hrefs like <Link href="/does-not-exist"> stop type-checking; as Route casts still work.

What's included

  • Template port: generateLinkTypesFile is ported from Next.js (packages/next/src/server/lib/router-utils/typegen.ts, identical in next@16.3.0 dist) with the emitted declaration content kept verbatim. Verified by running Next's own dist implementation side by side on equivalent manifests: the output is byte-identical to next@16.3.0's for both an app-only and a hybrid app+pages route set. The // cause comments carry project-relative source paths, matching Next's getRelativePath output.
  • Route coverage: App Router pages (static, dynamic, catch-all, optional catch-all, route groups stripped) and route handlers, plus Pages Router pages and API routes (via the existing pagesRouter/apiRouter scanners), deduped in Next's manifest iteration order (app routes win). Each shape has a dedicated content assertion in tests/typegen.test.ts, with assertions ported from Next's typed-links.test.ts (linked in-test). Dynamic-vs-static classification is driven by the scanners' :param pattern parts rather than re-parsing the decoded route string, so percent-encoded literal bracket segments (%5Bslug%5D → the static URL /[slug]) stay static instead of degrading into an accept-anything ${SafeSlug<T>} matcher (regression-tested for both routers). Pages Router routes go only into the link unions — routes.d.ts continues to model the app dir.
  • All three emission paths:
    • Dev: initial emission and watch regeneration are covered by tests that boot a real Vite dev server with inline vinext({ nextConfig: { typedRoutes: true } }), then add a page and assert the union updates — for both App Router and Pages Router file changes (the pages watcher branch now queues route-type regeneration alongside its route-cache invalidation).
    • Build: the plugin's build-time writeRouteTypes() threading; exercised by the two example apps below, which CI's Playwright jobs rebuild on every PR. The hybrid vinext build secondary Pages Router SSR pass (which runs vinext({ disableAppRouter: true }) on a project that still has an app dir) is now excluded from typegen — previously it flattened routes.d.ts to empty unions on every hybrid build (a pre-existing bug this PR fixes) and would have deleted link.d.ts; verified end to end by running the full hybrid CLI build on the example.
    • CLI: vinext typegen resolves the flag from next.config.* on disk or from inline vinext({ nextConfig }) in vite.config (inline wins, matching the plugin's own precedence; the loader helper is unit-tested and the inline path is verified live in the example).
  • next-env.d.ts management: the import "./.next/types/link.d.ts"; line is added when the flag is on and absent when off, including the on→off transition; unit tests cover all three states.
  • Config plumbing: typedRoutes is resolved into ResolvedNextConfig (unit-tested for the stable key, the default, and the alias below) and threaded through the plugin and CLI. experimental.typedRoutes is treated as a deprecated alias with Next's exact warning ("has been moved to typedRoutes"), and — matching warnOptionHasBeenMovedOutOfExperimental in packages/next/src/server/config.ts — the experimental value wins when both are set; all three behaviors are unit-tested in tests/next-config.test.ts.
  • Zero-routes fallback: with no routable files, the emitted RouteImpl<T> falls back to string & {} exactly like Next (unit-tested), so enabling the flag can never make an empty project fail to compile.
  • vinext check: the stable typedRoutes key reports as supported; experimental.typedRoutes flips from "unsupported" to a supported deprecated-alias entry. Both statuses and detail strings are asserted in tests/check.test.ts.
  • Examples: app-router-cloudflare enables the flag inline via vinext({ nextConfig: { typedRoutes: true } }) (its loose router.push() call sites are narrowed via the as Route idiom, and a tsconfig paths entry mirrors its existing vite-only @test/og-font alias so the example type-checks clean); pages-router-cloudflare enables it in its existing next.config.mjs. Both examples build green and pass tsc --noEmit with the generated declarations, and an invalid-href probe fails in both with the same error real Next produces.
  • README: config-table row for typedRoutes.

Deliberate divergences from Next

  • Pages Router API routes are emitted as URL routes (/api/hello). Next's implementation pushes the manifest's raw relative file paths into the unions (its own comment notes "Pages Router API routes are stored as file paths"), so next@16.3.0 emits entries like `pages/api/hello.ts` — which would make file paths valid hrefs while the real /api/hello stays invalid. vinext emits the API routes' URLs, consistent with how App Router route handlers are treated. Tested from both sides: the URL entry must be present, the raw file path absent, and a consumer-level @ts-expect-error proves the file path is rejected.
  • Union entries are sorted with vinext's compareStrings (used throughout typegen.ts for deterministic output) instead of Next's locale-dependent localeCompare.
  • When the flag is off, a stale link.d.ts from a previous flag-on run is deleted (unit-tested). Next never cleans it up, but next build wipes .next while vinext regenerates in place, and a migrated tsconfig with include: [".next/types/**/*.ts"] would otherwise keep applying stale typed-route declarations.

Deferred (follow-ups)

Redirect/rewrite sources in the unions, and experimental.strictRouteTypes (the validator files). Note the practical implication of the first deferral: Next.js includes redirect/rewrite sources in the unions, so until the follow-up lands, an href that only exists as a rewrite or redirect source needs an as Route cast under vinext where it would type-check bare under Next.

Testing

The core assurance is that enforcement actually fires in a type-checker, in every supported configuration: a four-way tsc --strict matrix (App Router / Pages Router × real next installed / vinext fallback types) compiles real consumer files against the generated declarations. Valid hrefs, Route assignments, redirect() calls, and as Route casts must compile; invalid ones must fail — and because every negative case is a @ts-expect-error (which errors when unused), the matrix is self-verifying in both directions and cannot pass by silently checking nothing.

Around that:

  • tests/typegen.test.ts: per-shape emission assertions (static / dynamic / catch-all / optional catch-all / route group / route handler / Pages Router pages and API routes, hybrid and pages-only projects); percent-encoded literal bracket segments staying static; flag-off emitting nothing and cleaning up stale output; zero-routes fallback; dev-server regeneration with inline config for both App Router and Pages Router file changes (the Pages Router regeneration test was verified to fail without its watcher fix). Assertions ported from Next.js: test/e2e/app-dir/typed-routes/typed-links.test.ts.
  • tests/next-config.test.ts also covers the inline vite-config nextConfig loader used by vinext typegen.
  • tests/next-config.test.ts: resolution precedence (stable key, experimental alias, both set) and the deprecation warning.
  • tests/check.test.ts: support-map entries for both keys.

The generated file needs skipLibCheck (unresolved next/dist/* type imports resolve only when next is installed; the bare JSX namespace needs it even on real Next with React 19) — the same requirement Next itself has for this template, and skipLibCheck: true is Next's managed-tsconfig default.

When typedRoutes is enabled, dev, build, and `vinext typegen` emit
.next/types/link.d.ts (Next 16's statically-typed-links declarations:
Route<T>, typed Link hrefs, typed useRouter/redirect/permanentRedirect,
typed next/form action) alongside routes.d.ts, and the generated
next-env.d.ts references it. The declaration template is ported verbatim
from Next.js and the emitted file is byte-identical to next@16.3.0's
output for equivalent routes. The unions cover App Router pages and
route handlers plus Pages Router pages and API routes; pages API routes
are emitted as URL routes rather than Next's raw file-path entries (a
deliberate, documented divergence). experimental.typedRoutes resolves as
a deprecated alias with Next's moved-option warning and precedence, and
vinext check reports both keys as supported. Redirect/rewrite sources in
the unions and strictRouteTypes are deferred.

The app-router-cloudflare example enables the flag inline via
vinext({ nextConfig }) and its loose router.push() call sites are
narrowed via the as-Route idiom; pages-router-cloudflare enables it in
next.config.mjs.

Closes cloudflare#3075
Adversarial-review round (Claude + Codex head-to-head):

- The hybrid `vinext build` secondary Pages Router SSR pass no longer
  runs route typegen: it resolves its own app-less config, which flattened
  routes.d.ts to empty unions on every hybrid build (pre-existing bug) and
  deleted the new link.d.ts when typedRoutes came from inline vite config.
- Pages Router add/unlink watcher events now queue route-type regeneration,
  so typed-link unions stay fresh in dev for pages-only and hybrid apps.
- Typed-link dynamic/static classification is driven by the scanners'
  :param pattern parts instead of re-parsing decoded route strings, so
  percent-encoded literal bracket segments (%5Bslug%5D) stay static rather
  than becoming accept-anything SafeSlug matchers.
- `vinext typegen` now honors inline vinext({ nextConfig }) from
  vite.config with the plugin's inline-over-disk precedence.
- Drop the unused generateLinkTypesFile export.
@LukeHalby
LukeHalby marked this pull request as ready for review August 25, 2026 05:43
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Aug 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@3076
npm i https://pkg.pr.new/create-vinext-app@3076
npm i https://pkg.pr.new/@vinext/types@3076
npm i https://pkg.pr.new/vinext@3076

commit: d3b4cc5

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared d3b4cc5 against base 4146d69 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 142.2 KB 142.2 KB ⚫ -0.0%
Client entry size (gzip) vinext 129.5 KB 129.5 KB ⚫ -0.0%
Dev server cold start vinext 3.24 s 3.21 s ⚫ -0.7%
Production build time vinext 3.67 s 3.65 s ⚫ -0.6%
RSC entry closure size (gzip) vinext 116.2 KB 116.6 KB ⚫ +0.4%
Server bundle size (gzip) vinext 197.7 KB 198.1 KB ⚫ +0.2%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support the typedRoutes config option (statically typed links)

2 participants