useSyncExternalStore getServerSnapshot: Next.js Fix

Updated 7 min read

TL;DR useSyncExternalStore needs a third argument, getServerSnapshot, whenever Next.js renders the component on the server, which includes client components on static routes. Without it next build fails with "Missing getServerSnapshot". Return a constant default such as false or true, and React swaps in the real browser value after hydration.

On this page
  1. What does the "Missing getServerSnapshot" error look like?
  2. Why does useSyncExternalStore need getServerSnapshot in Next.js?
  3. How do I fix it? Add the third argument
  4. Three useSyncExternalStore examples that work in Next.js
  5. Online status
  6. A localStorage store that updates across tabs
  7. Using them together
  8. When the third argument isn't the right fix
  9. A quick checklist
  10. useSyncExternalStore vs useEffect and useState
  11. Mistakes I see in generated hooks
  12. FAQ
  13. What is getServerSnapshot in useSyncExternalStore?
  14. How does useSyncExternalStore work?
  15. Should I use useSyncExternalStore or useEffect for localStorage?
  16. Why does React say the result of getSnapshot should be cached?

In Next.js, useSyncExternalStore needs its third argument, getServerSnapshot, because client components are rendered on the server too. The error "Missing getServerSnapshot, which is required for server-rendered content" means a component called useSyncExternalStore with only two arguments, and Next.js tried to render it on the server. The fix is to pass a third argument, getServerSnapshot, a function that returns the value to use on the server and during hydration. Something like () => false for a media query or () => true for online status is enough.

That error broke one of my own dashboards the first time I imported it into a Next.js App Router page, so I reproduced it in a clean project to write this up. Everything below runs in a fresh create-next-app project (Next.js 15.5, React 19.1, Tailwind v4), and every hook passes tsc --noEmit and next build.

What does the "Missing getServerSnapshot" error look like?

Here is a media query hook in the shape you often see in tutorials and in generated code. It works fine in a Vite app:

ts
"use client";

import { useSyncExternalStore } from "react";

export function useMediaQuery(query: string) {
  return useSyncExternalStore(
    (onChange) => {
      const mql = window.matchMedia(query);
      mql.addEventListener("change", onChange);
      return () => mql.removeEventListener("change", onChange);
    },
    () => window.matchMedia(query).matches
  );
}

A client component uses it, and a normal server page.tsx renders that component:

tsx
"use client";

import { useMediaQuery } from "./useMediaQueryBroken";

export default function Layout() {
  const isWide = useMediaQuery("(min-width: 768px)");
  return <p>{isWide ? "Desktop layout" : "Mobile layout"}</p>;
}

tsc passes. Then next build fails during "Generating static pages":

Error occurred prerendering page "/usesyncexternalstore-getserversnapshot-nextjs/broken". Read more: https://nextjs.org/docs/messages/prerender-error
Error: Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering.
    at f (.next/server/app/usesyncexternalstore-getserversnapshot-nextjs/broken/page.js:2:8972) {
  digest: '54299029'
}
Export encountered an error on /usesyncexternalstore-getserversnapshot-nextjs/broken/page: /usesyncexternalstore-getserversnapshot-nextjs/broken, exiting the build.
 ⨯ Next.js build worker exited with code: 1 and signal: null

The build stops there, and the whole deploy fails with it.

Command-K Dashboard running live. A whole SaaS app you drive from ⌘K: rows stretch into pages, coins fly into the total.

Why does useSyncExternalStore need getServerSnapshot in Next.js?

Two facts combine here.

First, "use client" doesn't mean "browser only". Next.js still renders client components to HTML on the server so the first paint has content. For a static route that happens at build time, during prerender.

Second, useSyncExternalStore reads from a store outside React, and on the server that store usually doesn't exist. There is no window.matchMedia, no navigator.onLine, no localStorage. React needs some value to put in the HTML, and the third argument is how you give it one. The useSyncExternalStore reference describes getServerSnapshot as the snapshot used during server rendering and hydration, and says it must return the same value on the server and on the client during hydration.

Leave it out and React throws during the server render. The message says it "will revert to client rendering", but in a static prerender Next.js treats the thrown error as a failed page. Next.js has a general page on prerender errors as well.

What happens after hydration is the useful part: React renders with the server snapshot first, so the HTML matches, then switches to the real client value and re-renders. You get no hydration mismatch, and you don't need a useEffect plus useState pair to fake it.

How do I fix it? Add the third argument

For the media query hook, the server value is a fallback you choose. I also wrapped subscribe in useCallback, because React re-subscribes whenever it gets a new subscribe function:

ts
"use client";

import { useCallback, useSyncExternalStore } from "react";

export function useMediaQuery(query: string, serverFallback = false) {
  const subscribe = useCallback(
    (onChange: () => void) => {
      const mql = window.matchMedia(query);
      mql.addEventListener("change", onChange);
      return () => mql.removeEventListener("change", onChange);
    },
    [query]
  );

  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(query).matches,
    () => serverFallback
  );
}

Pick the fallback that matches what most visitors will see. For a mobile-first layout, false for (min-width: 768px) is right. If most of your traffic is desktop and the jump would be jarring, pass true from the call site.

Three useSyncExternalStore examples that work in Next.js

These are the three stores I reach for most in dashboards and app shells. All three are in the test app, used together in one component, and the page builds and prerenders as a static route.

Online status

The browser fires online and offline events on window, and navigator.onLine is the current value:

ts
"use client";

import { useSyncExternalStore } from "react";

function subscribe(onChange: () => void) {
  window.addEventListener("online", onChange);
  window.addEventListener("offline", onChange);
  return () => {
    window.removeEventListener("online", onChange);
    window.removeEventListener("offline", onChange);
  };
}

export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,
    () => true // assume online on the server
  );
}

subscribe is defined outside the component, so it's stable without useCallback. On the server we assume the user is online, which keeps an "offline" banner out of the HTML.

A localStorage store that updates across tabs

localStorage has no change event for the current tab. The storage event only fires in other tabs. So the setter dispatches a custom event as well, and subscribe listens to both:

ts
"use client";

import { useCallback, useSyncExternalStore } from "react";

const EVENT = "local-storage";

function subscribe(onChange: () => void) {
  // "storage" fires in other tabs; our custom event covers this tab.
  window.addEventListener("storage", onChange);
  window.addEventListener(EVENT, onChange);
  return () => {
    window.removeEventListener("storage", onChange);
    window.removeEventListener(EVENT, onChange);
  };
}

export function useLocalStorage(key: string, initial: string) {
  const value = useSyncExternalStore(
    subscribe,
    () => localStorage.getItem(key) ?? initial,
    () => initial
  );

  const setValue = useCallback(
    (next: string) => {
      localStorage.setItem(key, next);
      window.dispatchEvent(new Event(EVENT));
    },
    [key]
  );

  return [value, setValue] as const;
}

getSnapshot returns a string, which React compares with Object.is, so the same stored value never triggers a re-render. If you store JSON, don't JSON.parse inside getSnapshot; that returns a new object on every call and React will warn that the result of getSnapshot should be cached, then re-render in a loop. Read the raw string in the hook and parse it with useMemo in the component, or cache the parsed object next to the string it came from.

On the server, the hook returns initial. After hydration, it reads the saved value. A user who picked "compact" sees the default for a moment, then their setting. If that flash matters, put the setting in a cookie and read it on the server instead.

Using them together

tsx
"use client";

import { useLocalStorage } from "./useLocalStorage";
import { useMediaQuery } from "./useMediaQuery";
import { useOnlineStatus } from "./useOnlineStatus";

export default function StatusBar() {
  const isWide = useMediaQuery("(min-width: 768px)");
  const online = useOnlineStatus();
  const [density, setDensity] = useLocalStorage("density", "comfortable");

  return (
    <div className="flex flex-wrap items-center gap-4 rounded-xl border p-4 text-sm">
      <span>{isWide ? "Desktop layout" : "Mobile layout"}</span>
      <span>{online ? "Online" : "Offline"}</span>
      <button
        type="button"
        className="rounded-md border px-3 py-1"
        onClick={() => setDensity(density === "compact" ? "comfortable" : "compact")}
      >
        Density: {density}
      </button>
    </div>
  );
}

The page that renders this is a plain server component. In the build output it shows up as a static route (the ○ marker), which means it was prerendered at build time with the server snapshots, then hydrated.

When the third argument isn't the right fix

getServerSnapshot covers the case where you have a reasonable default. Sometimes you don't:

  • The value decides the whole layout, like rendering a completely different tree for mobile and desktop. Prefer CSS (md: variants) so the server HTML is right for everyone, and keep the hook for behavior.
  • The component can't render anything useful without the browser, like a canvas or a chart sized from the window. Load it with next/dynamic and ssr: false from inside a client component, and give it a sized placeholder.
  • You're calling a third-party hook that uses useSyncExternalStore internally without a server snapshot. You can't pass the argument yourself. Check for a newer version of the library first, then fall back to the dynamic import.

A quick checklist

  1. Search your code for useSyncExternalStore( and check that every call has three arguments.
  2. Make the third argument return a constant or a value you also have on the server. Never read window in it.
  3. Keep subscribe stable: define it outside the component or wrap it in useCallback.
  4. Return primitives or cached objects from getSnapshot.
  5. Run next build, not just next dev. The dev server renders on demand and you can miss a prerender failure until deploy.

SaaS dashboard template with live stats, the kind of screen that reads screen size and saved preferences

Dashboards are where I see this most, since they read theme, screen size and saved preferences from the browser. If you're building one, my SaaS dashboard and the other React dashboard templates on qodesign show the kind of screen where these hooks end up. The open-source qodesign-free repo has two dashboards with full source, and the free pieces page lists what you can grab without paying.

useSyncExternalStore vs useEffect and useState

Most of the pages that rank for this explain the hook in general and stop there. The question I actually get from people pasting AI-generated hooks into Next.js is simpler: why not keep the old useEffect plus useState version? Here's how the two compare for browser values like these:

useEffect + useStateuseSyncExternalStore
Server renderRenders the initial stateRenders getServerSnapshot()
First client renderSame initial state, then an effect sets the real valueServer snapshot, then React switches to getSnapshot()
UpdatesYou wire listeners and cleanup in the effectReact calls subscribe and re-reads the snapshot
Concurrent renderingCan show two values in one render pass (tearing)Built to prevent tearing
Missing piece breaks the build?NoYes, without the third argument

Both avoid a hydration mismatch if you do them right. The difference is that useSyncExternalStore keeps the subscription and the read in one place, and React re-reads the value itself after hydration, so you don't hand-write the "set it once on mount" step. The effect version is still fine for a one-off value you read once and never subscribe to.

Mistakes I see in generated hooks

  • A subscribe function created inline inside the component. It works, but React unsubscribes and subscribes again on every render. Hoist it or wrap it in useCallback, as above.
  • getServerSnapshot that reads localStorage or window behind a typeof window check. On the server it returns one thing and during hydration another, which is the mismatch you were trying to avoid. The server snapshot has to be the same on both sides.
  • A getSnapshot that builds a new object or array each time (JSON.parse, { width, height }). React compares with Object.is, sees a change on every read, warns, and loops.
  • A theme hook that returns "dark" from the server snapshot for everyone. Anyone on a light system theme then sees a flash. For themes, a class set by a small inline script or a cookie read on the server avoids the flash entirely.

I make animated React components and dashboards at qodesign, and this bug is why I now test every piece with next build, not just in dev.

FAQ

What is getServerSnapshot in useSyncExternalStore?

It is the optional third argument: a function that returns the value React uses while rendering on the server and during hydration. It must return the same value on the server and on the client during hydration, so it usually returns a constant default.

How does useSyncExternalStore work?

You give it a subscribe function that registers a listener on an outside store, and a getSnapshot function that reads the current value. React calls subscribe once, re-reads the snapshot whenever the listener fires, and re-renders only if the value changed.

Should I use useSyncExternalStore or useEffect for localStorage?

For a value that can change while the page is open, like a saved setting synced across tabs, useSyncExternalStore is the better fit because it subscribes and reads in one place. Give it a server snapshot with the default, and keep the snapshot a string so it compares cheaply.

Why does React say the result of getSnapshot should be cached?

Your getSnapshot returns a new object or array on every call, so React thinks the store changed each time and re-renders in a loop. Return a primitive, or keep the parsed object in a cache and return the same reference until the raw value changes.