Skip to content
D
Documentation

Next.js store setup

how-to
3 min readUpdated

Use a store factory and a React context provider when you need request-isolated state in Next.js. Initialize the server and client stores with the same data so their first render matches. This example renders Count: 10; the buttons increment and decrement the count in the mounted client component.

Unlike the bound hook returned by create, a vanilla store exists independently of React. Create it with createStore, then connect client components with useStore. Context supplies the store for this part of the component tree.

1. Create a store factory

In your existing Next.js TypeScript project, install Zustand:

bash
npm install zustand

Place the three shared files below beside your route's page file (src/app/page.tsx or src/pages/index.tsx). Export a factory rather than a store instance. Each call returns a new store containing the initial count and its actions. CounterState and CounterActions describe your application data and operations; their intersection gives the store both.

ts
import { createStore } from 'zustand/vanilla'

export type CounterState = {
  count: number
}

export type CounterActions = {
  decrementCount: () => void
  incrementCount: () => void
}

export type CounterStore = CounterState & CounterActions

export const defaultInitState: CounterState = { count: 0 }

export const createCounterStore = (
  initState: CounterState = defaultInitState,
) =>
  createStore<CounterStore>()((set) => ({
    ...initState,
    decrementCount: () => set((state) => ({ count: state.count - 1 })),
    incrementCount: () => set((state) => ({ count: state.count + 1 })),
  }))

2. Provide a stable store to client components

Use a lazy useState initializer to retain the provided store across re-renders. The context holds the store API, not a bound hook. Derive its type from the factory's return value.

The custom hook returns the selector's result and subscribes the component to it. It throws a descriptive error if you use it outside the provider. CounterExample composes the provider with the client counter defined below, rendering the count and both buttons when you mount <CounterExample />.

tsx
'use client'

import { createContext, useContext, useState, type ReactNode } from 'react'
import { useStore } from 'zustand'
import { Counter } from './counter'
import {
  createCounterStore,
  type CounterState,
  type CounterStore,
} from './counter-store'

type CounterStoreApi = ReturnType<typeof createCounterStore>

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

type CounterStoreProviderProps = {
  children: ReactNode
  initialState: CounterState
}

export function CounterStoreProvider({
  children,
  initialState,
}: CounterStoreProviderProps) {
  const [store] = useState(() => createCounterStore(initialState))

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

export function useCounterStore<T>(selector: (state: CounterStore) => T): T {
  const store = useContext(CounterStoreContext)
  if (!store) {
    throw new Error('useCounterStore must be used within CounterStoreProvider')
  }
  return useStore(store, selector)
}

export default function CounterExample() {
  return (
    <CounterStoreProvider initialState={{ count: 10 }}>
      <Counter />
    </CounterStoreProvider>
  )
}
The counter displays Count: 10 with Increment Count and Decrement Count buttons.

Create a client component that selects the count and each action separately. After hydration, clicking Increment Count adds one to the displayed count; Decrement Count subtracts one.

tsx
'use client'

import { useCounterStore } from './counter-store-provider'

export function Counter() {
  const count = useCounterStore((state) => state.count)
  const incrementCount = useCounterStore((state) => state.incrementCount)
  const decrementCount = useCounterStore((state) => state.decrementCount)

  return (
    <div>
      <p>Count: {count}</p>
      <button type="button" onClick={incrementCount}>Increment Count</button>
      <button type="button" onClick={decrementCount}>Decrement Count</button>
    </div>
  )
}

3. Supply matching initial state

Choose the example for your router. Both mount the provider at page level, following the per-route setup. The initial count is explicit application data, not a value computed independently in the browser.

App Router

Keep your existing root layout and add this page. The Server Component passes only initial data to the client provider; it does not read or write a Zustand store.

tsx
import { Counter } from './counter'
import { CounterStoreProvider } from './counter-store-provider'
import type { CounterState } from './counter-store'

export default function Home() {
  const initialState: CounterState = { count: 10 }

  return (
    <CounterStoreProvider initialState={initialState}>
      <Counter />
    </CounterStoreProvider>
  )
}

Pages Router

Pass initial data through page props. Here, getServerSideProps supplies the initial count; the page passes that same value to the provider for server rendering and client hydration. Use this page-level provider without an additional counter provider in _app.tsx.

tsx
import { Counter } from './counter'
import { CounterStoreProvider } from './counter-store-provider'
import type { CounterState } from './counter-store'

type HomeProps = {
  initialState: CounterState
}

export const getServerSideProps = async (): Promise<{ props: HomeProps }> => {
  return { props: { initialState: { count: 10 } } }
}

export default function Home({ initialState }: HomeProps) {
  return (
    <CounterStoreProvider initialState={initialState}>
      <Counter />
    </CounterStoreProvider>
  )
}

Open the home page in your running Next.js app. It renders Count: 10 with both buttons, then updates the displayed count when you click them.

Initialization and scope

createStore takes a state creator, not Next.js-specific configuration options. In this example, these are your factory and provider inputs:

InputTypeDefaultWhat it does
Factory initStateCounterState{ count: 0 }Supplies the count when creating a store.
Provider initialStateCounterStateRequiredSupplies data to the factory on provider initialization.

Keep the provided store stable: initialState initializes it, rather than synchronizing it on every render. Do not recreate the store in the provider's render body.

Place the provider at page level when you need a per-route store. If you do not need per-route scope, the setup also supports a provider in the App Router's layout or the Pages Router's _app.tsx. See Store scope and lifecycle for choosing the store's lifetime.

Pitfalls

  • Do not export a server-side singleton store. A Next.js server handles multiple requests, so a global store can share state between them. Export the factory and create the store inside the provider instead.
  • Match the initial data. Different server and client output causes hydration errors. Pass the same initial data to both stores; do not independently calculate a time-dependent or browser-only value during initialization.
  • Keep Server Components out of the store. Do not read or write Zustand state from React Server Components. Pass initial data into the client provider and access the store through client components.

useStore uses getInitialState() for its server snapshot and getState() for its current client snapshot. Matching the two stores' initialization is therefore part of making their first render agree.

Was this page helpful?

Next.js store setup — zustand · GPT-6.1 Sol