Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Hydrating Next.js Client Store with Server State

Hello! Welcome to the fifth module of our course on Zustand.

In our last lesson, we established a crucial pattern for using Zustand in a Next.js SSR environment. We moved away from a flawed global singleton store and implemented a transient, per-request store using a store factory and a React Context provider. This solved the problem of state leakage between different user requests.

Today, we will build directly on that foundation. Our goal is to hydrate the client-side store with server-provided initial state in a Next.js application. We'll connect the final dots in our SSR setup, ensuring that data fetched on the server is correctly passed to our isolated store, so that both the server and client start with the exact same state, preventing hydration errors.

1. The Hydration Challenge: A Quick Recap

In the previous lesson, we created a store factory that looked something like this:

// src/stores/counter-store.ts
import { createStore } from 'zustand/vanilla'

// ... types ...

export const createCounterStore = (
  initState: CounterState = defaultInitState
) => {
  return createStore<CounterStore>()((set) => ({
    ...initState,
    // ... actions
  }))
}

The key piece for today's lesson is the initState parameter. We defined it but didn't use it. This parameter is the entry point for server-fetched data.

The term hydration in this context refers to the process where the client-side React application takes the server-rendered HTML and "brings it to life" by attaching event listeners and initializing its state. For this to work without errors, the initial state on the client must perfectly match the state used to render the HTML on theserver. Our task is to ensure our Zustand store is initialized with this server state.

The overall flow is:

  1. Fetch data on the server (e.g., in getServerSideProps).
  2. Pass this data to the page component as props.
  3. Pass these props into our StoreProvider.
  4. The StoreProvider uses the props to initialize the store via the initState parameter.

2. The Concept of Hydration in SSR

This pattern of fetching data on the server, passing it as props, and using it to initialize a client-side state manager is a standard practice in SSR frameworks. Different libraries have different APIs, but the principle is the same.

To see a clear parallel, let's look at how this is done with Redux.

NextJS + State Management = Good Idea???

This video by Jack Herrington demonstrates state management in Next.js. We'll watch the section on Redux, as it provides a very clear illustration of the server-to-client state hydration pattern that we are about to implement for Zustand.

Please watch the segment from 23:17 to 26:23. Focus on the flow in getServerSideProps: data is fetched, an action is dispatched to a server-side store instance, and then store.getState() is passed as an initialState prop. On the client, a useEffect hook dispatches a 'rehydrate' action using this prop. This is the core hydration concept.

The Redux example shows the exact data flow we need: getServerSideProps -> props -> client-side store initialization. With our per-request store pattern, Zustand allows for an even more elegant solution that doesn't require a useEffect hook.

3. Implementing Hydration with the Provider Pattern

Let's modify the provider pattern from our last lesson to handle the initial state.

Step 1: Update the Store Provider

First, we need to update our CounterStoreProvider to accept the initial state as a prop and use it when creating the store. The key is that we will now initialize the store with data instead of with the default empty state.

Since we are using useRef to ensure the store is created only once, this initialization will happen on the very first render of the provider, both on the server and on the client.

Setup with Next.js - Zustand

The official Zustand documentation for Next.js alludes to this pattern. We will now implement the necessary changes to our provider based on the principles outlined in the docs.

Please review the section titled 'Initializing the store'. While the code example is incomplete in the resource, the description is key: it explains that we must initialize the store with a server-provided state. We will now write the code that accomplishes this, modifying the provider from the previous lesson.

Here is the updated CounterStoreProvider. Notice the new initStore prop and how it's used.

// src/providers/counter-store-provider.tsx
'use client'

import { type ReactNode, createContext, useRef, useContext } from 'react'
import { useStore } from 'zustand'

import { 
  type CounterStore, 
  createCounterStore,
  type CounterState // Import CounterState type
} from '@/stores/counter-store'

export type CounterStoreApi = ReturnType<typeof createCounterStore>

export const CounterStoreContext = createContext<CounterStoreApi | undefined>(
  undefined,
)

export interface CounterStoreProviderProps {
  children: ReactNode
  // Add a prop to accept the initial state
  initState: CounterState 
}

export const CounterStoreProvider = ({
  children,
  initState, // Destructure the new prop
}: CounterStoreProviderProps) => {
  const storeRef = useRef<CounterStoreApi>()
  
  // Create the store only once
  if (!storeRef.current) {
    // Use the initState prop when creating the store
    storeRef.current = createCounterStore(initState)
  }

  return (
    <CounterStoreContext.Provider value={storeRef.current}>
      {children}
    </CounterStoreContext.Provider>
  )
}

export const useCounterStore = <T,>(
  selector: (store: CounterStore) => T,
): T => {
  // ... (this hook remains the same as the previous lesson)
}

Key Changes:

  • The CounterStoreProviderProps interface now includes initState.
  • The CounterStoreProvider component accepts initState as a prop.
  • Crucially, createCounterStore(initState) is called, passing the server-provided state directly into our store factory.

Step 2: Fetch Data and Pass it to the Provider

Now, let's use this in a Next.js page. We'll use getServerSideProps to fetch some data (or just create it, for this example) and pass it to our provider.

For this example, let's assume we are using the Pages Router, as it provides a clear way to pass props from data fetching functions to the whole app via _app.tsx.

First, in your page file:

// src/pages/index.tsx

export async function getServerSideProps() {
  // Imagine fetching this data from an API
  const initialCount = 5;

  return {
    props: {
      // This object will be passed to the page component as props,
      // and will be available in _app.tsx's pageProps.
      initialState: { count: initialCount },
    },
  };
}

// The rest of your page component...
export default function HomePage() {
  // ...
}

Next, you connect this in your _app.tsx file. You'll wrap your application in the CounterStoreProvider and pass the initialState from pageProps.

// src/pages/_app.tsx
import type { AppProps } from 'next/app'
import { CounterStoreProvider } from '@/providers/counter-store-provider'

export default function App({ Component, pageProps }: AppProps) {
  // Extract the initial state from pageProps
  const { initialState } = pageProps

  return (
    <CounterStoreProvider initState={initialState}>
      <Component {...pageProps} />
    </CounterStoreProvider>
  )
}

And that's it! The loop is closed.

  • On the server: getServerSideProps runs, creating { initialState: { count: 5 } }. The App component renders, the CounterStoreProvider receives this state and creates a store instance where count is 5. The HTML is rendered with the value 5.
  • On the client: The browser receives the HTML and the pageProps (including initialState) inside the __NEXT_DATA__ JSON. React hydrates. The App component renders, the CounterStoreProvider receives the exact same initialState and creates a client-side store where count is also 5.

Because the initial state is identical, the client render matches the server render, and hydration succeeds without errors.

Conclusion

In this lesson, you've learned how to complete the SSR pattern for Zustand by hydrating the client-side store.

Key Takeaways:

  • Hydration is the process of initializing the client-side state to match the server-side state, preventing UI mismatches.
  • The initState parameter in our store factory is the designated entry point for server-provided data.
  • The pattern involves modifying the Store Provider to accept an initial state prop.
  • In a Next.js page, you fetch data in getServerSideProps/getStaticProps, return it as a prop, and pass this prop to your provider at the root of your application (_app.tsx or layout.tsx).

By combining the per-request store pattern from the last lesson with the hydration technique from this one, you now have a complete, robust, and safe solution for using Zustand with Next.js Server-Side Rendering.

Preview of the Next Lesson

We've spent two lessons building a fairly involved architecture to make Zustand work safely with SSR. But is this always necessary? In the next lesson, we will contrast the transient store pattern for SSR with the much simpler singleton store pattern used for Client-Side Rendering (CSR). This will clarify when you need the full SSR setup and when you can use Zustand's default, simpler API.

Can't find a good explanation? Sign up and we'll make it for you

Sign up