The Problem
Upgraded a client dashboard from Next.js 15.4 to 16.2 this week. The dev server started, the routes loaded, the data fetched. Then next build blew up with around forty type errors that all looked like this:
./components/PostList.tsx:24:9
Type error: Type 'string' is not assignable to type
'Route<`/blog/${string}`> | UrlObject'.
22 | <li key={post.slug}>
23 | <Link
> 24 | href={`/blog/${post.slug}`}
| ^^^^
25 | >
Every Link that built its href from a template string with an interpolation was suddenly red. Hard-coded strings like <Link href="/about"> still compiled. Any href={/blog/$} or href={/products/$/edit} or href={pathFromDb} did not.
The project had typedRoutes turned on in next.config.ts since 15.0. Nothing about the routing structure or the components had changed in the upgrade. The same code that built green on 15.4 was broken on 16.2.
Why It Happens
Next.js 13 shipped typedRoutes as a generated declaration file that gave you a literal-union type for every static route in your app folder, with dynamic segments represented as template literal types like `/blog/${string}`. Up to 15.4, the Link component's href prop was typed as Route<string> | UrlObject, which is permissive: any template literal string narrows to string, and the prop took it.
In 16.0 the prop was retyped as Route | UrlObject, where Route is the strict union of all known concrete routes. Template literal types still match, but only when the literal type matches a generated route pattern exactly. A plain string in the interpolation slot loses the connection. `/blog/${post.slug}` where post.slug is string no longer narrows to `/blog/${string}` automatically because TypeScript widens it to string first.
That was a deliberate tightening. The whole point of typedRoutes is that you cannot accidentally Link to a route that does not exist. Under the looser 15.x typing you could write <Link href={/blgo/$}> with a typo and it would compile. Under 16.x the strict type catches that. The cost is that legitimate dynamic links need to be explicit about which route pattern they belong to.
The typedRoutes reference covers the new behaviour, but only the part where you write a literal route. The migration path for genuinely dynamic hrefs sits a couple of paragraphs down and is easy to miss.
The Fix
Three patterns, depending on how dynamic the route is. Pick the lightest one that works for the call site.
Pattern 1: Cast through the Route type when the segment is genuinely dynamic. This is the simplest fix and it preserves enough safety to catch a typo in the literal prefix:
import Link, { type Route } from 'next/link';
type Post = { slug: string; title: string };
export function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}` as Route}>
{post.title}
</Link>
</li>
))}
</ul>
);
}
The cast tells TypeScript that the resulting string is a valid route. The prefix is still checked: if you mistype `/blgo/${slug}`, you get a type error because there is no generated route matching that pattern. Only the dynamic segment value escapes type checking, which is the correct trade-off because that value is data.
Pattern 2: Centralise route construction in a typed helper. When you build the same dynamic route in five components, the cast gets noisy. Wrap it once:
import { type Route } from 'next/link';
export const routes = {
post: (slug: string) => `/blog/${slug}` as Route,
productEdit: (id: string) => `/products/${id}/edit` as Route,
userProfile: (handle: string) => `/u/${handle}` as Route,
};
Then the components stop knowing about the URL shape:
import Link from 'next/link';
import { routes } from '@/lib/routes';
<Link href={routes.post(post.slug)}>{post.title}</Link>
If a route pattern changes, you update the helper and every call site moves with it. This is also where a server component can validate the slug shape before constructing the href.
Pattern 3: Use UrlObject when the segment comes from a value you cannot pre-validate. For hrefs that are fully data-driven (a CMS field, a redirect URL from the server), bypass the route type entirely with the second half of the union:
import Link from 'next/link';
export function CmsLink({ url }: { url: string }) {
return (
<Link href={{ pathname: url }}>
{url}
</Link>
);
}
The UrlObject form does not get checked against the generated routes, which is correct here because the URL came from outside the type system. Reserve this for the genuinely external case, not as a way to silence the checker everywhere.
One more thing: regenerate the route types after the upgrade. The .next/types folder is stale until the dev server has reread app. Wipe it before running tsc:
rm -rf .next/types
pnpm next dev --turbopack
# wait for "Ready", then in another terminal:
pnpm tsc --noEmit
If the type errors persist with the same call signatures after the regeneration, the route really is missing. If they go away on hard-coded routes and only remain on template strings, you have the bug described above and one of the three patterns will fix it.
The Lesson
typedRoutes got stricter in 16.0 on purpose. Template strings that used to widen silently now fail the build, which is the protection working as intended. Cast to Route for one-off dynamic hrefs, wrap into a typed helper when the same route gets built in many places, and fall back to UrlObject only when the URL is genuinely external. The upgrade noise is mechanical and the fixes survive future tightening.
If your Next.js 16 upgrade is sitting on hundreds of Link type errors and the build pipeline is blocked, that is the kind of triage I take on. See my services. For another typing gotcha from the same upgrade, read Next.js 16 searchParams Promise TypeError.
Stuck on a Next.js 16 type-check that will not pass? Get the build green.
