# useSyncExternalStore getServerSnapshot: Next.js Fix

> 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.

- URL: https://qodesign.dev/blog/usesyncexternalstore-getserversnapshot-nextjs
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-14
- Updated: 2026-10-20
- Category: Next.js fixes
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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":

```txt
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.

## 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](https://react.dev/reference/react/useSyncExternalStore) 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](https://nextjs.org/docs/messages/prerender-error) 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](/previews/dashboard-saas.jpg)

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](/components/dashboard-saas) and the other [React dashboard templates](/ui/react-dashboard-templates) on qodesign show the kind of screen where these hooks end up. The open-source [qodesign-free repo](https://github.com/teodoravadani/qodesign-free) has two dashboards with full source, and the [free pieces](/free) 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 + useState | useSyncExternalStore |
| --- | --- | --- |
| Server render | Renders the initial state | Renders `getServerSnapshot()` |
| First client render | Same initial state, then an effect sets the real value | Server snapshot, then React switches to `getSnapshot()` |
| Updates | You wire listeners and cleanup in the effect | React calls `subscribe` and re-reads the snapshot |
| Concurrent rendering | Can show two values in one render pass (tearing) | Built to prevent tearing |
| Missing piece breaks the build? | No | Yes, 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.
