js

Using Zustand in Next.js App Router Without Hydration Headaches

Learn how to use Zustand in Next.js App Router to manage client state, avoid hydration mismatches, and build faster, cleaner apps.

Using Zustand in Next.js App Router Without Hydration Headaches

I’ve been building with Next.js for years, and I kept running into the same headache: where do I put the state that must live entirely on the client? The App Router steers us toward server components, which is fantastic for performance, but it also means that the old tricks of wrapping everything in a context provider start to feel clumsy. You end up with a tangled web of providers, unnecessary re-renders, and a gut feeling that the framework is fighting you. That’s when I decided to strip things down and give Zustand a proper try. I wanted something that felt like a natural extension of JavaScript, not a framework bolted on top of a framework.

Zustand, at its core, is a tiny state manager. You create a store with a function, and that store lives outside of your component tree. The components subscribe to exactly the slices they care about. No provider, no nesting, no accidental re-renders of the entire page because a single value changed deep in the hierarchy. This alone makes it a great match for Next.js, where you already have a clear boundary between server-rendered content and client interactivity.

Let me show you what I mean. Here is a bare bones store for a shopping cart:

import { create } from 'zustand'

const useCartStore = create((set) => ({
  items: [],
  addItem: (product) =>
    set((state) => ({ items: [...state.items, product] })),
  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
    })),
}))

No context, no reducers, no action types. You call useCartStore directly from any client component, and you get exactly the data and functions you need. Have you ever stared at a deeply nested Redux devtools trace and wondered which action caused that tiny text field to flash? With Zustand, that confusion disappears because you are subscribing to a single store, and the re-renders are fine-grained by default.

But here is the tricky part: Next.js pre-renders pages on the server. If your store has initial state that came from a server component, you can’t just hydrate it mindlessly. The server sends HTML, the client loads JavaScript, and React runs the same render on the client. If the store’s initial state on the client doesn’t match what the server produced, you get that annoying hydration mismatch warning, and sometimes a flash of wrong content. I remember a project where we used a global store to hold user preferences, and every page load showed the default theme for a split second before switching to the user’s saved choice. That is a bad look.

The fix is simple: use Zustand’s create function only inside a client component, and initialize the store after the first render. You can also leverage the persist middleware to keep state in sessionStorage or localStorage, then read it on the client after hydration. Here’s how I handle it now:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

const useThemeStore = create(
  persist(
    (set) => ({
      mode: 'light',
      toggleMode: () =>
        set((state) => ({ mode: state.mode === 'light' ? 'dark' : 'light' })),
    }),
    {
      name: 'theme-preference',
      // only run on client – this prevents server mismatch
      skipHydration: true,
    }
  )
)

// In a client component:
export function ThemeToggle() {
  // Wait until component mounts to hydate
  const store = useThemeStore()
  useEffect(() => {
    useThemeStore.persist.rehydrate()
  }, [])
  // ... rest of the component
}

The skipHydration: true flag tells Zustand to not try to read the storage during server-side rendering. Then you manually rehydrate once the component mounts. This ensures the server sends a consistent version, and the client picks up the stored preference without causing a mismatch. What’s the cost? One extra useEffect. That’s it. No suspense boundaries, no special hydration wrappers.

Beyond hydration, Zustand’s middleware ecosystem is where it really shines with Next.js. You can connect to the Redux Devtools for debugging, which I find invaluable when state logic grows complex. You can also use immer to write mutable-looking updates that stay immutable under the hood. Let me show you a more realistic example – a form wizard that spans multiple pages in a Next.js app. The form state needs to survive page transitions, but you don’t want to store everything in global state permanently.

import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'

const useWizardStore = create(
  subscribeWithSelector((set) => ({
    step: 1,
    personalInfo: { name: '', email: '' },
    paymentInfo: { card: '', expiry: '' },
    setPersonalInfo: (info) =>
      set((state) => ({ personalInfo: { ...state.personalInfo, ...info } })),
    setPaymentInfo: (info) =>
      set((state) => ({ paymentInfo: { ...state.paymentInfo, ...info } })),
    nextStep: () => set((state) => ({ step: state.step + 1 })),
    prevStep: () => set((state) => ({ step: Math.max(1, state.step - 1) })),
  }))
)

Now, if you have a page that shows a summary before submission, you can subscribe to only the slices you need:

function SummaryPage() {
  const personalInfo = useWizardStore((state) => state.personalInfo)
  const paymentInfo = useWizardStore((state) => state.paymentInfo)
  // only re-renders when personalInfo or paymentInfo change
}

Notice how I didn’t wrap the entire page in a provider. Zustand’s selector mechanism is built on top of useSyncExternalStore, which React natively supports. That means it avoids tearing and works correctly with concurrent features like Suspense. I remember the first time I saw a colleague try to use a context store for a real-time dashboard – every keystroke in one widget re-rendered the entire dashboard. We switched to Zustand, and the performance problem vanished overnight.

You might ask: what about server components? Can a server component access the Zustand store? The answer is no, and that’s actually the point. Server components are for data that is fetched once and serialized. Client state belongs to the browser. Zustand enforces that separation cleanly. You can still pass server-side data into your store on the client side by using an initialization function. For example:

// In a client component that receives server data as props
function CartPage({ initialItems }) {
  const store = useCartStore()
  useEffect(() => {
    store.setItems(initialItems)
  }, [initialItems, store])
  // ...
}

This is a pattern I use all the time. The server renders the initial list of items, the client picks it up, and from that moment on, the store owns the state. Every user action, every drag, every removal is handled by Zustand without needing to roundtrip to the server unless you want to persist.

Now, let’s talk about testing. Because Zustand stores are plain functions, you can test them in isolation without mounting a component. You instantiate the store, call its actions, and assert on the state. This is a huge win for developer experience. I usually write tests like this:

import { create } from 'zustand'
import { act, renderHook } from '@testing-library/react'

test('cart adds item', () => {
  const useStore = create((set) => ({
    items: [],
    addItem: (product) => set((state) => ({ items: [...state.items, product] })),
  }))

  const { result } = renderHook(() => useStore())
  act(() => result.current.addItem({ id: 1, name: 'T-shirt' }))
  expect(result.current.items).toHaveLength(1)
})

No provider, no wrapping, no mock for Next.js. You can run these tests in milliseconds.

I know what you’re thinking: what if my app has dozens of stores? Won’t that be chaotic? In practice, each store represents a bounded context. I have one for UI state (modals, toasts), one for user session, one for cart, one for a real-time chat. They are small, independent, and easy to reason about. The total bundle size for Zustand itself is under 1KB after minification. Compare that to Redux with its middleware and React bindings.

So why would you choose this over the built-in React Context or even Redux Toolkit? The answer lies in the trade-offs. If you need a global store that dozens of components access, and you want zero boilerplate, Zustand wins. If you need time-travel debugging and a strict data flow, Redux is still excellent. But for most Next.js apps, especially those with both server and client components, Zustand hits a sweet spot between simplicity and power.

Before you start refactoring your entire project, try it on a single page. Create a store for a filter panel or a sidebar. See how it feels to not wrap that page in a provider. I think you’ll be surprised by how clean your components become.

If this article helped you see a new way to manage state in Next.js, hit the like button, share it with a teammate who is still drowning in context providers, and drop a comment about your own state management struggles. I read every one, and I’d love to know what patterns you’re using to keep your client and server state from colliding.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: Zustand, Next.js App Router, client state management, hydration mismatch, React state



Similar Posts
Blog Image
Build High-Performance Event-Driven Microservices with Fastify, EventStore, and TypeScript: Complete Professional Guide

Build high-performance event-driven microservices with Fastify, EventStore & TypeScript. Learn event sourcing, projections, error handling & monitoring. Complete tutorial with code examples.

Blog Image
Node.js Event-Driven Architecture: Build Scalable Apps with RabbitMQ & TypeScript Guide

Learn to build scalable event-driven systems with Node.js, RabbitMQ & TypeScript. Master microservices, message routing, error handling & monitoring patterns.

Blog Image
Build High-Performance Rate Limiting with Redis and Node.js: Complete Developer Guide

Learn to build production-ready rate limiting with Redis and Node.js. Implement token bucket, sliding window algorithms with middleware, monitoring & performance optimization.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Web Applications

Learn how to integrate Next.js with Prisma ORM for type-safe web applications. Build scalable apps with seamless database interactions and end-to-end type safety.

Blog Image
Complete Guide to Integrating Svelte with Supabase: Build Real-Time Web Applications Fast

Learn how to integrate Svelte with Supabase to build fast, real-time web apps with authentication and database management. Complete guide for modern developers.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM: Build Type-Safe Full-Stack Applications

Learn how to integrate Next.js with Prisma ORM for type-safe, database-driven web applications. Build faster with seamless TypeScript support and modern development tools.