Next.js

Typed Routes in Next.js 16: Dynamic hrefs That Compile

Statically typed links catch broken hrefs at build time. How typedRoutes works in Next.js 16, dynamic hrefs that satisfy it, redirects.

5 min read
Next.jsTypeScriptRouting
856 words5 min read

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:

TypeScript
const nextConfig = {
  typedRoutes: true,
}
export default nextConfig

Stable 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="..."> from next/link.
  • router.push, router.replace, router.prefetch from next/navigation (via the useRouter hook).
  • redirect() and permanentRedirect() from next/navigation.
  • The Route type itself, importable from next for 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:

TSX
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:

TypeScript
const path = verticalPath(v)          // returns string
<Link href={path}>                     // error: string is not assignable to Route

Fix by typing the helper's return:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TSX
<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 a notFound() in the page handles the second half.
  • next.config redirects 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.

All writingHire me for this