How to Handle SSR Safety with the ssrSafe Middleware in Zustand

The unstable_ssrSafe middleware prevents server-side state mutations by replacing setState with a function that throws, eliminating hydration mismatches between server and client renders.

Zustand provides an experimental SSR-safety middleware to enforce immutable stores during server-side rendering. By wrapping your store creator with ssrSafe, you ensure that state initialization can occur on the server while mutations are strictly blocked until hydration completes. This implementation, found in the pmndrs/zustand repository, guarantees deterministic renders and surfaces accidental state updates as explicit runtime errors.

What the ssrSafe Middleware Does

The middleware intercepts store creation and detects the runtime environment using typeof window === 'undefined' (or a custom flag you provide). When executing in a server context, it wraps the store API to prevent mutations.

In [src/middleware/ssrSafe.ts](https://github.com/pmndrs/zustand/blob/main/src/middleware/ssrSafe.ts), the implementation follows this pattern:

export function ssrSafe<T extends object, U extends object, ...>(
  config: StateCreator<T, ..., U>,
  isSSR: boolean = typeof window === 'undefined',
): StateCreator<T, ..., U> {
  return (set, get, api) => {
    if (!isSSR) {
      // Normal client execution proceeds unchanged
      return config(set, get, api)
    }
    // Server environment: replace set with a function that throws
    const ssrSet = () => {
      throw new Error('Cannot set state of Zustand store in SSR')
    }
    api.setState = ssrSet                // Block any later mutation attempts
    return config(ssrSet as never, get, api) // Initialize with safe setter
  }
}

Key behaviors of this approach:

  • Fast failure: Any call to set() or api.setState during SSR throws immediately with the message "Cannot set state of Zustand store in SSR".
  • Initialization preserved: The original store configuration function still executes, allowing default state values to be established for the initial HTML snapshot.
  • Global protection: By overwriting api.setState directly, the middleware blocks mutations even from external code holding a reference to the API object.

The middleware is re-exported from [src/middleware.ts](https://github.com/pmndrs/zustand/blob/main/src/middleware.ts) under the unstable namespace:

export { ssrSafe as unstable_ssrSafe } from './middleware/ssrSafe.ts'

Why You Need SSR Protection

Without the ssrSafe middleware, Zustand stores created on the server can accidentally mutate during the render phase. When a component updates global state inside useEffect or during initial data fetching, the server-rendered HTML may diverge from the client's first render, causing hydration mismatches or silent state corruption.

By applying ssrSafe, you achieve:

  • Deterministic server renders: The HTML snapshot remains identical across every request because state cannot change during the React render cycle.
  • Early error detection: Accidental server-side state updates surface as explicit exceptions during development rather than subtle hydration bugs in production.
  • Client-side interactivity: Once the browser hydrates the application, the store automatically accepts mutations and behaves normally.

How to Apply the Middleware

Import unstable_ssrSafe from the main middleware entry point and wrap your store creator function:

import { create } from 'zustand'
import { unstable_ssrSafe as ssrSafe } from 'zustand/middleware'

// SSR-protected store
const useStore = create(
  ssrSafe((set) => ({
    count: 0,
    inc: () => set((state) => ({ count: state.count + 1 })),
    dec: () => set((state) => ({ count: state.count - 1 })),
  }))
)

The middleware accepts an optional second argument for custom SSR detection logic. This is useful for frameworks like Next.js that expose specific environment flags:

const isServer = () => 
  typeof window === 'undefined' || process.env.NEXT_RUNTIME === 'nodejs'

const useEdgeStore = create(
  ssrSafe(
    (set) => ({
      data: null,
      load: (payload) => set({ data: payload }),
    }),
    isServer() // Custom detection boolean
  )
)

React 18 Hydration Integration

When using React 18's renderToString and hydrateRoot, the ssrSafe middleware ensures the store remains immutable during the server pass but reactive after hydration:

import React, { useEffect } from 'react'
import { create } from 'zustand'
import { unstable_ssrSafe as ssrSafe } from 'zustand/middleware'
import { hydrateRoot } from 'react-dom/client'
import { renderToString } from 'react-dom/server'

const useCounter = create(
  ssrSafe((set) => ({
    value: 0,
    inc: () => set((s) => ({ value: s.value + 1 })),
  }))
)

function Counter() {
  const { value, inc } = useCounter()

  // Effects do not run during SSR, so inc() only executes client-side
  useEffect(() => {
    inc()
  }, [inc])

  return <div>Count: {value}</div>
}

// Server render: store is initialized but immutable
const html = renderToString(<Counter />)

// Client hydration: store becomes mutable and interactive
hydrateRoot(document.getElementById('root')!, <Counter />)

During the server render, any accidental call to setState (for example, inside a synchronous data fetcher) throws an error immediately. On the client, the store initializes with the server-provided state and accepts updates normally.

Summary

  • Import path: Access unstable_ssrSafe from zustand/middleware (re-exported from src/middleware.ts).
  • Implementation location: Core logic resides in src/middleware/ssrSafe.ts.
  • Detection strategy: Defaults to typeof window === 'undefined' but accepts custom boolean flags.
  • Protection mechanism: Replaces api.setState with a function that throws on the server while allowing state initialization to proceed.
  • Use case: Prevents hydration mismatches in Next.js, Remix, or custom SSR environments by enforcing immutable stores during server rendering.

Frequently Asked Questions

Does ssrSafe prevent reading state on the server?

No. The middleware only blocks mutations via the set function and api.setState. Reading state through get or accessing the store's current values remains fully functional during server-side rendering, allowing components to initialize with default data for the HTML snapshot.

Can I use ssrSafe with the Next.js App Router?

Yes. While the App Router handles server components differently, you can use ssrSafe in client components ("use client") that need protection during the initial server render pass or in streaming SSR scenarios. Pass a custom isSSR detector if you need to account for specific Next.js runtime flags like process.env.NEXT_RUNTIME.

What happens if I call setState during SSR?

The middleware throws an immediate error with the message "Cannot set state of Zustand store in SSR". This prevents the mutation from completing and alerts you to the problematic code location during development, allowing you to move the state update to a client-only lifecycle method like useEffect.

Is the ssrSafe middleware production-ready?

The middleware is currently marked as experimental and exported under the unstable_ prefix. While functional, the API may change before a stable release. For production applications, pin your Zustand version and monitor the repository for updates regarding the stabilization of this feature.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →