Skip to content

React "unique key prop" warning from ClerkProvider's own children (App Router) #9415

Description

@ant1m4tt3r

Preliminary Checks

Reproduction

https://github.com/ant1m4tt3r/clerk-dynamic-key-warning

Publishable key

pk_test_ZGVzdGluZWQtYXBlLTQwLmNsZXJrLmFjY291bnRzLmRldiQ

Description

Hi! This issue started popping on recently in my app and I dug in a little to find out what caused it.

TLDR: a console warning shows in development caused by a React dev-only key check that misfires on Clerk's provider when RSC streams its children as separate chunks. The reported behavior also does not seem to cause any bugs, just a minor discomfort in development as it appears to be an application side bug.

The content below was generated by AI, but I've verified everything that was written and it appears to be correct.

Rendering <ClerkProvider> from a Server Component root layout with a large prop (the documented localization prop) makes React log this on every page load:

Each child in a list should have a unique "key" prop.
Check the render method of __experimental_CheckoutProvider. It was passed a child from ClerkProvider.

Nothing in the app renders a list — the array is created inside Clerk.

Steps to reproduce:

  1. Clone the reproduction, npm install, put a pk_test_... in .env.local, npm run dev
  2. Open http://localhost:3000 and look at the browser console → the warning is logged
  3. In app/layout.tsx, swap localization={ptBR} for a small object such as {{ signIn: { start: { title: "Hi" } } }}, reload → the console is clean

No clerkMiddleware and no secret key are involved; the warning is independent of auth state. The React Compiler is not required.

Expected behavior: no React key warnings from Clerk internals.

Actual behavior: the warning above on every page load, pointing at a list the application does not have.

Root cause

NextClientClerkProvider (app-router/client/ClerkProvider.js) renders three static sibling children:

React.createElement(ReactClerkProvider, { ...mergedProps },
  React.createElement(RouterTelemetry, null),
  __internal_scriptsSlot != null ? __internal_scriptsSlot : React.createElement(ClerkScripts, null),
  children
)

That array is forwarded as a single props.children through ClerkProviderBaseClerkContextProvider__experimental_CheckoutProvider (@clerk/shared/react), where React reconciles it as an array and key-checks each entry.

Static siblings never need keys, which is why this normally warns about nothing — React enforces that rule by marking each positional child as validated. What the prop size changes is whether that mark lands where the reconciler later looks for it. With a large prop, React outlines the children into separate flight rows — the same element, serialized both ways:

// small prop — one 1.5 KB row, children inlined as elements
"__internal_scriptsSlot": ["$","$36",null,{…},"$1c","$5a",0],
"children":               ["$","$L5f",null,{…},null,"$5e",1]

// localization={ptBR} — one 74 KB row, children outlined
"__internal_scriptsSlot": "$L5a",
"children":               "$L5b"

A $L<id> deserializes to a lazy node, not an element (the trailing field is React's validated flag — the scripts slot ships as 0). validateChildKeys in react.development.js can only mark a lazy that has already resolved:

isValidElement(node)
  ? node._store && (node._store.validated = 1)        // inlined element → marked
  : node.$$typeof === REACT_LAZY_TYPE &&
    ("fulfilled" === node._payload.status
      ? /* mark the resolved element */
      : node._store && (node._store.validated = 1));  // pending → marks the WRAPPER

At createElement time the outlined rows have not arrived, so the mark lands on the wrapper. The reconciler later does case REACT_LAZY_TYPE: (child = resolveLazy(child)), warnOnInvalidKey(...), finds validated: 0 on the resolved scripts slot with no key, and warns — naming the reconciling parent (__experimental_CheckoutProvider) and the child's owner (ClerkProvider).

Suggested fix

Key the children where they are created, e.g. in app-router/server/ClerkProvider.js:

const scriptsSlot = dynamic ? (
  <Suspense key="clerk-scripts"><DynamicClerkScripts … /></Suspense>
) : undefined;

and/or key the three children in NextClientClerkProvider. Keys make the array immune regardless of how the payload is chunked.

Version notes

Reproduced on @clerk/nextjs 7.7.4 (latest at time of filing). On 7.4.0 it additionally required the dynamic prop; on 7.7.4 the large prop alone is enough. Development-only (React strips key validation in production), but it fires on every page load and reads like an application bug.

Possible bug origin
The scripts slot and its sibling children were introduced in #7773 (Isolate nonce fetch in Suspense boundary for PPR support), which added <Suspense> around DynamicClerkScripts in the server provider and {__internal_scriptsSlot ?? <ClerkScripts />} as one of three unkeyed siblings in the client provider.

Environment

System:
    OS: macOS 26.5
    CPU: (12) arm64 Apple M4 Pro
    Memory: 246.88 MB / 24.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 20.19.6 - /Users/antimatter/.nvm/versions/node/v20.19.6/bin/node
    Yarn: 3.2.4 - /Users/antimatter/.nvm/versions/node/v20.19.6/bin/yarn
    npm: 10.8.2 - /Users/antimatter/.nvm/versions/node/v20.19.6/bin/npm
    pnpm: 8.10.5 - /Users/antimatter/.nvm/versions/node/v20.19.6/bin/pnpm
    bun: 1.2.19 - /Users/antimatter/.bun/bin/bun
    Deno: 2.1.3 - /opt/homebrew/bin/deno
  Browsers:
    Chrome: 151.0.7922.109
    Firefox: 125.0.3
    Safari: 26.5
  npmPackages:
    @clerk/localizations: ^4.15.1 => 4.15.1
    @clerk/nextjs: ^7.6.4 => 7.7.4
    @types/node: ^20 => 20.19.43
    @types/react: ^19 => 19.2.18
    @types/react-dom: ^19 => 19.2.4
    next: 16.3.0 => 16.3.0
    react: 19.2.6 => 19.2.6
    react-dom: 19.2.6 => 19.2.6
    typescript: ^5 => 5.9.3

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageA ticket that needs to be triaged by a team member

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions