next/root-params emits no types when an app has two root layouts and one serves "/"
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 142k
- Forks
- 32.4k
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 351
Description
Link to the code that reproduces this issue
https://github.com/CHC383/nextjs-root-params-two-root-layouts-repro
To Reproduce
The repro has two root layouts, siblings rather than nested:
app/(marketing)/layout.tsx serves "/", no dynamic segment
app/[locale]/layout.tsx serves "/[locale]/*", `locale` is a root param
- npm install
- npx next typegen
- cat .next/types/root-params.d.ts => "// No root params detected." and
export {}
Now remove the layout that serves "/":
- mv "app/(marketing)" ../hidden && npx next typegen
- cat .next/types/root-params.d.ts => export function locale(): Promise
Put it back and the type disappears again.
Current vs. Expected behavior
I expected locale to be typed, since it is a root param of
app/[locale]/layout.tsx and resolves fine at runtime. Instead typegen writes
"No root params detected", and because next ships a shorthand ambient
declaration for the module, next/root-params silently becomes any. This
compiles with no error under tsc --strict:
import * as rootParams from "next/root-params";
export async function probe(): Promise<number> {
return await rootParams.locale(); // locale() is `any`
}
Adding my own declare module "next/root-params" doesn't help — the shipped
declaration is a resolved file, so it wins over an ambient one. tsc accepts the
local declaration while the module still resolves to any. With
typescript-eslint's strict type-checked rules the call reports no-unsafe-call,
so the only options left are suppressing the rule or dropping the strict rules.
Runtime is unaffected, which is what makes this easy to miss. next build on
the repro prerenders /en and /de, and each page renders the correct value:
grep -o 'root param at runtime:.\{0,20\}' .next/server/app/en.html
=> root param at runtime: <!-- -->en
Since "/" has no locale segment, I'd expect
locale(): Promise<string | undefined> rather than nothing at all.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.6.0: Sat Jul 11 15:26:21 PDT 2026; root:xnu-12377.161.13~4/RELEASE_ARM64_T6000
Available memory (MB): 16384
Available CPU cores: 10
Binaries:
Node: 24.19.0
npm: 12.0.2
Yarn: 1.22.22
pnpm: 10.33.2
Relevant Packages:
next: 16.3.1-canary.3 // Latest available version is detected (16.3.1-canary.3).
eslint-config-next: N/A
react: 19.2.8
react-dom: 19.2.8
typescript: 5.9.3
Next.js Config:
output: N/A
Which area(s) are affected? (Select all that apply)
Route Groups, TypeScript
Which stage(s) are affected? (Select all that apply)
next build (local), next dev (local)
Additional context
Hit this on 16.3.0 in a real app; the repro pins next@canary and still fails on
16.3.1-canary.3.
It looks like the root-layout detection in
packages/next/src/server/lib/router-utils/route-types-utils.ts,
collectRootParamsFromLayouts:
const rootLayoutRoutes = routes.filter(
(route) =>
!routes.some(
(other) =>
other !== route && (other === '/' || route.startsWith(other + '/'))
)
)
app/(marketing)/layout.tsx normalizes to "/", so the other === '/' clause
treats it as an ancestor of "/[locale]" and that layout contributes no root
params. They're siblings, though. The predicate compares route strings, which
have already had the route group stripped and so no longer carry the nesting
information needed to tell an ancestor from a sibling — manifest.layoutRoutes
does keep a filePath per layout, which would.
The runtime doesn't share the bug, which is why only types break:
next-root-params-loader's findRootLayouts walks the app directory and stops at
the first layout per branch, so it finds both roots and collects locale.
Two root layouts with one serving "/" is how you split a localized subtree from
an unprefixed marketing or legal tree, so this isn't an exotic shape. It also
fails open rather than loudly — the import keeps working, just unchecked.
Claude Opus 5 helped with the investigation and built the linked reproduction.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/next/src/server/lib/router-utils/route-types-utils.ts at collectRootParamsFromLayouts, then run the linked reproduction with npx next typegen and inspect .next/types/root-params.d.ts. Verify that sibling root layouts, including one normalized to "/", preserve locale and generate locale(): Promise<string | undefined> rather than the no-params declaration, without changing runtime behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, next.js, typescript
- Domain
- build-system, developer-experience, web-dev
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100