How to Use Redux in Next.js: A Complete Guide

Master state management in Next.js with Redux Toolkit. Set up stores, create slices, and integrate with the App Router for scalable applications.

Why Use Redux with Next.js?

Next.js has become the de facto standard for building React applications, offering server-side rendering, automatic code splitting, and an excellent developer experience. However, as applications grow in complexity, managing state across components becomes increasingly challenging. Redux, paired with Redux Toolkit, provides a predictable state management solution that integrates seamlessly with Next.js applications.

Redux provides a centralized store that holds the entire application state, making it easier to reason about state changes and debug issues. With Redux DevTools, you can time-travel through state changes, which is invaluable for tracking down bugs and understanding how your application reached any particular state. Redux Toolkit, the official recommended approach for writing Redux logic, simplifies store configuration and reduces boilerplate code significantly compared to legacy Redux patterns.

Consider Redux when your application:

  • Needs to share state across many components without prop drilling
  • Has state updates involving complex logic or multiple data transformations
  • Requires predictable state management for debugging, testing, and collaboration
  • Maintains user session data, shopping carts, or persistent state across pages

The centralized approach means your state lives in one predictable location, making it easier to debug issues and understand how data flows through your application. When combined with Next.js's server-side rendering capabilities, this creates a powerful foundation for building scalable, maintainable applications.

When Context Alone Falls Short

React's Context API serves well for prop drilling avoidance in smaller applications, but it has limitations that Redux addresses:

  • Re-render performance: Context can cause unnecessary re-renders when multiple consumers subscribe to the same context, triggering updates even when only specific data changed
  • No built-in logic management: Context doesn't provide mechanisms for complex state logic, middleware, or side effect handling
  • Coarse-grained updates: Changes to context trigger updates for all consumers, even if they only need specific data slices

Context vs Redux: A practical comparison

// With Context - updates all consumers
const ThemeContext = createContext(null)

// Any theme change re-renders ALL theme consumers
<ThemeContext.Provider value={theme}>
 <Header /> // Re-renders even for header-only changes
 <Sidebar /> // Re-renders unnecessarily
 <Content /> // Re-renders unnecessarily
</ThemeContext.Provider>

// With Redux - granular subscriptions
const selectTheme = (state) => state.ui.theme
const selectUserName = (state) => state.user.name

// Each component only re-renders when its specific data changes
const headerTheme = useAppSelector(selectTheme) // Only updates on theme changes
const userName = useAppSelector(selectUserName) // Only updates on user name changes

Redux's subscription model is more granular, allowing components to subscribe only to specific slices of state they need, resulting in better performance for larger applications with complex state requirements. For teams working on enterprise React applications, choosing the right state management strategy early prevents costly refactoring later.

Setting Up Redux Toolkit in Next.js

Installation

The first step in adding Redux to your Next.js project is installing the necessary packages:

npm install @reduxjs/toolkit react-redux
# or
yarn add @reduxjs/toolkit react-redux

We recommend using TypeScript for better developer experience and full type inference throughout your application. The combination of Next.js with TypeScript and Redux Toolkit provides excellent type safety and autocompletion.

Creating the Store

In Next.js with the App Router, we avoid singleton store instances to prevent state contamination between requests. Instead, we create a factory function that generates a fresh store for each request:

// lib/store.ts
import { configureStore } from '@reduxjs/toolkit'
import { createSlice } from '@reduxjs/toolkit'

// Create a sample slice
export const counterSlice = createSlice({
 name: 'counter',
 initialState: { value: 0 },
 reducers: {
 increment: (state) => { state.value += 1 },
 decrement: (state) => { state.value -= 1 }
 }
})

export const { increment, decrement } = counterSlice.actions

// Factory function for store creation
export const makeStore = () => {
 return configureStore({
 reducer: {
 counter: counterSlice.reducer
 }
 })
}

// Type inference
export type AppStore = ReturnType<typeof makeStore>
export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch']

This pattern ensures each request gets its own isolated store instance, preventing data leakage between users in server-side rendered applications. The factory function approach is essential for maintaining request isolation in the Next.js App Router environment.

Creating Typed Hooks

One of Redux Toolkit's most valuable features is the ability to create typed versions of React Redux hooks, eliminating manual type annotations throughout your application. This significantly improves the developer experience by providing full TypeScript inference:

// lib/hooks.ts
import { useDispatch, useSelector, useStore } from 'react-redux'
import type { AppDispatch, AppStore, RootState } from './store'

// Typed hooks to use throughout your application
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppStore = useStore.withTypes<AppStore>()

Benefits of typed hooks:

  • Autocomplete: TypeScript knows your state shape and suggests properties as you type
  • Type safety: Accidentally accessing non-existent state properties triggers compile errors
  • Refactoring confidence: Renaming state properties updates across your entire codebase
  • No repeated annotations: No need to type useDispatch<AppDispatch>() in every component

When you type useAppSelector((state) => state. in your IDE, you'll see exactly what slices and properties are available. This eliminates guesswork and makes navigating complex state structures much easier, especially in larger applications with multiple slices.

Implementing the Store Provider

In the App Router architecture, any component interacting with Redux must be a client component because Redux relies on React context, which is only available on the client side. Create a StoreProvider component that wraps your application and provides access to the store:

// app/StoreProvider.tsx
'use client'

import { useRef } from 'react'
import { Provider } from 'react-redux'
import { makeStore, AppStore } from '../lib/store'

export default function StoreProvider({
 children
}: {
 children: React.ReactNode
}) {
 const storeRef = useRef<AppStore | null>(null)
 
 if (!storeRef.current) {
 // Create the store instance the first time this renders
 storeRef.current = makeStore()
 }

 return <Provider store={storeRef.current}>{children}</Provider>
}

How this pattern works:

  • The useRef hook stores the store instance across re-renders
  • The null check ensures the store is created only once
  • The Provider component makes the store available to all nested components
  • Because StoreProvider only renders once per request on the server, each request gets a fresh store instance
  • During client-side navigation, the same store persists, maintaining state across page transitions

This approach solves the server-side rendering challenge where each request needs an isolated store while maintaining state persistence during client navigation.

Integrating with Root Layout

To make Redux available throughout your application, include the StoreProvider in your root layout. This placement is critical because it ensures the store is created once per request and made available to all client components in your application:

// app/layout.tsx
import StoreProvider from './StoreProvider'

export default function RootLayout({
 children
}: {
 children: React.ReactNode
}) {
 return (
 <html lang="en">
 <body>
 <StoreProvider>
 {children}
 </StoreProvider>
 </body>
 </html>
 )
}

Why this placement matters:

  • Global availability: Every page and component in your application can access the Redux store
  • Request isolation: The store is created fresh for each server request, preventing data leakage
  • Client persistence: During client-side navigation, the store persists, maintaining state across pages
  • Single instance: The useRef pattern ensures one store instance per application lifecycle
  • Server-side rendering: Initial HTML is rendered with the store already configured, avoiding hydration issues

This integration pattern is the foundation for using Redux throughout your Next.js application, whether you're building a simple dashboard or a complex enterprise application requiring sophisticated state management across many pages and features.

Creating Redux Slices

Slices are the heart of Redux Toolkit, encapsulating reducer logic and actions in a single file. Here's an authentication slice example that demonstrates both synchronous and asynchronous operations:

// lib/features/auth/authSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface User {
 id: string
 email: string
 name: string
 role: string
}

interface AuthState {
 user: User | null
 isAuthenticated: boolean
 loading: boolean
 error: string | null
}

const initialState: AuthState = {
 user: null,
 isAuthenticated: false,
 loading: false,
 error: null
}

export const authSlice = createSlice({
 name: 'auth',
 initialState,
 reducers: {
 setUser: (state, action: PayloadAction<User | null>) => {
 state.user = action.payload
 state.isAuthenticated = !!action.payload
 state.error = null
 },
 setLoading: (state, action: PayloadAction<boolean>) => {
 state.loading = action.payload
 },
 setError: (state, action: PayloadAction<string | null>) => {
 state.error = action.payload
 },
 logout: (state) => {
 state.user = null
 state.isAuthenticated = false
 state.loading = false
 state.error = null
 }
 }
})

export const { setUser, setLoading, setError, logout } = authSlice.actions
export default authSlice.reducer

Async Operations with createAsyncThunk

For asynchronous operations like API calls, Redux Toolkit provides createAsyncThunk with built-in promise lifecycle actions:

// lib/features/auth/authThunks.ts
import { createAsyncThunk } from '@reduxjs/toolkit'
import { loginUser, LoginCredentials } from '@/lib/api/auth'

export const login = createAsyncThunk<
 User, // Return type
 LoginCredentials, // Argument type
 { rejectValue: string } // ThunkAPI config
>(
 'auth/login',
 async (credentials, { rejectWithValue }) => {
 try {
 const user = await loginUser(credentials)
 return user
 } catch (error) {
 return rejectWithValue(error instanceof Error ? error.message : 'Login failed')
 }
 }
)

// Usage in component:
// dispatch(login({ email, password }))
// Handles pending/fulfilled/rejected automatically

The createAsyncThunk pattern provides automatic lifecycle action dispatching (pending, fulfilled, rejected), making it easy to handle loading states and errors in your components.

Using Redux in Components

With everything set up, using Redux in components is straightforward. The typed selectors and dispatch hooks make component code clean and type-safe:

// app/dashboard/page.tsx
'use client'

import { useAppDispatch, useAppSelector } from '@/lib/hooks'
import { logout } from '@/lib/features/auth/authSlice'

export default function Dashboard() {
 const { user, isAuthenticated } = useAppSelector((state) => state.auth)
 const dispatch = useAppDispatch()

 const handleLogout = () => {
 dispatch(logout())
 }

 if (!isAuthenticated) {
 return <p>Please log in to access the dashboard.</p>
 }

 return (
 <div className="dashboard">
 <header>
 <h1>Welcome, {user?.name}</h1>
 <button onClick={handleLogout}>Logout</button>
 </header>
 <main>
 <p>User ID: {user?.id}</p>
 <p>Role: {user?.role}</p>
 </main>
 </div>
 )
}

Best Practices for Component Usage

// Best Practice 1: Select only what you need
// ❌ Avoid: Selecting entire auth slice
const auth = useAppSelector((state) => state.auth)

// ✅ Better: Select specific data
const user = useAppSelector((state) => state.auth.user)

// Best Practice 2: Use callbacks to prevent re-renders
// ❌ Avoid: Inline functions in render
<button onClick={() => dispatch(increment())}>+</button>

// ✅ Better: Stable callback reference
const handleIncrement = () => dispatch(increment())
<button onClick={handleIncrement}>+</button>

// Best Practice 3: Handle async thunk results
const handleLogin = async () => {
 const result = await dispatch(login(credentials))
 if (login.fulfilled.match(result)) {
 // Login successful
 router.push('/dashboard')
 } else {
 // Handle error shown in result.error
 }
}

These patterns ensure your components remain performant and your Redux usage follows established best practices for maintainable applications.

Best Practices for Next.js + Redux

Per-Route State Management

When using Next.js's client-side navigation, the Redux store persists across route changes. For route-specific data that should reset when leaving a page, implement cleanup in your components:

// In your slice
export const dataSlice = createSlice({
 name: 'data',
 initialState: { routeData: null as Data | null },
 reducers: {
 setRouteData: (state, action) => {
 state.routeData = action.payload
 },
 clearRouteData: (state) => {
 state.routeData = null
 }
 }
})

// In your page component
'use client'

export default function ProductPage({ params }: { params: { id: string } }) {
 const dispatch = useAppDispatch()
 const routeData = useAppSelector((state) => state.data.routeData)

 // Load data when entering route
 useEffect(() => {
 dispatch(loadProductData(params.id))
 }, [dispatch, params.id])

 // Cleanup when leaving route
 useEffect(() => {
 return () => {
 dispatch(clearRouteData())
 }
 }, [dispatch])

 return <ProductDisplay data={routeData} />
}

Performance Optimization with Memoized Selectors

For large applications, use memoized selectors with Reselect to prevent unnecessary re-renders and computations:

import { createSelector } from '@reduxjs/toolkit'
import { RootState } from '@/lib/store'

// Basic selector
const selectUser = (state: RootState) => state.auth.user

// Memoized selector - only recalculates when user changes
export const selectUserName = createSelector(
 [selectUser],
 (user) => user?.name ?? 'Guest'
)

// Complex selector with multiple inputs
export const selectUserDisplay = createSelector(
 [selectUser, (state: RootState) => state.ui.theme],
 (user, theme) => ({
 name: user?.name ?? 'Guest',
 initials: user?.name?.split(' ').map(n => n[0]).join('') ?? 'G',
 themeColor: theme.primaryColor
 })
)

Component Subscription Strategies

Structure your components to minimize unnecessary subscriptions:

  • Container/Presentational pattern: Keep Redux logic in container components, pass data as props to presentational components
  • Selectors at the leaf level: Subscribe to state in components that actually use the data
  • Avoid selector duplication: Extract complex selectors to shared files for reuse across components

Following these patterns ensures your Next.js application maintains optimal performance even as state complexity grows.

Common Patterns and Use Cases

Authentication State Management

The most common Redux use case in Next.js is managing authentication state. Store user object, tokens, and permissions in Redux for access throughout the application:

// lib/features/auth/authSlice.ts (simplified)
export const authSlice = createSlice({
 name: 'auth',
 initialState: { user: null, token: null, permissions: [] },
 reducers: {
 setCredentials: (state, action) => {
 state.user = action.payload.user
 state.token = action.payload.token
 state.permissions = action.payload.permissions
 },
 clearCredentials: (state) => {
 state.user = null
 state.token = null
 state.permissions = []
 }
 }
})

// Check permissions anywhere in your app
export const selectHasPermission = (permission: string) =>
 createSelector(
 [(state: RootState) => state.auth.permissions],
 (permissions) => permissions.includes(permission)
 )

Shopping Cart Management

E-commerce applications benefit from Redux for shopping cart management with persistent data across page navigation:

// lib/features/cart/cartSlice.ts
interface CartItem {
 productId: string
 quantity: number
 price: number
}

const cartSlice = createSlice({
 name: 'cart',
 initialState: { items: [] as CartItem[], total: 0 },
 reducers: {
 addToCart: (state, action) => {
 const existing = state.items.find(i => i.productId === action.payload.productId)
 if (existing) {
 existing.quantity += action.payload.quantity
 } else {
 state.items.push(action.payload)
 }
 state.total = state.items.reduce((sum, item) => 
 sum + item.price * item.quantity, 0
 )
 },
 removeFromCart: (state, action) => {
 state.items = state.items.filter(i => i.productId !== action.payload)
 state.total = state.items.reduce((sum, item) => 
 sum + item.price * item.quantity, 0
 )
 }
 }
})

API Response Caching

While tools like React Query handle server state effectively, Redux complements them for client-side caching:

// lib/features/cache/cacheSlice.ts
const cacheSlice = createSlice({
 name: 'cache',
 initialState: { cachedData: {} as Record<string, unknown> },
 reducers: {
 setCachedData: (state, action) => {
 const { key, data } = action.payload
 state.cachedData[key] = { data, timestamp: Date.now() }
 },
 invalidateCache: (state, action) => {
 delete state.cachedData[action.payload]
 }
 }
})

// Check cache before making API call
const getCachedData = (key: string) => (state: RootState) => {
 const cached = state.cache.cachedData[key]
 if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) {
 return cached.data // Return cached data if less than 5 minutes old
 }
 return null
}

These patterns represent the most common state management scenarios in Next.js applications, each leveraging Redux's strengths for predictable, maintainable state handling.

Key Benefits of Redux Toolkit in Next.js

Why leading development teams choose Redux for state management

Predictable State

Single source of truth with unidirectional data flow makes state changes predictable and traceable across your entire application.

Powerful DevTools

Time-travel debugging, action logging, and state inspection accelerate development and make troubleshooting complex issues straightforward.

Type Safety

Full TypeScript integration with typed hooks and selectors provides excellent developer experience with autocomplete and compile-time checking.

Performance Optimized

Granular subscriptions prevent unnecessary re-renders even in large applications with complex state requirements across many components.

Frequently Asked Questions

Do I need Redux with Next.js App Router?

Not for every application. Use Redux when you have complex state shared across many components, need predictable state management for debugging, or require middleware for side effects. Simpler apps can use React state and Context effectively.

How does Redux work with server-side rendering?

Each request gets a fresh store instance to prevent data leakage between users. The StoreProvider pattern ensures proper hydration from server to client, avoiding the common hydration mismatch errors.

Can I use Redux with the Pages Router too?

Yes, though the setup differs slightly. The Pages Router uses getServerSideProps, requiring the next-redux-wrapper library for proper integration with Redux store serialization.

Should I use Redux or React Query for my Next.js application?

They serve different purposes. React Query excels at server state (API data, caching, synchronization), while Redux handles client state (UI state, user sessions, complex local state). Many applications use both together effectively.

Conclusion

Redux Toolkit provides a robust solution for state management in Next.js applications, offering predictability, debugging capabilities, and excellent developer experience when properly configured. By following the patterns outlined in this guide--using factory functions for store creation, typed hooks for type safety, and the StoreProvider pattern for App Router compatibility--you can integrate Redux seamlessly into your Next.js projects while maintaining clean, maintainable code.

The initial setup investment pays dividends in reduced bugs, better developer experience, and easier state management as your application grows. Whether you're building a simple dashboard or a complex enterprise application, Redux Toolkit scales with your needs while keeping your code organized and predictable. The combination of Redux's centralized state management with Next.js's performance features creates a powerful foundation for modern web applications.

Ready to build scalable Next.js applications with proper state management? Our team specializes in modern React architecture and can help you implement the right state management strategy for your project, whether that involves Redux Toolkit, React Query, or a combination of approaches tailored to your specific requirements.

Build Better Web Applications with Modern State Management

Our expert developers specialize in Next.js, React, and scalable architecture patterns that leverage the full power of modern state management solutions.

Sources

  1. Redux.js.org - Redux Toolkit Setup with Next.js - Official Redux documentation providing authoritative guidance on setting up Redux Toolkit with Next.js App Router
  2. LogRocket Blog - How to use Redux in Next.js - Practical tutorial demonstrating Redux integration with real-world implementation examples