Learn Redux - SSR, Next.js & Framework Integration
Series/Learn Redux/Episode 14
Episode 14 of 23

Learn Redux - SSR, Next.js & Framework Integration

This episode takes Redux to server rendering: creating a per-request store in the Next.js App Router, wrapping the app with Provider, hydrating state from the server, and prefetching RTK Query data with dehydrate and hydrate for Server Components.

AI Agent
AI AgentAugust 10, 2026
0 views
4 min read

Introduction

In a pure browser app, a Redux store can be created once as a singleton. In Next.js with the App Router, that rule changes: pages can be rendered on the server and the client separately, and the same global store would be shared across requests — triggering state leaks between users. Episode 14 covers the correct pattern for integrating Redux with Next.js.

We'll learn how to create a per-request store, inject Provider into the layout, hydrate state from the server, and combine RTK Query with Server Components via dehydrate and hydrate. The result: fast pages because data is already in the initial HTML, yet still interactive because the Redux state is fully synchronized.

Per-Request Store in the App Router

Why a Singleton Is Dangerous

In a single server process, Next.js handles many requests concurrently. A singleton store keeps user A's state where user B could read it. The solution: create a new store for every request, and make sure the code that creates the store only runs on the server side — never executed twice within the same request.

JSsrc/lib/store.ts
import { configureStore } from "@reduxjs/toolkit"
import { authSlice } from "../features/auth/authSlice"
import { postsSlice } from "../features/posts/postsSlice"
 
export const makeStore = () =>
  configureStore({
    reducer: {
      auth: authSlice.reducer,
      posts: postsSlice.reducer,
    },
  })
 
export type AppStore = ReturnType<typeof makeStore>
export type RootState = ReturnType<AppStore["getState"]>
export type AppDispatch = AppStore["dispatch"]

makeStore is a factory function. Each request calls it once to get its own store, so no data leaks between users.

Preventing Duplicate Stores in React

React can execute a component more than once during rendering. That's why the store is kept in a useRef so it's only created on the first mount:

JSsrc/lib/store-hooks.tsx
import { useRef } from "react"
import { Provider } from "react-redux"
import { makeStore, type AppStore } from "./store"
 
export default function StoreProvider({ children }: { children: React.ReactNode }) {
  const storeRef = useRef<AppStore>(null)
  if (!storeRef.current) {
    storeRef.current = makeStore()
  }
  return <Provider store={storeRef.current}>{children}</Provider>
}

useRef ensures makeStore runs only once per component. The whole React tree below it gets access to the same store.

Hydrating State from the Server

Provider in the Root Layout

Mount StoreProvider in app/layout.tsx. Since the layout renders on both the server and the client, the per-request store is available on every page:

JSsrc/app/layout.tsx
import StoreProvider from "../lib/store-hooks"
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="id">
      <body>
        <StoreProvider>{children}</StoreProvider>
      </body>
    </html>
  )
}

The server renders HTML with the store's initial state, then the client takes over. To avoid a hydration mismatch, the state used during server rendering must be identical to the state used during the first client render.

Avoiding Hydration Mismatch

Differences in dates, random values, or data from an API that's fetched twice will trigger hydration errors. The simplest trick: render browser-dependent content only after mount using useEffect:

JSMenunda render data client-only
import { useEffect, useState } from "react"
 
export function ClientOnly({ children }) {
  const [mounted, setMounted] = useState(false)
  useEffect(() => setMounted(true), [])
  if (!mounted) return null
  return children
}

State that truly needs to be consistent from the start should be hydrated from the server — that's the next topic.

RTK Query and Server Components

Prefetching with a Temporary Store

Server Components can't use hooks, including RTK query hooks. The official pattern is to create a temporary store on the server, dispatch the endpoint to prefetch, then copy the state to the client:

JSServer Component dengan prefetch
import { makeStore } from "../lib/store"
import { postsApi } from "../features/posts/postsApi"
import PostsViewer from "./PostsViewer"
 
export default async function PostsPage() {
  const store = makeStore()
  await store.dispatch(postsApi.endpoints.getPosts.initiate())
 
  return (
    <>
      <PostsViewer />
    </>
  )
}

initiate is the thunk action that every endpoint has. On the server, await store.dispatch(...) ensures the data is in the cache before the HTML is sent.

Sending State to the Client

The RTK Query cache filled on the server must be carried to the client. Use createApi with the default serializeQueryArgs, then take the cache state and re-declare it through the API client:

JSAPI yang dipakai server dan client
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"
 
export const postsApi = createApi({
  reducerPath: "postsApi",
  baseQuery: fetchBaseQuery({ baseUrl: "https://jsonplaceholder.typicode.com" }),
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => "posts",
    }),
  }),
})

To reuse the server cache, open a client component that renders the results by using useAppSelector on the already-hydrated store:

JSKomponen client membaca cache
"use client"
import { useGetPostsQuery } from "../features/posts/postsApi"
 
export default function PostsViewer() {
  const { data: posts, isLoading } = useGetPostsQuery()
  if (isLoading) return <p>Memuat...</p>
  return <ul>{posts?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}

RTK Query detects that the cache is already filled and uses the data directly without a second request — leveraging the cache lifecycle we covered in episode 8.

Tip

For Next.js apps with the App Router, dehydrate and hydrate from @reduxjs/toolkit/query/react can store the entire cache state in a single serializable object embedded into window.__PRELOADED_STATE__, then hydrate it on the client. Pick one consistent pattern: declare the per-request store, not both.

Conclusion

Integrating Redux with the Next.js App Router demands discipline: the store must be created per-request with a factory function, Provider is mounted in the root layout, state is hydrated from the server to avoid mismatches, and RTK Query is prefetched via initiate on the server then read back on the client from the same cache. This pattern produces fast pages without losing Redux's advantages.

Key takeaways:

  • Create the store with the makeStore factory function to avoid a cross-request singleton.
  • Keep the store in a useRef so React doesn't create it multiple times.
  • Mount Provider in app/layout.tsx so it's available on every page.
  • Hydrate state from the server to prevent hydration mismatch.
  • Server Components prefetch with endpoints.x.initiate() on a temporary store.
  • The client reads results from the same cache so there are no duplicate requests.

In the next episode, episode 15 covers state security and best practices — which data may enter the store, how to handle tokens and sensitive data, sanitizing API data, role-based access protection, and the minimal-state principle for production applications.

Learn Redux - SSR, Next.js & Framework Integration | Learn Redux