Zustand persist in Next.js: the hydration mismatch, and what version is actually for

I built a storefront as a portfolio project — fictional shop, fictional products, no real orders. The cart lives in a Zustand store persisted to localStorage, which is about the most standard setup there is. It also produces a React hydration error the first time you run it, and the reason is a misconception worth spelling out.

The bug

Server render: no localStorage exists, so the cart is empty and the header badge says 0.

Client hydration: Zustand reads localStorage, finds three items, and the badge says 3.

React compares what the server sent with what the client produced, finds they disagree, and complains. Visually you get a flash — badge shows 0, then snaps to 3.

Why "use client" doesn’t fix it

This is the part that trips people up, and it’s worth being blunt about:

"use client" does not mean “not server-rendered”. It means “this component also ships and runs on the client”. Client components are still pre-rendered to HTML on the server.

So the directive at the top of the store file changes nothing about this problem. There is still a server pass, and during that pass localStorage does not exist. Anything read from browser-only storage will differ between the two renders by construction.

Once you internalise that, the fix is obvious: don’t render storage-dependent output until you know you’re on the client.

const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);

useEffect never runs on the server, so mounted is reliably false during SSR and the first client render, then true. The first client render now matches the server exactly, and the real value appears on the next paint.

The interesting part: the same fix, three different shapes

I used this in three places and did it differently each time, because the right answer depends on what happens to the layout while the value is missing.

Header badge — swap the value, keep the component.

const items = useCartStore((state) => state.items);
const count = mounted ? getCartItemCount(items) : 0;

The badge always renders. Only the number is deferred. The header is the most layout- sensitive thing on the page — if it changed height or width after hydration, everything below it would jump. Rendering 0 briefly is nearly invisible; a shifting header is not.

Checkout form — skeleton with a reserved height.

if (!mounted) {
  return (
    <div className="site-container min-h-[600px] py-12">
      <div className="h-[460px] animate-pulse rounded-[6px] bg-[#edf0ec]" />
    </div>
  );
}

There’s no sensible “empty” version of a checkout summary, so the whole block waits. But it waits at roughly its final size — the explicit min-h-[600px] and h-[460px] exist so the page doesn’t collapse and then expand. That’s cumulative layout shift, and reserving the space is what prevents it.

Cart page — just wait.

The cart view is the main content of its own page. Nothing sits below it to be pushed around, so it can hold until mounted without any layout cost.

The rule I’d extract: defer the smallest thing you can get away with, and reserve space for whatever you defer. Wrapping everything in if (!mounted) return null is the easy version and it’s how you get a page that visibly assembles itself.

What version is actually for

The persist config is small:

{
  name: "northline-cart",
  storage: createJSONStorage(() => localStorage),
  version: 1,
}

version looks decorative. It isn’t, and the reason is a design decision one level up.

A cart item stores a snapshot of the product, not just a reference:

export type CartItem = {
  productId: string;
  slug: string;
  name: string;
  price: number;
  image: string;
  imageAlt: string;
  variant: string;
  quantity: number;
};

Two options existed:

Store just productIdStore a snapshot
Cart rendersneeds a catalog lookuprenders straight from storage
Stale dataimpossibleprice and name can go stale
Schema stabilityvery stablechanges whenever the display needs a new field

I stored the snapshot, so the cart page can render immediately without resolving anything. The cost is exactly the bottom row: the moment I add a field — imageAlt was one — every cart already sitting in a returning visitor’s localStorage is missing it. That data was written by an older version of the app and does not match what the code now expects.

That’s the problem version exists to solve. And here’s the part worth knowing:

With no migrate function, a version mismatch causes the persisted state to be discarded and the store falls back to its initial state.

For a cart, discarding is acceptable. The user loses items they’d added and re-adds them — annoying, not damaging. For user-authored content, settings, or draft form data, silently throwing away saved state is a serious bug, and that’s when you must write migrate to reshape old data instead of dropping it.

So version: 1 isn’t a label. It’s a switch that lets a future change reject incompatible data on purpose, instead of hydrating a half-populated object and crashing on item.imageAlt.length somewhere far from the cause.

Testing the round trip

Persistence is easy to break silently, so the test exercises it directly rather than trusting the middleware:

useCartStore.setState({ items: [] });
await useCartStore.persist.rehydrate();
expect(useCartStore.getState().items[0]).toMatchObject({
  productId: product.id, variant: "Ink", quantity: 1,
});

persist.rehydrate() forces a real read-back, which is the only way to catch a store that writes fine but reads wrong.

Limits worth stating

  • The snapshot goes stale. If a price changed, a returning visitor sees the old one. This project is front-end only with no server, so there is nowhere to re-validate. A real storefront must re-price the cart server-side at checkout and never trust what came out of the browser.
  • mounted costs you the first paint. Deferred UI is never in its final state on first render. That’s a real trade, chosen because a hydration error is worse.
  • No migrate here — a cart can afford to be discarded. That decision should be revisited the moment anything worth keeping goes into the same store.

Takeaway

Two things I’d carry to the next project. First, "use client" is about where code runs, not whether it’s server-rendered — most hydration bugs come from conflating those. Second, when you cache a snapshot of something instead of a reference to it, you’ve signed up for schema drift, and version is how you handle that on purpose rather than by crashing.


Northline Supply is a fictional storefront built as a portfolio demonstration — no real retailer, products, inventory, orders or payments. Built with AI-assisted development: I define requirements and acceptance criteria, iterate on the implementation, then review, test and debug it.

Discover more from ZFLI Works

Subscribe now to keep reading and get access to the full archive.

Continue reading