Next.js 16 typedRoutes Breaking Dynamic href TypeScript Errors

Next.js 16 typedRoutes throwing 'string is not assignable to Route' on dynamic Link hrefs? Why the new validator rejects template strings and the fix.
Next.jsTypeScriptApp Router
June 11, 20266 min read1151 words

The Problem

I picked up a Next.js 15 to 16.2 upgrade on a marketing site last week. The build had been clean on 15, the runtime was fine in dev, and the first production build under 16.2 came back with 47 type errors in components that had not been touched. All of them on <Link> and router.push() calls with dynamic hrefs:

Type error: Argument of type 'string' is not assignable to parameter of type 'Route'.

  24 |     <Link href={`/blog/${post.slug}`}>
     |                ^^^^^^^^^^^^^^^^^^^^^

The site has experimental.typedRoutes enabled (it has been on the project since 14, no one had questioned it). On 15 the validator accepted template literals as long as the prefix matched a known static or dynamic route segment. On 16 the same template literals were rejected because the new validator only resolves literal string types and template literal types whose substitutions are statically known.

Every dynamic blog link, every product card, every breadcrumb that built the href from an id was broken. The build failed before any page rendered. Rolling back to 15 to ship a hotfix bought a day. The actual fix took an hour once I understood what changed.

Why It Happens

In Next.js 15, experimental.typedRoutes generated a .next/types/routes.d.ts file that exported a Route union of every static path plus a ${string} template per dynamic segment. So /blog/${string} was a valid member of the union and a template literal of type \/blog/$`` widened into it. Loose, but it compiled.

In 16.2 the implementation tightened. The generated Route type is now a precise union of:

  • Every static path as a literal ('/about', '/pricing')
  • Every dynamic path as a template literal type with explicit substitution constraints (\/blog/$`, `/products/$/$``)

The TypeScript checker now requires the substituted value in your template literal to be statically resolvable. A literal string works (\/blog/hello`widens correctly). Astringvariable does not, becausestringis too wide and TS cannot prove the result fits a specific dynamic route shape. Template literals built frompost.slug: string` therefore fall through the union check.

This is intentional. The whole point of typed routes is to catch typos before they hit production. A string variable could be '/admin/delete-everything' for all the checker knows; the new behaviour is "if you want a dynamic route, mark it explicitly so the validator stops second-guessing you." But the upgrade guide does not flag it loudly and the error message does not name the missing utility, so most teams hit a wall.

The typedRoutes documentation covers the new helper, but only if you go looking.

The Fix

Three patterns depending on how often you build hrefs from variables.

Pattern 1: Use the Route helper for known dynamic shapes. Next.js 16 exports a Route<T> generic type from next for this exact case. Import it where you build hrefs and cast through it:

import type { Route } from 'next'
import Link from 'next/link'

type Post = { slug: string; title: string }

export function BlogCard({ post }: { post: Post }) {
  const href = `/blog/${post.slug}` as Route
  return <Link href={href}>{post.title}</Link>
}

The cast tells the validator "I know this string fits a generated route shape, accept it." It is not a free pass: if you cast '/notarealroute' as Route the checker still rejects it because the string literal does not match any union member. The cast only relaxes the variable-substitution check, not the prefix check.

For pages that build a lot of hrefs, pull the cast into a helper so the assertion lives in one place:

import type { Route } from 'next'

export const routes = {
  post: (slug: string) => `/blog/${slug}` as Route,
  product: (category: string, id: string) =>
    `/products/${category}/${id}` as Route,
  account: (section: 'orders' | 'profile' | 'addresses') =>
    `/account/${section}` as Route,
} as const

Then call sites stay clean:

<Link href={routes.post(post.slug)}>{post.title}</Link>
<Link href={routes.product(p.category, p.id)}>{p.name}</Link>

This is the pattern I now use on every Next.js 16 project. Centralising the casts means a routing change touches one file, not 47.

Pattern 2: Use object hrefs for router.push(). The validator is stricter on the string form than the object form. If you find yourself wrestling with template literals in event handlers, switch to the segmented version:

'use client'
import { useRouter } from 'next/navigation'

export function ProductPicker({ id }: { id: string }) {
  const router = useRouter()

  return (
    <button
      onClick={() => router.push({ pathname: '/products/[id]', query: { id } })}
    >
      View
    </button>
  )
}

The pathname is a literal string matching the file path of the dynamic route. The query object provides the substitution. TypeScript can validate this against the generated route table without any casts.

Pattern 3: Disable typedRoutes if the site is mostly dynamic. Not every project benefits from this feature. For a heavily content-driven site where every link is built from data, the value of catching typos is low and the noise is high. Drop the flag in next.config.ts:

import type { NextConfig } from 'next'

const config: NextConfig = {
  experimental: {
    typedRoutes: false,
  },
}

export default config

The build will accept any string href again. You lose compile-time route validation, but if 80% of your links were already casts, you were not getting that protection in the first place.

Verify after the migration. Run tsc --noEmit after the changes and make sure no Route casts remain in components that should be using routes.* helpers. Then run next build and confirm there are no warnings about unrecognised pathnames. A common mistake is to cast a typo as Route and ship a 404 link.

The Lesson

experimental.typedRoutes in Next.js 16 went from permissive to strict, and template literals with variable substitutions no longer slip through. Cast through Route when you build hrefs from data, prefer the object form for router.push(), or turn the flag off if the protection is not worth the friction. Either way, the build error is telling you the validator finally does what it says on the tin.

If a Next.js 16 upgrade is jammed on a wall of type errors and you need somebody to clear them without rewriting half the components, that is what I do for a living. See my services. For another upgrade gotcha that bit the same week, read Next.js 16 unstable_cache migration errors.

Stuck on a Next.js 16 upgrade with a wall of type errors? Get it shipped.

Back to blogStart a project