Redux in Server-Side Rendered Next.js: Merging Server and Client State

General overview
When building applications with Next.js, server-side rendering (SSR) is a powerful tool to improve SEO and reduce Time to First Paint (TTFP). However, managing state between the server and client can get tricky, especially when using Redux. This article explores how to set up Redux with server-side state in Next.js and seamlessly merge it with client-side state.
Why Manage State on Both the Server and Client?
In SSR, Next.js pre-renders the HTML with initial data fetched on the server. This data needs to be hydrated into the Redux store on the client to avoid mismatches and to allow users to interact with a fully functional app without re-fetching data unnecessarily.
Step 1: Set Up Redux in a Next.js App
First, ensure your Next.js project is set up with TypeScript. Install the required Redux packages:
npm install @reduxjs/toolkit react-redux
Create a basic Redux store.
store.ts:
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';
// Example slice
const initialState = {
serverData: null as string | null,
};
const appSlice = createSlice({
name: 'app',
initialState,
reducers: {
setServerData: (state, action: PayloadAction<string>) => {
state.serverData = action.payload;
},
},
});
export const { setServerData } = appSlice.actions;
const store = configureStore({
reducer: {
app: appSlice.reducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
Step 2: Create a Redux Provider for Next.js
To use Redux throughout your app, wrap it with a provider. In Next.js, you can enhance this by creating an App
wrapper.
_app.tsx:
import { Provider } from 'react-redux';
import store from '../store';
const App = ({ Component, pageProps }: any) => {
return (
<Provider store={store}>
<Component {...pageProps} />
</Provider>
);
};
export default App;
Step 3: Add Server-Side State with getServerSideProps
Fetch the initial data server-side in a page using getServerSideProps
. Pass this data to the Redux store.
pages/index.tsx:
import { GetServerSideProps } from 'next';
import { useDispatch, useSelector } from 'react-redux';
import { setServerData } from '../store';
import { RootState } from '../store';
const Home = ({ initialServerData }: { initialServerData: string }) => {
const dispatch = useDispatch();
const serverData = useSelector((state: RootState) => state.app.serverData);
// Merge state only if Redux state is empty
if (!serverData) {
dispatch(setServerData(initialServerData));
}
return (
<div>
<h1>Redux SSR with Next.js</h1>
<p>Server Data: {serverData}</p>
</div>
);
};
// Fetch server-side data
export const getServerSideProps: GetServerSideProps = async () => {
// Simulating data fetching
const serverData = 'Hello from the server!';
return {
props: {
initialServerData: serverData,
},
};
};
export default Home;
Step 4: Persist State on the Frontend
When the app transitions from server-rendered to client-rendered, the Redux state should persist. However, the server-rendered state needs to merge with client-side state properly. To achieve this, Next.js passes the initial state via props, and the app uses these to populate the Redux store.
Hydration Hook (Optional Enhancement):
Create a reusable hook to hydrate the Redux store.
useHydrate.ts:
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
export const useHydrate = (initialState: any, setStateAction: any) => {
const dispatch = useDispatch();
useEffect(() => {
if (initialState) {
dispatch(setStateAction(initialState));
}
}, [dispatch, initialState, setStateAction]);
};
Update your component to use the hydration hook:
pages/index.tsx:
import { GetServerSideProps } from 'next';
import { useSelector } from 'react-redux';
import { setServerData } from '../store';
import { RootState } from '../store';
import { useHydrate } from '../hooks/useHydrate';
const Home = ({ initialServerData }: { initialServerData: string }) => {
useHydrate(initialServerData, setServerData);
const serverData = useSelector((state: RootState) => state.app.serverData);
return (
<div>
<h1>Redux SSR with Next.js</h1>
<p>Server Data: {serverData}</p>
</div>
);
};
export const getServerSideProps: GetServerSideProps = async () => {
const serverData = 'Hello from the server!';
return {
props: {
initialServerData: serverData,
},
};
};
export default Home;
Step 5: Debugging State Merging
To verify state merging, you can log the initial state and Redux store state during hydration.
useEffect(() => {
console.log('Initial Server State:', initialServerData);
console.log('Redux State:', serverData);
}, [initialServerData, serverData]);
Step 6: Advanced Usage — Combining Server and Client Data
If you need to fetch additional data client-side and merge it with the server state, extend the slice reducer to handle both server and client updates.
const appSlice = createSlice({
name: 'app',
initialState,
reducers: {
setServerData: (state, action: PayloadAction<string>) => {
state.serverData = action.payload;
},
appendClientData: (state, action: PayloadAction<string>) => {
state.serverData += ` | ${action.payload}`;
},
},
});
Dispatch actions client-side after hydration:
useEffect(() => {
dispatch(appendClientData('Client-side data!'));
}, [dispatch]);
Conclusion
Setting up Redux with server-side rendering in Next.js requires careful handling of initial state and hydration. By fetching state server-side, passing it via props, and merging it on the client, you ensure your app is performant, consistent, and user-friendly. With this setup, your app can seamlessly blend SSR’s power with Redux’s robust state management.
Happy coding! 🚀