How Context API Fits Inside Modern Next.js Projects - Real-World Patterns
After building a dozen Next.js apps with the App Router, I've learned what works and what doesn't when it comes to Context API.
The App Router changed the game. Server Components are the default. Client Components need 'use client'. Context only works in one of those worlds.
Let me show you the patterns that actually work in production.
Pattern 1: The Provider Setup
First things first: where do you put your Context providers in App Router?
The Right Way
// app/providers.tsx
'use client'
import { ThemeProvider } from '@/contexts/ThemeContext'
import { AuthProvider } from '@/contexts/AuthContext'
export function Providers({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
Then in your root layout:
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }) {
return (
{children}
)
}
This pattern:
- Keeps all providers in one file
- Makes the root layout clean
- Lets you add/remove providers easily
Pattern 2: Theme Switcher (Client-Side UI State)
This is Context's sweet spot. Pure UI state that persists across navigation.
// contexts/ThemeContext.tsx
'use client'
import { createContext, useContext, useState, useEffect } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
toggleTheme: () => void
}
const ThemeContext = createContext(undefined)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('light')
const [mounted, setMounted] = useState(false)
// Load theme from localStorage after mount
useEffect(() => {
setMounted(true)
const saved = localStorage.getItem('theme') as Theme
if (saved) {
setTheme(saved)
}
}, [])
// Save theme to localStorage
useEffect(() => {
if (mounted) {
localStorage.setItem('theme', theme)
document.documentElement.setAttribute('data-theme', theme)
}
}, [theme, mounted])
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light')
}
// Prevent flash of wrong theme
if (!mounted) {
return <>{children}>
}
return (
{children}
)
}
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within ThemeProvider')
return context
}
Use it anywhere:
// components/ThemeToggle.tsx
'use client'
import { useTheme } from '@/contexts/ThemeContext'
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
)
}
Pattern 3: Auth State (Server + Client Hybrid)
Fetch user on server. Manage state on client.
// lib/auth.ts (Server-side)
import { cookies } from 'next/headers'
export async function getUser() {
const token = cookies().get('auth-token')
if (!token) return null
try {
const res = await fetch('https://api.example.com/me', {
headers: { Authorization: `Bearer ${token.value}` }
})
if (res.ok) {
return await res.json()
}
} catch (error) {
console.error('Failed to fetch user', error)
}
return null
}
// contexts/AuthContext.tsx (Client-side)
'use client'
import { createContext, useContext, useState } from 'react'
interface User {
id: string
name: string
email: string
}
interface AuthContextType {
user: User | null
setUser: (user: User | null) => void
logout: () => Promise
}
const AuthContext = createContext(undefined)
export function AuthProvider({
children,
initialUser
}: {
children: React.ReactNode
initialUser: User | null
}) {
const [user, setUser] = useState(initialUser)
const logout = async () => {
await fetch('/api/auth/logout', { method: 'POST' })
setUser(null)
window.location.href = '/login'
}
return (
{children}
)
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth must be used within AuthProvider')
return context
}
// app/layout.tsx
import { Providers } from './providers'
import { getUser } from '@/lib/auth'
export default async function RootLayout({ children }) {
const user = await getUser() // Server fetch
return (
{children}
)
}
Best of both worlds: server fetches data, client manages state.
Pattern 4: Multi-Provider Architecture
For larger apps, organize contexts by domain.
// app/providers.tsx
'use client'
import { ThemeProvider } from '@/contexts/ThemeContext'
import { AuthProvider } from '@/contexts/AuthContext'
import { ModalProvider } from '@/contexts/ModalContext'
import { NotificationProvider } from '@/contexts/NotificationContext'
export function Providers({
children,
initialUser
}: {
children: React.ReactNode
initialUser: User | null
}) {
return (
{children}
)
}
Order matters: outer providers can't use inner ones.
Pattern 5: Settings & Preferences
Perfect for user preferences that persist.
// contexts/SettingsContext.tsx
'use client'
import { createContext, useContext, useState, useEffect } from 'react'
interface Settings {
language: string
currency: string
notifications: boolean
emailUpdates: boolean
}
const defaultSettings: Settings = {
language: 'en',
currency: 'USD',
notifications: true,
emailUpdates: false,
}
interface SettingsContextType {
settings: Settings
updateSettings: (updates: Partial) => void
resetSettings: () => void
}
const SettingsContext = createContext(undefined)
export function SettingsProvider({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = useState(defaultSettings)
// Load from localStorage
useEffect(() => {
const saved = localStorage.getItem('settings')
if (saved) {
setSettings(JSON.parse(saved))
}
}, [])
// Save to localStorage
useEffect(() => {
localStorage.setItem('settings', JSON.stringify(settings))
}, [settings])
const updateSettings = (updates: Partial) => {
setSettings(prev => ({ ...prev, ...updates }))
}
const resetSettings = () => {
setSettings(defaultSettings)
}
return (
{children}
)
}
export const useSettings = () => {
const context = useContext(SettingsContext)
if (!context) throw new Error('useSettings must be used within SettingsProvider')
return context
}
Pattern 6: Modal Management
Global modal state without prop drilling.
// contexts/ModalContext.tsx
'use client'
import { createContext, useContext, useState, ReactNode } from 'react'
interface ModalContextType {
isOpen: boolean
content: ReactNode | null
openModal: (content: ReactNode) => void
closeModal: () => void
}
const ModalContext = createContext(undefined)
export function ModalProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const [content, setContent] = useState(null)
const openModal = (content: ReactNode) => {
setContent(content)
setIsOpen(true)
}
const closeModal = () => {
setIsOpen(false)
setTimeout(() => setContent(null), 300) // After animation
}
return (
{children}
{isOpen && (
e.stopPropagation()}>
{content}
)}
)
}
export const useModal = () => {
const context = useContext(ModalContext)
if (!context) throw new Error('useModal must be used within ModalProvider')
return context
}
// Usage
function ProductCard({ product }) {
const { openModal } = useModal()
return (
)
}
Pattern 7: Combining with Server Components
Server Components can pass data to Client Components, which use Context.
// app/dashboard/page.tsx (Server Component)
import { DashboardClient } from './DashboardClient'
import { getStats } from '@/lib/stats'
export default async function DashboardPage() {
const stats = await getStats() // Server fetch
return
}
// app/dashboard/DashboardClient.tsx (Client Component)
'use client'
import { useState } from 'react'
import { useAuth } from '@/contexts/AuthContext'
export function DashboardClient({ initialStats }) {
const { user } = useAuth() // From Context
const [stats, setStats] = useState(initialStats) // From server
return (
Welcome, {user?.name}
)
}
Server data + Context state. Clean separation.
Performance Optimization
Split Contexts When Needed
Don't put everything in one context if parts update frequently.
Bad:
const AppContext = createContext({ user, theme, notifications, cart })
Every update rerenders all consumers.
Good:
const UserContext = createContext({ user })
const ThemeContext = createContext({ theme })
const CartContext = createContext({ cart })
Independent updates. Better performance.
Use useMemo for Provider Values
const value = useMemo(() => ({
user,
login,
logout,
}), [user])
return ...
Prevents unnecessary rerenders.
Common Pitfalls & Solutions
Pitfall 1: Forgetting 'use client'
Context needs 'use client'. Don't forget it.
Pitfall 2: Hydration Mismatch
Theme from localStorage causes hydration errors. Fix:
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return <>{children}>
Pitfall 3: Too Much in Context
Context isn't for everything. Use it for:
- UI state (theme, modals)
- Auth state
- User preferences
Don't use it for:
- Frequently changing data
- Server data (use Server Components)
- Complex app state (use Zustand)
Real Production Example
Here's a complete providers setup from a real app:
// app/providers.tsx
'use client'
import { ThemeProvider } from '@/contexts/ThemeContext'
import { AuthProvider } from '@/contexts/AuthContext'
import { ModalProvider } from '@/contexts/ModalContext'
import { ToastProvider } from '@/contexts/ToastContext'
export function Providers({
children,
initialUser,
}: {
children: React.ReactNode
initialUser: User | null
}) {
return (
{children}
)
}
// app/layout.tsx
import { Providers } from './providers'
import { getUser } from '@/lib/auth'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
export default async function RootLayout({ children }) {
const user = await getUser()
return (
{children}
)
}
Clean. Organized. Production-ready.
The Bottom Line
Context API in Next.js App Router works great when you:
- Use it for UI state and auth
- Combine it with Server Components
- Keep contexts focused and separate
- Handle hydration properly
- Don't overuse it
It's not the only state management solution. But for these specific use cases, it's perfect.
Server Components handle data. Context handles client state. Together, they create clean, performant Next.js apps.
---
Want to go deeper? Check out my full authentication guide using Context API in Next.js, complete with protected routes and session management.Want articles like this in your inbox?
Join developers and founders who get practical insights on frontend, SaaS, and building better products.
Written by Salman Izhar
Frontend Developer specializing in React, Next.js, and building high-converting web applications.
Learn More