There is a file in Nakodo that nobody on the project has ever edited, that is not in git, and that 36 source files depend on at compile time. It is 146 lines and Next.js writes it:
// This file is generated automatically by Next.js
// Do not edit this file manually
type AppRoutes = "/" | "/about" | "/admin" | "/admin/inbox" | ... | "/youtubers/[niche]"
type AppRouteHandlerRoutes = "/admin/mailing/export" | "/api/campaigns/[id]/changes" | ...
type PageRoutes = never
type LayoutRoutes = "/" | "/admin" | "/campaigns/[id]" | "/settings"
52 app routes, 20 route handlers, 4 layouts. PageRoutes is never because that one is the Pages Router, which this app has never had.
Then a map from each of those strings to the params it actually has:
interface ParamMap {
"/": {}
"/admin/users/[id]": { "id": string; }
"/campaigns/[id]/creators/[channelId]": { "id": string; "channelId": string; }
"/og/[...path]": { "path": string[]; }
"/tools/[slug]": { "slug": string; }
// ...
}
And three global types built on top of it, which is the part you write code against:
declare global {
interface PageProps<AppRoute extends AppRoutes> {
params: Promise<ParamMap[AppRoute]>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
params: Promise<ParamMap[LayoutRoute]>
children: React.ReactNode
} & {
[K in LayoutSlotMap[LayoutRoute]]: React.ReactNode
}
interface RouteContext<AppRouteHandlerRoute extends AppRouteHandlerRoutes> {
params: Promise<ParamMap[AppRouteHandlerRoute]>
}
}
No imports. They are globals, generated during next dev, next build or next typegen, and tsconfig.json picks them up by including .next/types/**/*.ts and .next/dev/types/**/*.ts.
What a page looks like when the route is the type
The page behind our engagement rate calculator is one of four that share a route, and the whole signature is the URL:
export const dynamicParams = false;
export const generateStaticParams = () => entriesOf("tool").map((e) => ({ slug: e.page.slug }));
export async function generateMetadata({ params }: PageProps<"/tools/[slug]">) {
return entryMetadata(requireEntry("tool", (await params).slug));
}
export default async function ToolPage({ params }: PageProps<"/tools/[slug]">) {
const entry = requireEntry("tool", (await params).slug);
// ...
}
(await params).slug is string. Writing .slugg is an error, and so is writing .id, because this route has no id. That sounds like a small thing until you have 28 files doing it, several of them two and three segments deep:
export default async function CreatorPage({ params }: PageProps<"/campaigns/[id]/creators/[channelId]">) {
const { id, channelId } = await params;
Before the helpers existed, that signature was a hand written { params: Promise<{ id: string; channelId: string }> }, and the hand written version has a failure mode the generated one does not: it keeps compiling after you rename the folder. The literal "/campaigns/[id]/creators/[channelId]" does not. Rename a segment and every file that named the old path fails to typecheck, which is exactly the list of files you need to go and fix.
37 of these annotations across 28 files, out of 53 page.tsx files. The rest take no props at all, because a static page has nothing to read.
The part that is not typed, and should not be
Look again at PageProps:
searchParams: Promise<Record<string, string | string[] | undefined>>
params is derived from the folder names. searchParams cannot be, because a query string is whatever the person pasting the link decided. So you get a Record and the same awkward three way value type as always: a key can be absent, a string, or a string repeated.
In practice that means the route tells you nothing about the query, and every page that reads one is doing its own validation:
export default async function PlanPage({ searchParams }: PageProps<"/settings/plan">) {
const { upgraded } = await searchParams;
upgraded here is string | string[] | undefined, and the only safe thing to do with it is treat presence as a boolean. I have seen people wish for a second type parameter so a route could declare its own query shape. I do not want it. The compiler cannot know, so the choice is between a type that is honest and a type that lies at the exact moment someone hand edits a URL, and the second one is how you get .split() called on an array in production.
Route groups collapse, and the layouts show it
LayoutRoutes has four members. The app has seven layout.tsx files:
src/app/layout.tsx
src/app/(app)/layout.tsx
src/app/(app)/settings/layout.tsx
src/app/(app)/campaigns/[id]/layout.tsx
src/app/(auth)/layout.tsx
src/app/(marketing)/layout.tsx
src/app/admin/layout.tsx
Parenthesised folders do not appear in the URL, so (app), (marketing) and the root all render at /, and there is no separate type for each:
// src/app/layout.tsx
export default function RootLayout({ children }: LayoutProps<"/">) {
// src/app/(app)/layout.tsx
export default async function AppLayout({ children }: LayoutProps<"/">) {
// src/app/(marketing)/layout.tsx
export default function MarketingLayout({ children }: LayoutProps<"/">) {
Three files, same annotation, three completely different jobs: one owns <html>, one checks the session and renders the app shell, one renders the marketing header and footer that every page under the guides sits inside. The type says "a layout that renders somewhere at the root of a URL", which is true, and the file path says the rest.
The seventh is the honest inconsistency in the list:
// src/app/(auth)/layout.tsx
export default function AuthLayout({ children }: { children: React.ReactNode }) {
LayoutProps<"/"> would work there too. It predates the rest and nothing made me change it, because for a layout that reads no params, the hand written type is the same type. The generated helper earns its keep where params exist:
export default async function CampaignLayout({ children, params }: LayoutProps<"/campaigns/[id]">) {
And LayoutSlotMap is the piece I have no use for yet. Every entry in ours is never:
interface LayoutSlotMap {
"/": never
"/admin": never
"/campaigns/[id]": never
"/settings": never
}
Those are parallel route slots: name a folder @modal and the slot turns up in your layout's props as a typed React.ReactNode. We have no parallel routes, so the intersection adds nothing, and the type still tells me something useful by being empty.
Route handlers get the same treatment
Our 20 route handlers use RouteContext, and the one I like is the catch all behind every social card:
export async function GET(_request: Request, ctx: RouteContext<"/og/[...path]">) {
ParamMap["/og/[...path]"] is { path: string[] }. A catch all gives you an array and the type says array, which is the one dynamic segment shape people get wrong from memory.
What we have not turned on
Typed route helpers are not the same feature as typed links. typedRoutes: true in next.config.ts is the opt in that makes next/link validate literal href strings and types push, replace and prefetch from next/navigation. We have not set it.
The reason is not principled, it is about where our wrong links come from. Almost every internal link in the product is written in content, not in JSX: 63 of our 76 pages are entries in one array, and their body text is markdown-ish with [text](/path) links in it. A broken link there is a string in a data file, which typedRoutes would never see, so there is a unit test that resolves every one of them against the registry instead. The handful of <Link href="/settings/plan"> calls in the app shell are not where the breakage happens.
That is a trade I would revisit the moment someone renames an app route, which is also the moment the 37 PageProps annotations start shouting. The literals in page signatures already cover the rename case from one side.
The other cost of typedRoutes is the one in the docs: non literal hrefs need as Route. A product with a dozen hand written links has a dozen literals. A product that computes hrefs from data would be adding casts, and a cast is a worse outcome than a test.
The failure mode worth knowing about
Everything above depends on a file in .next, which is not committed, which means a fresh clone cannot typecheck until something has generated it. I moved .next/types and .next/dev/types out of the way to see what that looks like:
src/app/(app)/campaigns/(list)/page.tsx(26,63): error TS2304: Cannot find name 'PageProps'.
src/app/(app)/campaigns/(list)/page.tsx(43,61): error TS7006: Parameter 'c' implicitly has an 'any' type.
src/app/(app)/campaigns/(list)/page.tsx(96,39): error TS2322: Type '{} | null' is not assignable to type 'number | null'.
50 lines, and only the first one in each file is the real error. PageProps is missing, so params is any, so everything downstream of a param is any, and noImplicitAny reports each of those separately. If you have ever seen a CI job fail with a wall of unrelated looking errors in a Next app, this is usually it, and the fix is in the docs: next typegen && tsc --noEmit.
Our typecheck script is still a bare tsc --noEmit, which works on any machine that has run next dev once. That is fine for a one person project and it is the first thing I would change if a second person joined, because "run the dev server once" is a terrible line in a README.
Worth it?
The generated union has caught exactly the class of mistake it is aimed at: a param that does not exist on the route you are on. It has not caught a single typo in a link, because it does not look at links. And the whole mechanism is one file I have never opened in anger, which is the best thing you can say about generated code.
Top comments (0)