How Context API Fits Inside Modern Next.js Projects - Real-World Patterns
Next.jsContext APIReact

How Context API Fits Inside Modern Next.js Projects - Real-World Patterns

January 21, 2025
10 min read
Salman Izhar

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.
Get More Like This

Want articles like this in your inbox?

Join developers and founders who get practical insights on frontend, SaaS, and building better products.

S

Written by Salman Izhar

Frontend Developer specializing in React, Next.js, and building high-converting web applications.

Learn More