Syncing Tabs in Next.js 13+ using Redux Toolkit & Redux State Sync — Simple Guide

When working on a web app, particularly in highly dynamic apps like e-commerce we need to sync the states in different tabs. This article will discuss how to easily sync data between tabs using Redux State Sync in Next.js by building a simple counter app.
Without wasting our time let’s jump right into the tutorial:
1) Initialize your Next.js App:
To create a new Next.js Project run the following command:
npx create-next-app@latest
Choose the prompts accordingly:
What is your project named? next-with-pwa
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) Yes
Would you like to customize the default import alias (@/*)? No
If you already have a Next project go to the next step. For further options visit the Official Website!
2) Add Redux Toolkit to Your Project:
If you already have redux setup move to the next step, else you can run the following command to install redux into your project.
npm install react-redux @reduxjs/toolkit
3) Add Redux-State-Sync and Redux-Persist to Your Project:
Run the following command in the terminal to install redux-state-sync to your project:
npm install redux-state-sync redux-persist
npm i --save-dev @types/redux-state-sync
4) Build a Counter Skeleton:
Now we will build a basic skeleton for our Counter App.
Update the code on app/page.tsx
with the following code:
// app/page.tsx
import CounterComponent from "./components/counter";
export default function Home() {
return (
<main className="flex flex-col w-full h-[100vh] min-h-[400px] items-center justify-center gap-16">
<div className="text-center">
<h1 className="text-2xl font-bold leading-7 text-gray-900">
Tab Sync Example
</h1>
<p>Open this page in multiple tabs and see how the state is synced.</p>
</div>
<CounterComponent />
</main>
);
}
Update the app/global.css
to remove any default styling:
// app/global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
padding: 0;
margin: 0;
box-sizing: border-box;
overflow-x: hidden;
}
Now add CounterComponent
and ButtonComponent
for our app:
// app/components/counter.tsx
import ButtonComponent from "./button";
const CounterComponent = () => {
return (
<div className="flex items-center justify-center gap-8">
<div className="border border-gray-700 px-4 py-1 rounded-md">0</div>
<ButtonComponent className="bg-green-400 hover:text-green-400 hover:border-green-400 hover:bg-transparent">
Add
</ButtonComponent>
<ButtonComponent className="bg-red-400 hover:text-red-400 hover:border-red-400 hover:bg-transparent">
Subtract
</ButtonComponent>
<ButtonComponent className="bg-blue-400 hover:text-blue-400 hover:border-blue-400 hover:bg-transparent">
Reset
</ButtonComponent>
</div>
);
};
export default CounterComponent;
// app/components/button.tsx
import { ButtonHTMLAttributes, DetailedHTMLProps, FC } from "react";
const ButtonComponent: FC<
DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
> = ({ children, className, ...props }) => {
return (
<button
className={`rounded-full py-1 px-5 transition-all text-white border-[2px] border-[color:transparent] ${className}`}
{...props}
>
{children}
</button>
);
};
export default ButtonComponent;
Here we have completed the basic structure for our app, and it will look as follows:

5) Configuring Redux in Our App:
To configure redux we will create two new folders in app
directory with the name of redux
and hooks
.
First, we will add code for the store which is a central state of your app. Here we have added one slice and for now, we are leaving our middleware empty.
// redux/store.ts
"use client";
import { configureStore } from "@reduxjs/toolkit";
import counterSlice from "./counter.slice";
export const store = () => {
return configureStore({
reducer: {
counter: counterSlice,
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(),
});
};
// just exported it as makeStore for the naming convention
export const makeStore = () => store;
// these exports are just intended to pass on the types
export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];
Now create a provider to wrap around our app to pass on the state to the client components.
// store/store-provider.tsx
'use client'
import { useRef } from 'react'
import { Provider } from 'react-redux'
import { makeStore, AppStore } from './store'
export default function StoreProvider({
children
}: {
children: React.ReactNode
}) {
const storeRef = useRef<AppStore>()
if (!storeRef.current) {
storeRef.current = makeStore()
}
return <Provider store={storeRef.current}>{children}</Provider>
}
Now we will wrap this provider around our app in layout.tsx
which goes as follows:
// app/layout.tsx
// ... other imports
import StoreProvider from "@/store/store-provider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<StoreProvider>{children}</StoreProvider>
</body>
</html>
);
}
Now we will create a slice which is a collection of Redux reducer logic and provide actions and state for the single feature of the app.
// store/counter.slice.ts
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
count: 0,
};
const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
addCount: (state) => {
return {
...state,
count: state.count + 1,
};
},
subtractCount: (state) => {
if (state.count === 0) return state;
return {
...state,
count: state.count - 1,
};
},
resetCount: () => initialState,
},
});
export const { addCount, subtractCount, resetCount } = counterSlice.actions;
export default counterSlice.reducer;
Now we will add these actions and state them in our CounterComponent
as follows:
// app/components/counter.tsx
"use client";
import { useAppDispatch, useAppSelector } from "@/hooks";
import { addCount, subtractCount, resetCount } from "@/redux/counter.slice";
import ButtonComponent from "./button";
const CounterComponent = () => {
const dispatch = useAppDispatch();
const { count } = useAppSelector((state) => state.counter);
return (
<div className="flex items-center justify-center gap-8">
<div className="border border-gray-700 px-4 py-1 rounded-md">{count}</div>
<ButtonComponent
className="bg-green-400 hover:text-green-400 hover:border-green-400 hover:bg-transparent"
onClick={() => dispatch(addCount())}
>
Add
</ButtonComponent>
<ButtonComponent
className="bg-red-400 hover:text-red-400 hover:border-red-400 hover:bg-transparent"
onClick={() => dispatch(subtractCount())}
>
Subtract
</ButtonComponent>
<ButtonComponent
className="bg-blue-400 hover:text-blue-400 hover:border-blue-400 hover:bg-transparent"
onClick={() => dispatch(resetCount())}
>
Reset
</ButtonComponent>
</div>
);
};
export default CounterComponent;
After doing this we have successfully configured Redux in our app and now the effect of buttons will look like as follows:

6) Configure Redux-Persist in Our App:
Here we need to use redux-persist
to persist the state of our app on the frontend else it will throw us a hydration error:
First, we will update our store to include redux-persist
as following:
// redux/store.ts
"use client";
import { configureStore, combineReducers } from "@reduxjs/toolkit";
import { persistStore, persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage";
import counterSlice from "./counter.slice";
const persistConfig = {
key: "root",
storage,
whitelist: ["counter"], // only this will be persisted
};
const rootReducer = combineReducers({ counter: counterSlice });
const persistedReducer = persistReducer(persistConfig, rootReducer);
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(),
});
export const persistor = persistStore(store);
// just exported it as makeStore for the naming convention
export const makeStore = () => store;
// these exports are just intended to pass on the types
export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];
Now we will create a provider to pass the context to our app:
// redux/persistor-provider.tsx
"use client";
import { PersistGate } from "redux-persist/integration/react";
import { persistor } from "./store";
export const PersisterProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<PersistGate loading={null} persistor={persistor}>
{children}
</PersistGate>
);
};
In the end, we will wrap this provider at layout.tsx
as follows:
// app/layout.tsx
// all other imports
import { PersisterProvider } from "@/redux/persistor-provider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<StoreProvider>
<PersisterProvider>{children}</PersisterProvider>
</StoreProvider>
</body>
</html>
);
}
7) Configure Redux-State-Sync in Our App:
Now it is straightforward to integrate redux-state-sync
and we will also tweak the redux persist so that we do not get any errors:
// redux/store.ts
"use client";
import { configureStore, combineReducers } from "@reduxjs/toolkit";
import {
createStateSyncMiddleware,
initMessageListener,
} from "redux-state-sync";
import { persistStore, persistReducer } from "redux-persist";
import {
PERSIST,
PURGE,
REHYDRATE,
REGISTER,
FLUSH,
PAUSE,
} from "redux-persist/es/constants";
// if above es import is not working check -> "redux-persist/lib/constants"
import storage from "redux-persist/lib/storage";
import counterSlice from "./counter.slice";
const persistConfig = {
key: "root",
storage,
whitelist: ["counter"], // only this will be persisted
};
const rootReducer = combineReducers({ counter: counterSlice });
const persistedReducer = persistReducer(persistConfig, rootReducer);
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(
createStateSyncMiddleware({
predicate: (action) => {
const blacklist = [PERSIST, PURGE, REHYDRATE, REGISTER, FLUSH, PAUSE];
if (typeof action !== "function") {
if (Array.isArray(blacklist)) {
return blacklist.indexOf(action.type) < 0;
}
}
return false;
},
})
) as any,
});
initMessageListener(store);
export const persistor = persistStore(store);
// just exported it as makeStore for the naming convention
export const makeStore = () => store;
// these exports are just intended to pass on the types
export type AppStore = ReturnType<typeof makeStore>;
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];
After doing this, you will see something like that:

And that’s it, Happy Coding! 😊
You can find a repo containing all the code here:
For a deployed version, you can follow this link:
If you liked my story you can follow me for more interesting tips and tricks at Farasat Ali — Medium.
You can Find Me on LinkedIn and GitHub.
Also, Visit My Portfolio at:
You can also check out the related articles:
Stackademic
Thank you for reading until the end. Before you go: