The Problem
Pulled a client project forward from Next.js 16.1 to 16.3 yesterday morning for a routing-related fix. Build had been clean for weeks. The first post-upgrade next build threw on a <Link> that pointed to a catch-all docs route I had not touched:
./components/docs-nav.tsx:34:13
Type error: Type '{ pathname: "/docs/[...slug]"; params: { slug: string[] } }'
is not assignable to type 'Route<string>'.
Type '{ pathname: ...; params: ...; }' is not assignable to type
'{ pathname: "/docs/[...slug]"; params: { slug: [string, ...string[]] } }'.
Types of property 'params.slug' are incompatible.
Type 'string[]' is not assignable to type '[string, ...string[]]'.
32 | <Link
33 | href={{
> 34 | pathname: '/docs/[...slug]',
| ^
35 | params: { slug: pathSegments },
Same TypeScript that built fine in 16.1. We had not touched next.config.ts, had not touched the route file, had not changed the navigation component. Just the patch upgrade. The error reproduced on every catch-all route in the project: /docs/[...slug], /blog/[...slug], /api/[...path], all of them. Optional catch-alls (/shop/[[...filters]]) compiled fine.
The Vercel build failed the same way. Reverting to 16.1 made it green again.
Why It Happens
Next.js 16.3 tightened the generated Route type for required catch-all dynamic segments. The old definition treated slug as string[], which matched any array including an empty one. The new definition uses [string, ...string[]], the tuple form that says "at least one element". That correctly reflects the runtime: a request to /docs does not match /docs/[...slug] — only /docs/getting-started and deeper paths do. An empty array can never be the value of a required catch-all segment, so the type should never have allowed it.
The motivation is sound. Code that passed an empty array used to silently produce a malformed link to /docs/, which then 404'd. The new type catches that at build. But every codebase that builds a path-segment array by split('/').filter(Boolean) is now feeding a string[] into a slot that wants a non-empty tuple, and TypeScript correctly refuses. Optional catch-alls were not touched because they already accept the empty case at the route level.
The typedRoutes reference covers the new shape, but most upgrade guides skim past it. If your codebase had typedRoutes enabled (it became stable in 16.0 and defaults to on in 16.3), you are exposed across every catch-all <Link>, redirect(), and useRouter().push() call in the project.
The other knock-on is redirect() inside Server Actions. The same Route type guards the argument, so any redirect that builds a catch-all destination from a dynamic array fails at build, not at runtime where you might otherwise notice it.
The Fix
Assert non-emptiness at the boundary where you build the array, not by casting at the call site. A small helper makes the intent obvious and gives you a single place to handle the empty case.
// lib/route-helpers.ts
type NonEmpty<T> = [T, ...T[]]
export function nonEmptySegments(input: string): NonEmpty<string> | null {
const parts = input.split('/').filter(Boolean)
const [first, ...rest] = parts
return first !== undefined ? [first, ...rest] : null
}
The destructuring lets TypeScript narrow first to string and the spread becomes the tail, so the return type is a real [string, ...string[]] without an as assertion. Use it at every <Link> and redirect() that targets a catch-all route, and fall back to the parent index route when the segments are empty.
import Link from 'next/link'
import { nonEmptySegments } from '@/lib/route-helpers'
export function DocsNav({ pathname }: { pathname: string }) {
const slug = nonEmptySegments(pathname.replace(/^\/docs\//, ''))
if (!slug) {
return <Link href="/docs">Docs home</Link>
}
return (
<Link
href={{
pathname: '/docs/[...slug]',
params: { slug },
}}
>
Current page
</Link>
)
}
For server-side redirects in Server Actions, wrap redirect() the same way. The empty case redirects to the parent route, which is a different route in the typed router and needs its own page.tsx:
'use server'
import { redirect } from 'next/navigation'
import { nonEmptySegments } from '@/lib/route-helpers'
export async function goToDocs(input: string) {
const slug = nonEmptySegments(input)
if (!slug) {
redirect('/docs')
}
redirect({ pathname: '/docs/[...slug]', params: { slug } })
}
If you have a hundred call sites and need to ship today, the escape hatch is to wrap legacy code in a typed adapter rather than scatter as assertions:
import type { Route } from 'next'
export function legacyCatchAllHref(
prefix: '/docs' | '/blog' | '/api',
segments: string[],
): Route<string> | '/docs' | '/blog' | '/api' {
const slug = nonEmptySegments(segments.join('/'))
if (!slug) return prefix
return { pathname: `${prefix}/[...slug]`, params: { slug } } as Route<string>
}
The cast lives in one place. Every legacy call site goes through it, and you can grep for legacyCatchAllHref to find the remaining sites that need a proper non-empty pass through the rest of the codebase.
After the changes, run a full type check and a production build. The build catches <Link> props, but the type check catches utility functions that construct route hrefs and pass them through as const:
pnpm tsc --noEmit
pnpm next build
One last gotcha. If you import Route directly from next to type a function's return value, the import resolves to a different type alias in 16.3 than it did in 16.2. The shape is compatible but the generic parameter is now required, so signatures like Route become Route<string> everywhere. Use a codemod or a single find-and-replace across the project.
The Lesson
Next.js 16.3 made required catch-all segments demand at least one element at the type level. The runtime behaviour did not change; the type finally tells the truth. Build a small helper that returns NonEmpty<string> | null, handle the empty case as a redirect to the parent index route, and the build goes green. Avoid the temptation to scatter as assertions at every call site, because the cast hides the bug class the upgrade is trying to surface.
If your Next.js 16 upgrade is stuck on typedRoutes errors across a routing-heavy app, that is exactly the kind of work I get paid to clear. See my services. For another routing change from this release cycle, read Next.js 16 async params TypeError on dynamic routes.
Stuck on a Next.js 16 typedRoutes upgrade? Get it shipped.