SSR-Safe Thread ID Generation#
When Next.js cacheComponents: true is enabled, prerendering fails with an error like:
Next.js encountered the unstable value Math.random() in a Client Component
The crash originates in the remote thread-list runtime's initialization path, which calls generateId() synchronously during the render phase — before any effect fires — so it gets baked into the static shell. Understanding where each ID comes from and why the default approach is incompatible with prerender is the key to diagnosing and fixing these failures.
The generateId() helper#
generateId() is defined in packages/core/src/utils/id.ts using nanoid/non-secure's customAlphabet over a 62-character alphanumeric set, producing 7-character strings. It is intentionally non-cryptographic and fast, but it relies on Math.random() internally — the exact source Next.js's cacheComponents prerender rejects.
Where IDs are generated during render#
Two call sites run in the render phase (not inside effects) and therefore fire during prerender:
-
RemoteThreadListThreadListRuntimeCoreconstructor — callsswitchToNewThread()synchronously, which reachesseedNewThread()via_switchToNewThread(). This is the primary crash site. -
RemoteThreadListtap-clientuseStateinitializer — callsseedNewThread()directly during the component's first render.
seedNewThread() runs a collision-avoidance loop calling generateId() and produces a local thread ID with the format __LOCALID_<generateId()> (see LOCAL_THREAD_ID_PREFIX). The seeded slot has status: "new" and localOrigin: true, acting as a placeholder until the user sends the first message.
The proposed fix: useId() for the initial draft seed#
useId() is deterministic across server and client renders, satisfying the cacheComponents constraint and ensuring hydration consistency. The fix (tracked in issue #8683) is:
- Call
useId()inuseRemoteThreadListRuntime.tsand pass the result into runtime construction as the initial draft ID. - Retain
generateId()for all subsequent seeds (e.g., when the user creates additional threads viaswitchToNewThread()).
As of the current codebase, useId() appears in RemoteThreadListThreadListRuntimeCore only for render-component instance deduplication — not for thread ID seeding. The fix has not yet been merged.
Related fixes and the broader standardization effort#
InMemoryThreadList: Date.now() collision (PR #5764)
InMemoryThreadList previously derived new thread IDs from thread-${Date.now()}. Two switchToNewThread() calls within the same millisecond produced duplicate keys, crashing useResources. PR #5764 replaced Date.now() with generateId(). The initial "main" thread still uses a hard-coded constant, so the in-memory list is unaffected by the SSR prerender issue.
Centralizing seedNewThread (PR #6347)
PR #6347 extracted duplicated seeding logic — byte-equivalent copies existed in both RemoteThreadList.ts and RemoteThreadListThreadListRuntimeCore.tsx — into the shared seedNewThread() and createEmptyRemoteThreadState() helpers. Centralizing these makes it easier to apply the useId() fix in one place.
Standardizing generateId across adapter packages (PRs #6342, #6400)
PR #6342 replaced uuid v4 and local crypto fallbacks in react-langgraph, react-google-adk, and react-ag-ui with core's generateId(). PR #6400 did the same for react-mcp and react-opencode, adding a helper (createMcpId.ts) that prefers crypto.randomUUID() and falls back to mcp-${generateId()}.
OAuth state: secure randomness required (PR #8585)
PR #8585 is a deliberate exception to the generateId() standardization: OAuth state (a CSRF nonce) requires cryptographic entropy. The fix replaces Date.now() + Math.random() with crypto.randomUUID() → 32 bytes from crypto.getRandomValues() → hard failure. This is the right pattern wherever IDs have security implications rather than just uniqueness requirements.
Key files#
| File | Role |
|---|---|
packages/core/src/utils/id.ts | generateId() — nanoid/non-secure-based, nondeterministic |
packages/core/src/runtimes/remote-thread-list/remote-thread-state.ts | seedNewThread(), createEmptyRemoteThreadState(), LOCAL_THREAD_ID_PREFIX |
packages/core/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx | Constructor calls switchToNewThread(); useId() used for instance dedup only |
packages/core/src/react/runtimes/useRemoteThreadListRuntime.ts | Hook wrapping the runtime; candidate location for useId() call |