Next.js

Fix localStorage is not defined after 'use client' (Next.js)

The cookie banner from the Stack Overflow question, rebuilt on Next.js 16.3.8: why 'use client' does not stop the crash, the useEffect fix, and the other errors Node 25 and 26 print.

Mahdi Ben RhoumaMahdi Ben Rhouma
9 min read

TL;DR#

'use client' does not make a component browser-only. Next.js renders Client Components on the server too, to build the first HTML of the page, and that render runs in Node.js, where there is no browser storage to read. Any localStorage call in the component body runs during that render and throws: a ReferenceError on Node 24, a TypeError on Node 25 (except 25.2.0) and 26. Read the value inside useEffect, which only runs in the browser, and start from a neutral state such as null so that the server render and the first browser render agree. The question's exact component still fails this way on Next.js 16.3.8, and the effect-based version builds cleanly there.

The Stack Overflow question is a cookie-notice modal, app/components/organisms/Cookies.tsx, imported into app/page.tsx, on Next.js 13.4.1. Shortened to the lines that matter, it reads the stored choice straight into state:

tsx
'use client';
import { useState } from 'react';
 
const Cookies = () => {
  const localStorageCookies = localStorage.getItem('cookies-accepted');
  const [cookiesAccepted, setCookiesStatus] = useState<boolean>(!!localStorageCookies);
 
  const acceptCookies = () => {
    localStorage.setItem('cookies-accepted', 'true');
    setCookiesStatus(true);
  };
 
  return (
    <>
      {!cookiesAccepted && (
        <section>
          <h2>🍪 Cookie Notice</h2>
          <button onClick={acceptCookies}>Accept</button>
        </section>
      )}
    </>
  );
};
 
export default Cookies;

The asker also put Cookies.tsx outside of the app folder, which "didn't help". The full component was rebuilt unchanged on 6 October 2026, in a fresh app with Next.js 16.3.8 and React 19.3.0, inside the official node:24-alpine Docker image (Node v24.21.0). That reproduction, repeated in the node:25-alpine and node:26-alpine images for the last section, is the source of every output on this page. next build stops while generating static pages:

text
Generating static pages using 3 workers (0/3) ...
Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
ReferenceError: localStorage is not defined
    at <unknown> (app/components/organisms/Cookies.tsx:5:31)
  4 | const Cookies = () => {
> 5 |   const localStorageCookies = localStorage.getItem('cookies-accepted');
    |                               ^
Export encountered an error on /page: /, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null

In next dev, the page request fails with a 500, and the terminal shows the same error, this time naming the component:

text
⨯ ReferenceError: localStorage is not defined
    at Cookies (app/components/organisms/Cookies.tsx:5:31)
GET / 500 in 1385ms (next.js: 1191ms, application-code: 194ms)

Three major versions after the question, the message is word for word the one the asker pasted.

Why 'use client' does not keep code off the server#

'use client' marks the point where the module graph switches to client code: it decides what ships to the browser. The boundary itself is covered in use client vs use server. What the directive does not do is turn off server rendering. The Next.js guide to the server and client boundary says so directly: "On a direct visit, the following Client Component renders on the server and again in the browser during hydration." Its table answers yes for a Client Component in both columns, on the server and in the browser, and adds: "Rendering a Client Component on the server produces HTML, but the component remains a Client Component." The accepted answer on the question quotes the documentation of the time saying the same thing: Next.js renders "a static HTML preview on the server for both Client and Server Components".

That server render is ordinary JavaScript running in Node.js. In a plain Node 24 process, typeof localStorage is undefined, and calling getItem on it throws the exact error from the question:

text
$ node -e "console.log(typeof localStorage)"
undefined
$ node -e "localStorage.getItem('cookies-accepted')"
ReferenceError: localStorage is not defined

Line 5 of the component sits in the component body, so it runs on every render, the server one included. When that server render happens depends on the command:

  • A route with nothing request-specific is prerendered by next build, so the build fails at the "Generating static pages" step.
  • next dev renders the page when it is requested, so the request fails with a 500 instead.

Moving the file changes neither. Repeating the asker's attempt, with the component moved to components/organisms/Cookies.tsx at the project root and the import updated, gives the same error from the new path:

text
ReferenceError: localStorage is not defined
    at <unknown> (components/organisms/Cookies.tsx:5:31)

What matters is when a line runs, not where its file lives. The component body runs during every render. Effects and event handlers do not run during the server render.

Fix: read localStorage in an effect, not in the render#

The accepted answer's fix still holds on Next.js 16. It makes four moves:

  • the state starts as null, meaning "not read yet", so the server render and the first browser render produce the same output;
  • the read moves into useEffect, and React's useEffect reference states that "Effects only run on the client. They don't run during server rendering.";
  • the write stays in the click handler, which can only run after a click in the browser;
  • the component renders nothing while the value is null, so returning visitors never see the banner flash.

1. Replace the component#

tsx
'use client';
 
import { useEffect, useState } from 'react';
 
const Cookies = () => {
  const [cookiesAccepted, setCookiesStatus] = useState<boolean | null>(null);
 
  const acceptCookies = () => {
    localStorage.setItem('cookies-accepted', 'true');
    setCookiesStatus(true);
  };
 
  useEffect(() => {
    const localStorageCookies = localStorage.getItem('cookies-accepted');
    setCookiesStatus(!!localStorageCookies);
  }, []);
 
  if (cookiesAccepted === null || cookiesAccepted) {
    return null;
  }
 
  return (
    <section className="fixed max-w-md p-4 mx-auto bg-white border border-gray-200 left-12 bottom-16 rounded-2xl z-20">
      <h2 className="font-semibold text-gray-800">🍪 Cookie Notice</h2>
      <p className="mt-4 text-sm text-gray-600">
        We use cookies to ensure that we give you the best experience on our website.
      </p>
      <button
        className="text-xs bg-gray-900 font-medium rounded-lg text-white px-4 py-2.5"
        onClick={acceptCookies}
      >
        Accept
      </button>
    </section>
  );
};
 
export default Cookies;

This is the exact file that was built in the reproduction. Keep your own markup in place of the shortened banner; only the state, the effect and the early return matter.

2. Rebuild from a clean .next folder#

bash
rm -rf .next
npx next build

Verify: the build passes and the banner is not in the HTML#

The build now gets past the step that failed:

text
✓ Generating static pages using 3 workers (3/3) in 127ms
Route (app)
┌ ○ /
○  (Static)  prerendered as static content

Then check what the server rendered. The banner must not be in the prerendered HTML, because the server rendered the null state:

bash
grep -c 'Cookie Notice' .next/server/app/index.html
text
0

The banner only appears in the browser, once the effect has read storage and found no key. To check that half, open the page in a private window, where the banner should show; click Accept and reload, and it should stay hidden, because the effect now reads 'true'. The same file also built cleanly under the node:25-alpine and node:26-alpine images, where the original component fails with different messages (see the last section).

ssr: false must be called from a Client Component#

The other common answer is to stop Next.js from rendering the component on the server at all, with next/dynamic. The lazy loading guide documents the option ("If you want to disable prerendering for a Client Component, you can use the ssr option set to false") and its limit: it "is not supported in Server Components". app/page.tsx is a Server Component by default, so the obvious place to put it fails the build:

tsx
import dynamic from 'next/dynamic';
 
const Cookies = dynamic(() => import('./components/organisms/Cookies'), { ssr: false });
 
export default function Home() {
  return (
    <main>
      <h1>Home</h1>
      <Cookies />
    </main>
  );
}
text
./app/page.tsx:3:17
Error: `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component.

Move the dynamic import into a small Client Component, app/components/organisms/CookiesNoSSR.tsx, and render that from the page. The original component, localStorage call included, stays as it was:

tsx
'use client';
 
import dynamic from 'next/dynamic';
 
const Cookies = dynamic(() => import('./Cookies'), { ssr: false });
 
export default function CookiesNoSSR() {
  return <Cookies />;
}

With app/page.tsx rendering <CookiesNoSSR />, the build passes and grep -c 'Cookie Notice' prints 0 again. That empty server HTML is what the option is for, since the guide presents it as the way to "disable prerendering for a Client Component", and it is also its cost: the banner exists only after the browser has loaded and run the component. That suits a cookie banner. It does not suit content you want indexed, because, as the boundary guide notes, a crawler that reads only HTML "runs none of your JavaScript".

A typeof window check trades this error for a hydration error#

The quickest-looking fix is a guard in the body:

tsx
const localStorageCookies =
  typeof window !== 'undefined' ? localStorage.getItem('cookies-accepted') : null;

It stops the crash, and the build passes. The prerendered HTML tells the rest of the story: grep -c 'Cookie Notice' now prints 1. On the server the guard returns null, so the banner is rendered into the HTML for every visitor. For a visitor who accepted earlier, the first render in the browser reads 'true', renders nothing, and no longer matches that HTML. React's hydrateRoot reference says it "expects the rendered content to be identical with the server-rendered content. You should treat mismatches as bugs and fix them."

React even names this pattern in its own error. The development build of react-dom shipped inside Next.js 16.3.8 lists "A server/client branch if (typeof window !== 'undefined')" first among the causes of "Hydration failed because the server rendered HTML didn't match the client". The server half was checked in the build; the browser mismatch itself was not run here. If your error has already turned into that hydration message, continue with the hydration mismatch fixes: its localStorage section uses the same effect pattern and turns it into a reusable hook.

force-dynamic only moves the crash to request time#

The build error mentions prerendering, so export const dynamic = 'force-dynamic' on the page looks like a way out. It is not one. With it, next build succeeds and lists the route as ƒ /, "server-rendered on demand", but the Client Component still renders on the server, now on every request. next start answers the first request with a 500:

text
⨯ ReferenceError: localStorage is not defined
    at <unknown> (.next/server/chunks/ssr/app_components_organisms_Cookies_tsx_1fam6_a._.js:1:97) {

force-dynamic is the switch for a different build error, Dynamic server usage, where a route reads request data such as cookies() or headers(). It does nothing for code that needs a browser.

Node 25 and 26 print a different error for the same bug#

Node.js 25 turned its Web Storage API on by default, according to the changelog in the Node.js localStorage documentation. The global now exists during the server render, but with no storage file behind it, so the message depends on the Node version that runs next build or next dev. The same app, with the question's component, in three official images:

Image (Node version)What next build prints
node:24-alpine (v24.21.0)ReferenceError: localStorage is not defined
node:25-alpine (v25.9.0)TypeError: localStorage.getItem is not a function, after the warning "--localstorage-file was provided without a valid path"
node:26-alpine (v26.10.0)TypeError: Cannot read properties of undefined (reading 'getItem'), after "ExperimentalWarning: localStorage is not available because --localstorage-file was not provided."

Two consequences follow.

  • A typeof localStorage !== 'undefined' guard is no guard on Node 25. On Node 25 (except 25.2.0), typeof localStorage is "object", and on v25.9.0 the build with that guard failed with the same TypeError; on 25.2.0 the typeof check itself throws (next paragraph). On Node 26.10.0 the condition is false (with the warning), the build passes, and the banner lands in the server HTML: the typeof window trap again.
  • Passing --localstorage-file to the server is not a fix either. That file is the server's storage, not the visitor's, and the documentation warns that "localStorage data is not stored per user or per request when used in the context of a server, it is shared across all users and requests." The server render would read that shared file, never the visitor's browser storage, so the two renders would still disagree for anyone who accepted.

One Node 25 release behaves differently. PR #60351 made the getter throw when no storage file is set, and the Node 25 release notes list it in 25.2.0 and its revert in 25.2.1. In the v25.2.0 source, the getter throws a SecurityError, "Cannot initialize local storage without a --localstorage-file path", and a plain typeof localStorage triggers it too, because it calls the getter; a probe of the node:25.2.0-alpine image shows both. On that release, the server render would stop on this SecurityError rather than on a TypeError.

On the main branch, PR #61333 later replaced the throw ("Make localStorage return undefined and emit a warning when --localstorage-file is not provided") without editing the documentation. The changelog still says that since Node 26, accessing localStorage without the flag "now throws a DOMException", but neither v26.0.0 nor v26.10.0 throws: probed directly, both print the warning above and return undefined, as the v26.10.0 lib/internal/webstorage.js does in its source. The useEffect version built cleanly on all three images in the table, because it never touches localStorage during render.

Every variant on this page comes down to one rule: in a Client Component, the body is server code too. Browser-only APIs belong in effects and event handlers, and the first render must not depend on them. window and document follow the same rule. When the code that touches them sits inside a third-party library that reads them as soon as it is imported, the window is not defined guide covers loading that library with next/dynamic and ssr: false, from a Client Component as shown above.

Further reading: Fix: Can't Perform a State Update on an Unmounted Component. Also see Fix: error while hydrating, root switched to client render. Also see MUI DatePicker readonly in Cypress CI: fix the variant. Also see Fix 'props.handleSelect is not a function': wrong prop, Adjacent JSX Elements Must Be Wrapped in an Enclosing Tag. Also see Minified React error #425: Text Mismatch After Deploy.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.