Next.js 16 typedRoutes Link href Type Error Fix

Next.js 16 build failing with 'Type string is not assignable to type Route' on Link href? Here is why typedRoutes got stricter and the migration that compiles.
Next.jsTypeScriptApp Router
May 27, 20266 min read1124 words

The Problem

Upgraded a client dashboard from Next.js 15.4 to 16.2 last Friday. Dev server ran fine. The Vercel build failed in the first TypeScript pass with dozens of variations of this:

./components/order-row.tsx:34:13
Type error: Type 'string' is not assignable to type 'Route'.

  32 |       <Link
  33 |         href={`/orders/${order.id}`}
> 34 |         className="block py-2"
     |             ^
  35 |       >

Same code shipped to production untouched a week earlier. The only change was the upgrade and a bump from experimental.typedRoutes to the stable top-level typedRoutes: true in next.config.ts. Every dynamic href built with a template literal — and the codebase had a few hundred of them — now refused to typecheck.

A second flavour of the same error fired on object hrefs we use for filtered list pages:

Type '{ pathname: string; query: { status: string; } }' 
is not assignable to type 'Route'.
  Property 'pathname' is incompatible: 
  Type 'string' is not assignable to type '/orders' | '/products' | ...

The first error was about template literals. The second was about object hrefs no longer accepting arbitrary pathname strings. Both came from the same tighter type definition that landed when typedRoutes graduated from experimental.

Why It Happens

typedRoutes in 15.x was opt-in and lenient. The href prop on Link accepted string as a fallback for anything the compiler could not statically resolve, including any template literal that contained an interpolated expression. Object hrefs accepted any pathname that started with /. Both relaxations existed because the feature was still being iterated on.

In 16, typedRoutes is on by default for new projects and the type definition is strict. Route is now a discriminated union of every literal pathname the app router can statically discover, including the dynamic segment placeholder form: '/orders/[orderId]'. A template literal like /orders/${order.id} is typed as string, not as '/orders/[orderId]', so it no longer satisfies Route. Object hrefs require pathname to be one of the same literal members of the union.

The App Router typedRoutes documentation explains the new model, but most teams hit the errors first. The intent is to catch typos and broken links at compile time. The cost is that every dynamic href has to be cast or constructed through a helper that the type system can follow.

There is also a quieter change worth knowing. Catch-all and optional catch-all segments ([...slug] and [[...slug]]) generate union members that include ${string} template literal types, which behave like a partial type guard. A href such as /docs/${slug} will typecheck against a [...slug] route only if slug is typed as string and the literal prefix matches. Untyped values, or interpolations into a route that has a different dynamic shape, will still fail.

The Fix

Three patterns. One for the common dynamic-segment case, one for object hrefs, one to keep the type system honest when you are intentionally building external or computed links.

Pattern 1: Use the dynamic-segment literal as the type. The Route union includes the bracketed form of every dynamic path, so you can construct a typed href without casting:

import type { Route } from 'next'

// Before: template literal types as `string`, fails to compile.
<Link href={`/orders/${order.id}`}>View order</Link>

// After: assert into the specific dynamic-segment member.
<Link href={`/orders/${order.id}` as Route<'/orders/[orderId]'>}>
  View order
</Link>

The cast is narrow enough that it still catches typos in the prefix. /order/${order.id} (missing the s) does not satisfy Route<'/orders/[orderId]'> and the compiler complains. The pattern wraps cleanly into a helper for routes you hit constantly:

import type { Route } from 'next'

export const orderPath = (id: string) =>
  `/orders/${id}` as Route<'/orders/[orderId]'>

Now every call site is one keystroke and the type system tracks the literal route. Refactoring /orders/[orderId] to /orders/[id] later becomes a single rename in the helper.

Pattern 2: Move query into a typed object href. Object hrefs accept a query field, but pathname must be a literal Route. Build the pathname as a route literal first, then merge query params:

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

const href = {
  pathname: '/orders' as Route<'/orders'>,
  query: { status: 'paid', page: '2' },
}

<Link href={href}>Paid orders</Link>

The narrow Route<'/orders'> annotation is doing real work: if you mistype the pathname to /order, the build fails. The query value can still be any record of strings, because query params are not part of the routing type system.

Pattern 3: Opt out for genuinely external or computed hrefs. Some links are never going to be routes the App Router knows about: external URLs, mailto, anchors built from a CMS feed. Use a small UnsafeHref escape hatch instead of casting at every call site:

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

type UnsafeHref = Route | (string & {})

// `string & {}` keeps autocompletion for known routes while accepting any string.
export function ExternalLink({ href, children }: { href: UnsafeHref; children: React.ReactNode }) {
  return <Link href={href as Route}>{children}</Link>
}

That (string & {}) trick is the standard TypeScript pattern for "literal union or any string" — it preserves IDE suggestions for the typed routes while letting genuinely external hrefs through. Keep the escape hatch in a single component so the cast is visible in code review.

Verify with tsc --noEmit. Before pushing, run a typecheck pass that does not skip the Next.js generated types:

npx tsc --noEmit -p tsconfig.json

Any remaining Type 'string' is not assignable to type 'Route' errors are missed call sites. The fix is mechanical at that point.

The Lesson

typedRoutes becoming the default is a real upgrade — it turns a category of dead-link bugs into compiler errors — but it does demand a one-time pass across every Link you have. Cast template literals into the dynamic-segment route literal, type object hrefs the same way, and put one UnsafeHref escape hatch where external links live. After that the type system pays you back on every route refactor.

If your Next.js 16 upgrade is stuck on a forest of typedRoutes errors, that is a one-day job I take on regularly. See my services. For the related cache directive migration that hits the same upgrade window, read Next.js 16 unstable_cache migration errors.

Back to blogStart a project