Skip to content

fix: replace typeof window with typeof document for Node.js 25+ SSR compatibility - #393

Open
EduardF1 wants to merge 1 commit into
pacocoursey:mainfrom
EduardF1:fix/node25-window-ssr-guard
Open

fix: replace typeof window with typeof document for Node.js 25+ SSR compatibility#393
EduardF1 wants to merge 1 commit into
pacocoursey:mainfrom
EduardF1:fix/node25-window-ssr-guard

Conversation

@EduardF1

Copy link
Copy Markdown

Problem

Node.js 25+ ships an experimental Web Storage API that aliases window to globalThis. This breaks next-themes on Node.js 25+ because the SSR guard evaluates at module load time:

const isServer = typeof window === 'undefined'
// Node.js 25: typeof window → "object" (not "undefined") ❌

This causes:

  • isServer = false on the server
  • getTheme() calls localStorage.getItem() on the server
  • ThemeScript nonce rendered empty during SSR
  • TypeError: localStorage.getItem is not a function on startup

Reported in #389.

Fix

Replace typeof window === 'undefined' with typeof document === 'undefined'.

document is a browser-only API — Node.js does not expose it, even in v25+. This is a reliable cross-version SSR guard:

const isServer = typeof document === 'undefined'
// Node.js 25: typeof document → "undefined" ✅
// Browser: typeof document → "object" ✅

Changes

  • next-themes/src/index.tsx — 2 occurrences updated

Testing

# Existing build still passes
pnpm build

# On Node.js 25: isServer correctly evaluates to true
node -e "console.log(typeof document === 'undefined')"  # true ✅
node -e "console.log(typeof window === 'undefined')"    # false ❌ (Node 25+)

Tested on Node.js v24.15.0, pnpm v10.32.1.

@EduardF1
EduardF1 force-pushed the fix/node25-window-ssr-guard branch from 9a159e3 to 841f74d Compare April 30, 2026 17:58
@EduardF1

Copy link
Copy Markdown
Author

Summary

Swaps typeof window for typeof document in the SSR guard, restoring Node.js 25+ compatibility where window is exposed on globalThis but document remains undefined in SSR contexts — one-line fix, no API surface change.

Status

  • CI: ✅ Both Playwright and unit tests passing
  • No unresolved review threads
  • Ready for review

@pacocoursey happy to add more tests or adjust anything if needed. Thanks!

@EduardF1

EduardF1 commented Jun 4, 2026

Copy link
Copy Markdown
Author

Friendly bump on this fix for Node.js 25+ compatibility. CI is green and it's ready whenever you have time. Thank you!

@EduardF1

EduardF1 commented Jun 4, 2026

Copy link
Copy Markdown
Author

Hi! Just checking in on this — happy to address any feedback. Thanks!

@EduardF1

Copy link
Copy Markdown
Author

Pushed a deterministic regression test (__tests__/ssr-node25.test.tsx, node vitest environment) that recreates the Node.js 25 windowglobalThis alias with a throwing localStorage.getItem. It asserts ThemeProvider renders to static markup without throwing and never reads localStorage on the server. The test fails on the previous typeof window guard and passes with the typeof document guard, locking in the fix against regressions. Full suite: 31 passed.

Node.js 25 exposes window as an alias for globalThis, which makes the previous typeof window SSR guard take the browser path during server rendering.

Use typeof document instead and cover the Node.js 25 server case with a regression test that verifies server rendering does not read localStorage.

Fixes pacocoursey#389
@EduardF1
EduardF1 force-pushed the fix/node25-window-ssr-guard branch from 999163f to e9e6e07 Compare June 30, 2026 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant