typedRoutes Experimentally and What It Catches That TypeScript Alone Misses
This article was written with the assistance of AI, under human supervision and review.
Most route bugs in Next.js stem from string literals that TypeScript treats as valid code but that fail at runtime. A developer writes /products when the folder structure defines /product, or passes id: "123" to a route expecting a numeric segment. TypeScript compiles both without complaint. The user clicks a link and lands on a 404 page. The cost appears later, in support tickets or lost conversions, not in the editor where the mistake was made.
The typedRoutes experimental flag changes this. When enabled, Next.js generates type definitions from the actual file structure in app/ and makes every route reference a type-checked operation. A route that does not exist becomes a compile-time error. A dynamic segment that receives the wrong type triggers a red squiggle before the code reaches production.
This matters because the feedback loop compresses from hours or days to seconds. Developers see the mistake where they made it. The distinction between runtime discovery and compile-time prevention is the difference between shipping broken links and catching them before commit.
app/ directory structure, turning route strings into type-checked values.href props all gain type safety when the flag is enabled.
Enabling typedRoutes requires three conditions: a Next.js project using App Router, TypeScript 5.0 or later, and the experimental flag set in the config file.
The configuration itself is a single property in next.config.ts:
import type { NextConfig } from 'next'
const config: NextConfig = {
experimental: {
typedRoutes: true,
},
}
export default config
When the dev server starts with this flag, Next.js scans the app/ directory and writes generated type definitions to .next/types/link.d.ts. This file contains a union type of every valid route path in the application. The TypeScript language server reads this file and enforces the types in Link components, useRouter hooks, and redirect calls.
The .next/types directory is build-time output. It should be added to .gitignore alongside .next/. When another developer clones the repository and runs npm run dev, Next.js regenerates the types for their local file structure. This regeneration happens automatically on every file system change that affects routes (adding a folder, deleting a page, renaming a dynamic segment).
The TypeScript version matters because the generated types rely on template literal types and recursive conditional types introduced in TypeScript 4.1 and refined in 5.0. A project stuck on TypeScript 4.9 will see type errors in the generated definitions themselves. Upgrading TypeScript is a prerequisite, not an optional step.
The .next/types/link.d.ts file is a single type definition that exports a Route type. This type is a union of string literals representing every route the application defines.
For a simple app structure:
app/
page.tsx
about/
page.tsx
blog/
[slug]/
page.tsx
Next.js generates a Route type approximately like this:
export type Route = "/" | "/about" | `/blog/${string}`
Dynamic segments appear as template literal types. The [slug] folder becomes /blog/${string}. Optional catch-all segments [[...slug]] generate more complex unions. The generated file is read-only. Modifying it manually does nothing because the next build overwrites the changes.
The Link component from next/link uses this Route type to constrain its href prop. When developers write <Link href="https://dev.to/products">, TypeScript checks if "/products" exists in the Route union. If the route is /product (singular), the editor shows an error before the code runs.
The same type flows into the useRouter hook from next/navigation. Calling router.push("/invalid") fails to compile when /invalid is not part of the Route union. The redirect function from next/navigation also accepts the Route type, catching invalid server-side redirects at compile time instead of runtime.
This generation step is deterministic. Given the same file structure, Next.js always produces the same type definitions. The types update instantly when route files change. Adding a new page to app/dashboard/settings/page.tsx immediately adds "/dashboard/settings" to the Route union without restarting the dev server.
TypeScript validates that code is syntactically correct and type-consistent but cannot verify that a string matches a file path. The typedRoutes flag solves this by making the file system the source of truth for route types.
Without typedRoutes, this code compiles:
import Link from 'next/link'
export default function Navigation() {
return (
<div>
<Link href="https://dev.to/products">Products</Link>
<Link href="https://dev.to/about-us">About</Link>
</div>
)
}
If the actual routes are /product and /about, both links break at runtime. TypeScript sees two string literals assigned to a prop expecting a string. That is valid. The compiler has no concept of route existence.
With typedRoutes, the same code triggers a compile-time error. The Route union contains "/product" but not "/products". TypeScript flags the mismatch. The developer fixes the typo before committing the code.
Dynamic segments add another layer of safety. A route defined as app/blog/[slug]/page.tsx expects a slug parameter. Without typedRoutes, this code compiles but fails at runtime:
<Link href="https://dev.to/blog">Blog</Link>
The route requires a slug. Navigating to /blog without one results in a 404. With typedRoutes, TypeScript enforces that the href matches the template /blog/${string}. The developer must provide a slug:
<Link href="https://dev.to/blog/introduction">Blog</Link>
Query parameters remain untyped even with the flag enabled. Writing href="https://dev.to/products?sort=price" compiles whether or not the products page uses a sort query param. The Route type only captures path segments, not query strings. This limitation is fundamental. Query parameters are runtime values that can be appended to any route, so the type system cannot enumerate them statically.
The implication here is that typedRoutes prevents path errors but does not replace runtime validation for query parameters. Developers still need to validate searchParams in server components or query strings in client components using libraries like Zod or manual checks.
The practical benefit of typedRoutes appears most clearly in navigation code. The Link component, useRouter hook, and redirect function all consume the Route type and enforce it at different layers of the application.
A navigation menu with typedRoutes enabled looks like this:
import Link from 'next/link'
export default function MainNav() {
return (
<nav>
<Link href="https://dev.to/">Home</Link>
<Link href="https://dev.to/about">About</Link>
<Link href="https://dev.to/blog/nextjs-routing">Latest Post</Link>
{/* This line triggers a type error if /products does not exist */}
<Link href="https://dev.to/products">Products</Link>
</nav>
)
}
If the /products route does not exist in the file structure, TypeScript underlines the string with a red squiggle. The error message states that "/products" is not assignable to type Route. The developer sees the mistake immediately and corrects it to the actual route (perhaps /product or /shop).
Client-side navigation using useRouter gains the same safety:
'use client'
import { useRouter } from 'next/navigation'
export default function ProductCard({ id }: { id: string }) {
const router = useRouter()
const handleClick = () => {
// Type-safe: Next.js knows /product/[id] exists
router.push(`/product/${id}`)
// Type error: /products/[id] does not exist in file structure
// router.push(`/products/${id}`)
}
return (
<button onClick={handleClick}>
View Product
</button>
)
}
The template literal /product/${id} matches the Route type because app/product/[id]/page.tsx exists. The commented-out line would fail to compile if uncommented, preventing the runtime 404 before the code reaches production.
Server actions and route handlers also benefit. The redirect function from next/navigation accepts the Route type:
import { redirect } from 'next/navigation'
export async function createProduct(formData: FormData) {
const productId = await saveProduct(formData)
// Type-safe redirect to dynamic route
redirect(`/product/${productId}`)
// Type error: this route does not exist
// redirect('/products/success')
}
This enforcement extends to nested layouts and parallel routes. If the app defines @modal/(.)product/[id]/page.tsx as a parallel route, the Route type includes that path. Referencing it in a Link or redirect call becomes type-safe.
The code examples above show the primary use case: preventing 404s caused by typos or refactoring oversights. When a route moves from /user/[id] to /profile/[id], every reference to /user/${id} becomes a type error. The developer cannot merge the PR until every reference updates. This catches breaking changes that manual code review might miss.
The typedRoutes flag enforces type safety for route paths but does not cover several navigation scenarios. Understanding where it fails prevents false confidence in type coverage.
Middleware rewrites and redirects bypass the type system. When middleware rewrites /old-path to /new-path, the Route type knows nothing about the rewrite. The application allows navigation to /old-path at runtime even though no app/old-path/page.tsx exists. TypeScript flags /old-path as invalid, but the code works in production. This mismatch forces developers to disable the type check or maintain a separate list of middleware-handled paths.
External links pose the same problem. Writing <Link href="https://example.com"> triggers a type error because https://example.com is not part of the Route union. The recommended workaround is casting the href to any or using an anchor tag instead of Link for external URLs. Neither solution is satisfying. The type system cannot distinguish between same-origin and cross-origin URLs, so it rejects both invalid internal routes and valid external links with the same error.
Dynamic imports and lazy-loaded routes also escape type checking. When a route is code-split and loaded on demand, the Route type includes it, but the runtime behavior depends on chunk availability. A network failure that prevents the route chunk results in a runtime error that typedRoutes does not predict.
Query parameter validation remains outside the scope of the feature. As mentioned earlier, the Route type captures path segments but ignores query strings. A route expecting ?page=1 accepts ?page=invalid without complaint. Developers must layer in runtime validation using libraries or manual checks.
Optional catch-all segments [[...slug]] generate overly permissive types. The route app/docs/[[...slug]]/page.tsx produces a Route type like /docs | /docs/${string}. This accepts /docs/a/b/c/d/e even if the application logic only handles two-level paths. The type system has no way to encode "up to N segments" without listing every valid combination explicitly.
Hash fragments (#section) and state objects passed to router.push are also untyped. The Route type only validates the pathname portion of the URL. Passing router.push("/blog/post", { state: { scroll: false } }) compiles even if the state object is malformed.
The failure mode here is subtle but expensive. Developers enable typedRoutes expecting full route safety and later discover that middleware, external links, and query parameters still require manual validation. The type system provides coverage for static paths and simple dynamic segments, not comprehensive navigation safety.
Adding typedRoutes to an established codebase requires planning. The types generated by the flag will immediately flag hundreds or thousands of route references, depending on project size. A gradual rollout avoids paralysis.
The first step is enabling the flag in next.config.ts and running the dev server to generate the initial types. This creates .next/types/link.d.ts and surfaces every route reference that does not match the file structure. The error count might be overwhelming. Do not attempt to fix all errors at once.
The second step is triaging errors by route importance. Start with the most critical user flows: authentication, checkout, dashboard navigation. Fix those routes first. The rest can wait. This ensures that the highest-value parts of the application gain type safety immediately while less critical pages remain in the backlog.
Type assertions provide a temporary escape hatch. When a route reference is valid but the type system rejects it (such as middleware rewrites), casting to any or using a type guard allows the code to compile:
<Link href={"/legacy-path" as any}>Old Link</Link>
This approach is pragmatic but accumulates technical debt. Each assertion is a spot where the type system cannot help. Minimize them. Track them in comments or a task list for eventual removal.
Refactoring route structures during migration is tempting but dangerous. If the file structure changes (renaming folders, consolidating routes), the Route type updates immediately. Every existing route reference breaks. Changing both the file structure and fixing route references in the same PR creates a large, risky changeset. Separate the two. Fix existing references first, then refactor the file structure in a follow-up PR.
Testing remains essential. The typedRoutes flag catches path errors at compile time but does not validate application logic. A route might exist and type-check correctly but still render the wrong content or fail to handle edge cases. Integration tests that navigate between routes ensure the application works end-to-end, not just that the paths are spelled correctly.
The migration timeline depends on codebase size and team capacity. A small application with 20 routes can migrate in a day. A large enterprise app with hundreds of routes might take weeks. The strategy is the same: enable the flag, prioritize critical flows, fix incrementally, ship often.
No. The feature requires App Router. Pages Router uses file-based routing but does not generate route types. Projects using Pages Router must migrate to App Router to gain typed route support.
Yes, but the custom server must delegate routing to Next.js. If the custom server handles routes independently (such as Express routes that bypass Next.js), those routes remain untyped. Only routes defined in app/ gain type safety.
Next.js stops generating .next/types/link.d.ts. Existing route references lose type checking and revert to accepting any string. The code continues to work at runtime, but type safety disappears.
Minimally. Type generation adds a few hundred milliseconds to the initial dev server startup. Incremental updates when routes change are near-instant. Production builds see no measurable impact because the type generation happens once during the build step.
No. The Route type is generated automatically from the file structure and cannot be extended manually. Projects needing custom route validation must layer additional runtime checks on top of the generated types.
The typedRoutes flag prevents a specific class of bugs (invalid route references) at the cost of additional build complexity and an experimental status. Whether to enable it depends on how frequently route errors occur in your workflow and whether your team has the TypeScript expertise to manage the generated types.
Teams with large codebases, frequent route refactoring, or a history of 404 bugs will see immediate value. The type system catches errors before they reach production. The feedback loop compresses to seconds. The cost is negligible for projects already using TypeScript.
Smaller projects with stable route structures might not benefit. If routes rarely change and manual testing catches errors reliably, the added complexity of managing generated types might outweigh the safety gains.
The experimental status matters. Next.js might change the implementation, deprecate the feature, or promote it to stable with breaking changes. Projects enabling typedRoutes should monitor Next.js release notes and be prepared to adapt if the API shifts.
That covers the essential patterns for typed routes in Next.js. Enable the flag in development first, validate that the generated types match your expectations, and gradually roll it out to production once your team understands the tradeoffs. The difference between catching route errors at compile time versus runtime is immediate.