feat(typegen): support the stable typedRoutes config option - #3076
Open
LukeHalby wants to merge 5 commits into
Open
feat(typegen): support the stable typedRoutes config option#3076LukeHalby wants to merge 5 commits into
LukeHalby wants to merge 5 commits into
Conversation
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
marked this pull request as ready for review
August 25, 2026 05:43
Member
|
/bigbonk review for issues |
Contributor
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
Member
|
/bigbonk review for issues |
1 similar comment
Member
|
/bigbonk review for issues |
Member
|
/bigbonk review for issues |
commit: |
Contributor
Performance benchmarksCompared 0 improved · 0 regressed · 6 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
Member
|
/bigbonk review for issues |
Member
|
/bigbonk review for issues |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3075.
With
typedRoutes: trueinnext.config.*(or inline viavinext({ nextConfig })), dev, build, andvinext typegennow emit.next/types/link.d.tsalongside the existingroutes.d.ts, and the generatednext-env.d.tsreferences it. The file is Next 16's statically-typed-links declaration: the__next_route_internal_types__namespace (StaticRoutes/DynamicRoutes/RouteImpl<T>) plusdeclare moduleaugmentations fornext(a realRoute<T>),next/link(typedhref),next/navigation(typeduseRouter,redirect,permanentRedirect), andnext/form(typedaction). Invalid hrefs like<Link href="/does-not-exist">stop type-checking;as Routecasts still work.What's included
generateLinkTypesFileis 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// causecomments carry project-relative source paths, matching Next'sgetRelativePathoutput.pagesRouter/apiRouterscanners), deduped in Next's manifest iteration order (app routes win). Each shape has a dedicated content assertion intests/typegen.test.ts, with assertions ported from Next'styped-links.test.ts(linked in-test). Dynamic-vs-static classification is driven by the scanners':parampattern 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.tscontinues to model the app dir.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).writeRouteTypes()threading; exercised by the two example apps below, which CI's Playwright jobs rebuild on every PR. The hybridvinext buildsecondary Pages Router SSR pass (which runsvinext({ disableAppRouter: true })on a project that still has an app dir) is now excluded from typegen — previously it flattenedroutes.d.tsto empty unions on every hybrid build (a pre-existing bug this PR fixes) and would have deletedlink.d.ts; verified end to end by running the full hybrid CLI build on the example.vinext typegenresolves the flag fromnext.config.*on disk or from inlinevinext({ 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.tsmanagement: theimport "./.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.typedRoutesis resolved intoResolvedNextConfig(unit-tested for the stable key, the default, and the alias below) and threaded through the plugin and CLI.experimental.typedRoutesis treated as a deprecated alias with Next's exact warning ("has been moved totypedRoutes"), and — matchingwarnOptionHasBeenMovedOutOfExperimentalinpackages/next/src/server/config.ts— the experimental value wins when both are set; all three behaviors are unit-tested intests/next-config.test.ts.RouteImpl<T>falls back tostring & {}exactly like Next (unit-tested), so enabling the flag can never make an empty project fail to compile.vinext check: the stabletypedRouteskey reports as supported;experimental.typedRoutesflips from "unsupported" to a supported deprecated-alias entry. Both statuses and detail strings are asserted intests/check.test.ts.app-router-cloudflareenables the flag inline viavinext({ nextConfig: { typedRoutes: true } })(its looserouter.push()call sites are narrowed via theas Routeidiom, and a tsconfigpathsentry mirrors its existing vite-only@test/og-fontalias so the example type-checks clean);pages-router-cloudflareenables it in its existingnext.config.mjs. Both examples build green and passtsc --noEmitwith the generated declarations, and an invalid-href probe fails in both with the same error real Next produces.typedRoutes.Deliberate divergences from Next
/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/hellostays 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-errorproves the file path is rejected.compareStrings(used throughouttypegen.tsfor deterministic output) instead of Next's locale-dependentlocaleCompare.link.d.tsfrom a previous flag-on run is deleted (unit-tested). Next never cleans it up, butnext buildwipes.nextwhile vinext regenerates in place, and a migrated tsconfig withinclude: [".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 anas Routecast 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 --strictmatrix (App Router / Pages Router × realnextinstalled / vinext fallback types) compiles real consumer files against the generated declarations. Valid hrefs,Routeassignments,redirect()calls, andas Routecasts 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.tsalso covers the inline vite-confignextConfigloader used byvinext 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(unresolvednext/dist/*type imports resolve only whennextis installed; the bareJSXnamespace needs it even on real Next with React 19) — the same requirement Next itself has for this template, andskipLibCheck: trueis Next's managed-tsconfig default.