The bug this removes
A link to /services/wordpress-developmnet. A country page URL built from a slug that was renamed last month. A redirect to a route that was deleted. Each is a 404 that no test caught because links are strings and strings do not know about the filesystem. On a site with a few hundred routes generated from data, these creep in every time a slug changes.
Typed routes make href a union of every route the app has, so the build fails on a link to nowhere.
Enabling it
In next.config.ts:
const nextConfig = {
typedRoutes: true,
}
export default nextConfigStable in Next.js 15.5 and later (it was experimental.typedRoutes before). On the next next dev or next build, Next.js generates .next/types/routes.d.ts (and link.d.ts), declaring a Route type that is the union of every static route path and template literal types for every dynamic segment. Link's href prop and the router's navigation methods are typed against it.
Add the generated types to tsconfig.json's include if they are not picked up (".next/types/**/*.ts" is added automatically by next dev in most setups). Run next typegen in CI before tsc if you type-check without building first.
What is typed
<Link href="...">fromnext/link.router.push,router.replace,router.prefetchfromnext/navigation(via theuseRouterhook).redirect()andpermanentRedirect()fromnext/navigation.- The
Routetype itself, importable fromnextfor your own helpers.
A string literal that matches a route passes. A string literal that does not fails with a type error naming the nearest routes. A plain string variable fails, because it could be anything.
Dynamic hrefs that satisfy it
The common friction is building hrefs from data. `/websites-for/${vertical.slug}` is a template literal whose type is `/websites-for/${string}`, and that matches the dynamic route /websites-for/[vertical]. So template literals with typed segments work directly:
import Link from 'next/link'
export function VerticalLink({ slug }: { slug: string }) {
return <Link href={`/websites-for/${slug}`}>…</Link>
}Nested dynamic routes work the same: `/websites-for/${parent}/${sub}` matches /websites-for/[vertical]/[sub].
Where it breaks is when the path is assembled elsewhere and arrives as string:
const path = verticalPath(v) // returns string
<Link href={path}> // error: string is not assignable to RouteFix by typing the helper's return:
import type { Route } from 'next'
export function verticalPath(v: Vertical): Route {
return `/websites-for/${v.slug}` as Route
}The as Route is honest here: the helper is the one place that knows the shape, and the assertion is localised. Better still, return the template literal type so the assertion is unnecessary:
export function verticalPath(v: Vertical): `/websites-for/${string}` {
return `/websites-for/${v.slug}`
}For paths from a registry (a JSON of routes, a CMS), where the values are genuinely strings, a single narrowing function at the boundary:
import type { Route } from 'next'
export function asRoute(path: string): Route {
// optionally validate against a known list here
return path as Route
}Used only at the registry boundary, this keeps the assertion in one file and everything downstream typed.
Query strings and hashes
An href object works and is typed on pathname:
<Link href={{ pathname: '/blog', query: { tag: 'nextjs' } }}>String hrefs with a query or hash are also accepted when the path part matches: /blog?tag=nextjs and /pricing#care type-check.
External URLs
Link with an external href (https://…) is allowed; the Route type includes external URL patterns. mailto: and tel: also pass.
Redirects and generateStaticParams
redirect('/services/wordpress-development') is typed, so a redirect to a removed route fails the build. This is the most valuable case on a site that has restructured: every stale redirect surfaces at once.
generateStaticParams is not typed against routes (it returns params, not paths), but the pages it generates are, so links to them are.
Where the types stop
- Runtime data. A slug from the CMS that does not exist as a page still type-checks, because
`/blog/${string}`accepts any string. Typed routes guarantee the route pattern exists, not that the specific page does. A registry check or anotFound()in the page handles the second half. next.configredirects and rewrites. Plain strings in config; not typed. Keep them few and test them.- Sitemap and metadata URLs. Built as strings for
MetadataRoute.Sitemap; not typed against routes. Generate them from the same source as the pages so they cannot drift. - Middleware
NextResponse.redirect. Takes a URL; not typed.
Adopting on an existing site
Turn it on and run next build. Expect a burst of errors in three categories: genuine broken links (fix them), helpers returning string (type them), and hrefs from data (narrow at the boundary). On a site with a few hundred routes this is an afternoon, and the afternoon usually finds two or three real 404s nobody knew about.
Where this sits
Typed routes are on for every Next.js site we build, and turning them on is one of the first things we do when inheriting one, because the build finding a broken link is cheaper than Search Console finding it a month later.