Theming in Next.js with Styled Components and useDarkMode

Build a robust, performant theming system with dark mode support that integrates seamlessly with Next.js server-side rendering.

Why Theming Matters in Modern Web Applications

Modern web applications demand flexible theming capabilities that go beyond simple color changes. Users increasingly expect dark mode options, and sophisticated applications often require multiple themes for branding, accessibility, or content differentiation. Implementing a robust theming system in Next.js using styled-components provides the foundation for creating maintainable, performant applications that adapt to user preferences while preserving design consistency across the entire application.

The combination of styled-components and a custom useDarkMode hook offers a powerful approach to theming that leverages React's context API for global theme access while providing individual components with scoped styling capabilities. This approach enables developers to define comprehensive design tokens that control colors, typography, spacing, and other visual properties from a centralized location, ensuring consistency throughout the application. The styled-components library's native support for dynamic styling based on props makes it particularly well-suited for implementing theme-aware components that respond to theme changes without requiring manual class switching or style recalculation.

Next.js presents unique considerations for theming implementations due to its server-side rendering architecture. The framework's ability to render pages on the server means that theme-related styling must be properly configured to avoid hydration mismatches and ensure consistent rendering across server and client. As noted in the LogRocket implementation guide, proper setup requires configuring a styled-components registry, implementing theme providers that work correctly during server rendering, and establishing mechanisms to prevent the dreaded flash of unstyled content that occurs when server-rendered HTML doesn't match client-side expectations.

For teams building modern web applications, understanding these theming patterns is essential for delivering professional-grade user experiences. Our web development services team regularly implements these patterns for enterprise applications requiring sophisticated theming capabilities.

In this guide, you'll learn:

  • How to configure styled-components for Next.js server-side rendering
  • Creating a comprehensive theme provider with light and dark mode support
  • Implementing the useDarkMode hook for theme state management
  • Preventing the flash of unstyled content during page loads
  • Performance optimization strategies for theme switching

Style: Professional, technically confident, focused on practical implementation

Key Theming Capabilities

Everything you need for a complete theming implementation

Server-Side Rendering Support

Properly configured styled-components registry ensures consistent styling across server and client rendering.

Theme Persistence

User preferences are saved to localStorage and restored automatically on return visits.

System Preference Detection

Automatic detection of OS-level dark mode settings for seamless user experience.

Zero Flash of Wrong Theme

Inline scripts prevent visual glitches during initial page load and hydration.

Setting Up Styled-Components Registry

Before implementing theming functionality, the styled-components library must be properly configured to work with Next.js server-side rendering. This configuration involves creating a registry component that collects styles during server-side rendering and ensures consistent style injection across page navigations. The registry pattern addresses a fundamental challenge in server-rendered React applications: ensuring that styles are collected and emitted at the correct point in the rendering lifecycle to prevent hydration issues.

The registry component should be placed at the root of the application layout, wrapping all page content to ensure that styled-components can properly track and inject styles throughout the application. This placement is critical because styled-components uses a runtime injection mechanism that must be coordinated with Next.js's server rendering to produce consistent output. Without proper registry configuration, developers may experience hydration mismatches where the server-rendered HTML differs from what the client expects after JavaScript execution, leading to visual inconsistencies and potential errors.

// lib/registry.jsx
'use client'

import React, { useState } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
import { ServerStyleSheet, StyleSheetManager } from 'styled-components'

export default function StyledComponentsRegistry({ children }) {
 const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet())

 useServerInsertedHTML(() => {
 const styles = styledComponentsStyleSheet.getStyleElement()
 styledComponentsStyleSheet.instance.clearTag()
 return <>{styles}</>
 })

 if (typeof window !== 'undefined') return <>{children}</>

 return (
 <StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
 {children}
 </StyleSheetManager>
 )
}

Why the Registry Matters

This registry implementation demonstrates several key patterns for Next.js and styled-components integration. The useServerInsertedHTML hook from Next.js provides a mechanism to inject styles at the appropriate point in the server rendering process, ensuring that styles are available when the HTML is serialized and sent to the client. The useState hook with a function initializer ensures that the ServerStyleSheet is created only once per component instance, preventing unnecessary memory allocation and style duplication.

The StyleSheetManager from styled-components allows us to provide a custom sheet instance for style collection, giving fine-grained control over how styles are managed during server rendering. This approach is superior to relying on styled-components' default behavior because it integrates directly with Next.js's rendering lifecycle and ensures that styles are collected and emitted at the optimal point in the HTML generation process. The conditional check for the browser environment prevents unnecessary wrapping during client-side rendering while ensuring that server-side rendering receives the proper style collection infrastructure.

Key Configuration Points

  • ServerStyleSheet: Captures styles during SSR
  • useServerInsertedHTML: Next.js hook for style injection timing
  • StyleSheetManager: Provides custom sheet to styled-components
  • Mounting guard: Prevents double-wrapping on client

To learn more about server-side rendering patterns in Next.js, see our guide on static site generation with React.

Creating the Theme Provider

The theme provider serves as the central hub for theme state and configuration in a styled-components-based application. This provider must manage theme state, handle theme switching logic, and make theme values available to all styled components throughout the application. A well-designed theme provider goes beyond simple state management to include persistence, system preference detection, and smooth transitions between themes.

The provider should define a comprehensive theme object that contains all design tokens needed by the application. These tokens typically include color palettes for primary, secondary, and semantic colors, typography scales, spacing values, breakpoints, and other design decisions that maintain consistency across the application. Organizing these tokens in a structured object makes it easy to modify the application's visual appearance by changing values in a single location rather than searching through individual component styles.

// providers/ThemeProvider.jsx
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import { ThemeProvider as StyledThemeProvider } from 'styled-components'

const lightTheme = {
 colors: {
 background: '#ffffff',
 text: '#1a1a1a',
 primary: '#0066cc',
 secondary: '#6c757d',
 success: '#28a745',
 error: '#dc3545',
 warning: '#ffc107',
 surface: '#f8f9fa',
 border: '#dee2e6',
 muted: '#6c757d',
 },
 fonts: {
 body: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
 heading: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
 mono: 'SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace',
 },
 fontSizes: {
 xs: '0.75rem',
 sm: '0.875rem',
 base: '1rem',
 lg: '1.125rem',
 xl: '1.25rem',
 '2xl': '1.5rem',
 '3xl': '1.875rem',
 '4xl': '2.25rem',
 },
 spacing: {
 xs: '0.25rem',
 sm: '0.5rem',
 md: '1rem',
 lg: '1.5rem',
 xl: '2rem',
 '2xl': '3rem',
 },
 breakpoints: {
 sm: '576px',
 md: '768px',
 lg: '992px',
 xl: '1200px',
 },
 transitions: {
 default: '0.2s ease-in-out',
 slow: '0.3s ease-in-out',
 },
}

const darkTheme = {
 ...lightTheme,
 colors: {
 background: '#1a1a1a',
 text: '#f8f9fa',
 primary: '#4dabf7',
 secondary: '#adb5bd',
 success: '#51cf66',
 error: '#ff6b6b',
 warning: '#fcc419',
 surface: '#2d2d2d',
 border: '#495057',
 muted: '#adb5bd',
 },
}

const ThemeContext = createContext()

export function ThemeProvider({ children }) {
 const [theme, setTheme] = useState('light')
 const [mounted, setMounted] = useState(false)

 useEffect(() => {
 setMounted(true)
 const savedTheme = localStorage.getItem('theme')
 if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
 setTheme(savedTheme)
 } else {
 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
 setTheme(prefersDark ? 'dark' : 'light')
 }
 }, [])

 const toggleTheme = () => {
 const newTheme = theme === 'light' ? 'dark' : 'light'
 setTheme(newTheme)
 localStorage.setItem('theme', newTheme)
 }

 const value = {
 theme,
 toggleTheme,
 isDark: theme === 'dark',
 }

 if (!mounted) {
 return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
 }

 return (
 <ThemeContext.Provider value={value}>
 <StyledThemeProvider theme={theme === 'light' ? lightTheme : darkTheme}>
 {children}
 </StyledThemeProvider>
 </ThemeContext.Provider>
 )
}

export function useTheme() {
 const context = useContext(ThemeContext)
 if (!context) {
 throw new Error('useTheme must be used within a ThemeProvider')
 }
 return context
}

Theme Object Structure

The theme objects are structured to provide semantic tokens rather than hardcoded values, allowing for easy creation of additional themes or theme variations. The spread operator inherits common values from the light theme while overriding color-specific properties for the dark theme, reducing duplication and ensuring consistency in the token structure. This approach makes it straightforward to add new themes that share most characteristics with existing themes while modifying specific aspects.

Theme Context and Hook

The useEffect hook handles theme initialization by first checking for a saved user preference in localStorage and falling back to the system's color scheme preference if no saved value exists. This two-tier approach respects user choices while providing a sensible default for new visitors. The mounting state prevents hydration mismatches by ensuring that the theme is only applied after the component has mounted in the browser environment.

Theme Tokens Covered:

  • colors: Semantic color palette with light/dark variants
  • fonts: Font family definitions for body, heading, mono
  • fontSizes: Consistent typographic scale
  • spacing: Unified spacing system
  • breakpoints: Responsive design thresholds
  • transitions: Smooth animation timing

For more context on React state management patterns, see our guide on using state machines with Xstate and React.

Implementing the useDarkMode Hook

The useDarkMode hook encapsulates the dark mode state management logic, providing a clean API for components to interact with theme state. This separation of concerns makes the theme logic reusable across different parts of the application and keeps components focused on presentation rather than state management. The hook handles the initial state determination, provides methods for changing the theme, persists user preferences, and synchronizes with system preference changes when no explicit user preference exists.

// hooks/useDarkMode.js
import { useEffect, useState, useCallback, useRef } from 'react'

export function useDarkMode() {
 const [theme, setTheme] = useState(() => {
 if (typeof window === 'undefined') {
 return 'light'
 }

 const savedTheme = localStorage.getItem('theme')
 if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
 return savedTheme
 }

 return window.matchMedia('(prefers-color-scheme: dark)').matches
 ? 'dark'
 : 'light'
 })

 const prefersDarkRef = useRef(
 typeof window !== 'undefined'
 ? window.matchMedia('(prefers-color-scheme: dark)').matches
 : false
 )

 const setMode = useCallback((mode) => {
 localStorage.setItem('theme', mode)
 setTheme(mode)
 }, [])

 const toggleTheme = useCallback(() => {
 setMode(theme === 'light' ? 'dark' : 'light')
 }, [theme, setMode])

 useEffect(() => {
 const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')

 const handleChange = (e) => {
 if (!localStorage.getItem('theme')) {
 const newMode = e.matches ? 'dark' : 'light'
 setTheme(newMode)
 }
 }

 mediaQuery.addEventListener('change', handleChange)
 return () => mediaQuery.removeEventListener('change', handleChange)
 }, [])

 return {
 theme,
 isDark: theme === 'dark',
 toggleTheme,
 setTheme: setMode,
 }
}

State Initialization Strategy

The hook implements a three-tier initialization strategy that prioritizes server-side rendering safety, local storage preference, and system preference fallback. During SSR, the hook defaults to 'light' to prevent hydration mismatches. On the client, it first checks for a saved user preference before falling back to the system's color scheme preference. This order matters because it respects explicit user choices while providing a sensible default for new visitors.

System Preference Synchronization

The useRef captures the initial system preference, allowing the hook to detect when system preferences change while respecting explicit user overrides stored in localStorage. The event listener for system preference changes is properly cleaned up when the component unmounts, preventing memory leaks. This approach ensures that users who have explicitly chosen a theme maintain that preference even when their system theme changes, while new visitors automatically follow their system preference until they make an explicit choice.

Return Value API

The hook returns a simple, focused API that provides components with everything they need to interact with the theme system:

  • theme: The current theme string ('light' or 'dark')
  • isDark: A boolean indicating if dark mode is active
  • toggleTheme: A function to switch between light and dark
  • setTheme: A function to explicitly set any theme value

To deepen your understanding of React hooks and state management, explore our guide on understanding Promise.all in JavaScript.

Preventing Flash of Unstyled Content

One of the most challenging aspects of implementing dark mode in a server-rendered application is preventing the flash of incorrect theme colors during page load. This occurs because server-rendered HTML uses one theme (typically the default light theme) while client-side JavaScript may immediately apply a different theme based on user preferences or system settings. The visual discontinuity is jarring and can significantly impact user experience.

The solution involves synchronizing theme application across server and client rendering by using inline scripts in the document head to set the correct theme before React hydrates. This approach provides immediate theme application without blocking page rendering.

// components/ThemeLoader.jsx
'use client'
import { useEffect, useState } from 'react'
import { ThemeProvider } from '@/providers/ThemeProvider'

export default function ThemeLoader({ children }) {
 const [isMounted, setIsMounted] = useState(false)

 useEffect(() => {
 setIsMounted(true)
 }, [])

 if (!isMounted) {
 return <div style={{ visibility: 'hidden' }}>{children}</div>
 }

 return <ThemeProvider>{children}</ThemeProvider>
}
// app/layout.jsx
import StyledComponentsRegistry from '@/lib/registry'
import ThemeLoader from '@/components/ThemeLoader'

export default function RootLayout({ children }) {
 return (
 <html lang="en" suppressHydrationWarning>
 <head>
 <script
 dangerouslySetInnerHTML={{
 __html: `
 (function() {
 try {
 var theme = localStorage.getItem('theme')
 if (theme && (theme === 'dark' || theme === 'light')) {
 document.documentElement.classList.add(theme)
 } else {
 var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
 document.documentElement.classList.add(prefersDark ? 'dark' : 'light')
 }
 } catch (e) {}
 })()
 `,
 }}
 />
 </head>
 <body>
 <StyledComponentsRegistry>
 <ThemeLoader>{children}</ThemeLoader>
 </StyledComponentsRegistry>
 </body>
 </html>
 )
}

ThemeLoader Component Pattern

The ThemeLoader component prevents hydration mismatches by tracking the mounted state. During server rendering and initial client render, it renders children with visibility hidden to avoid FOUC. Once mounted, it wraps children in the ThemeProvider. This pattern ensures that theme-dependent styling only applies after React has hydrated, while the inline script handles the visual aspect.

Why suppressHydrationWarning?

The suppressHydrationWarning prop on the html element acknowledges that Next.js cannot fully control the html element's classes when using inline scripts that modify DOM attributes. This prevents console warnings about hydration mismatches while allowing the inline script to set theme classes before React hydrates.

Benefits of this approach:

  • No visible theme flash on page load
  • Works with CSS custom properties and class-based styling
  • Graceful error handling with try-catch
  • Compatible with SSR and client hydration
  • Respects user preferences saved in localStorage

For more on building React applications with Next.js, see our guide on what's new in Next.js 12.

Using Theme Props in Styled Components

Once the theme infrastructure is in place, styled-components can leverage the theme context through the theme prop, which is automatically passed to all styled components. This automatic theme injection eliminates the need to manually pass theme objects to individual components and enables deep theme-aware styling throughout the component hierarchy. Components can access any property from the theme object by referencing it through the theme prop that styled-components provides.

// components/Button.jsx
import styled, { css } from 'styled-components'

const Button = styled.button`
 padding: ${({ theme }) => `${theme.spacing.sm} ${theme.spacing.md}`};
 font-size: ${({ theme }) => theme.fontSizes.base};
 font-family: ${({ theme }) => theme.fonts.body};
 border-radius: 4px;
 border: none;
 cursor: pointer;
 transition: all ${({ theme }) => theme.transitions.default};

 ${({ theme, $variant = 'primary' }) => {
 const variants = {
 primary: css`
 background-color: ${theme.colors.primary};
 color: white;
 &:hover:not(:disabled) {
 background-color: ${theme.colors.primary}dd;
 }
 `,
 secondary: css`
 background-color: ${theme.colors.surface};
 color: ${theme.colors.text};
 border: 1px solid ${theme.colors.border};
 &:hover:not(:disabled) {
 background-color: ${theme.colors.border}44;
 }
 `,
 danger: css`
 background-color: ${theme.colors.error};
 color: white;
 &:hover:not(:disabled) {
 background-color: ${theme.colors.error}dd;
 }
 `,
 }
 return variants[$variant] || variants.primary
 }}

 &:disabled {
 opacity: 0.6;
 cursor: not-allowed;
 }

 @media (max-width: ${({ theme }) => theme.breakpoints.md}) {
 padding: ${({ theme }) => `${theme.spacing.xs} ${theme.spacing.sm}`};
 font-size: ${({ theme }) => theme.fontSizes.sm};
 }
`

export default Button

Automatic Theme Injection

Styled-components automatically passes the theme prop to all styled components when ThemeProvider wraps the application. Within any styled component definition, the theme object is available as the first argument to the function or directly in template literals. This means you can access any token defined in your theme object, whether it's colors, spacing, typography, or custom properties.

Variant-Based Styling

The pattern for creating reusable styled components with variants uses prop-based conditional styling. The variant prop determines which visual treatment is applied, with each variant definition containing CSS that references theme tokens. This approach keeps styling logic centralized and makes it easy to add new variants or modify existing ones.

Responsive Theming

Theme breakpoints can be used directly in media queries within styled component definitions. This ensures consistent responsive behavior across the application since all components reference the same breakpoint values from the theme. When breakpoints need to change, updating the theme object automatically updates all components that use those breakpoints.

For more on building consistent UI patterns, see our guide on parallelism and async programming in Node.js which covers similar architectural patterns for state management.

Performance Optimization Strategies

Theme implementations can impact application performance through additional re-renders during theme changes, increased bundle size from theme infrastructure code, and runtime overhead from theme object property access. Optimizing these aspects requires thoughtful architecture that minimizes unnecessary work while maintaining the flexibility that theming provides.

// optimization example: stable theme references
const themes = {
 light: { /* theme properties */ },
 dark: { /* theme properties */ },
}

import { memo } from 'react'

const MemoizedThemeProvider = memo(function ThemeProvider({ children }) {
 // Provider implementation
})

const currentTheme = themes[themeMode]

Stable Theme References

The theme object itself should be stable between theme switches to prevent object reference changes from triggering unnecessary re-renders. Define themes as constant objects outside component render functions rather than creating new objects on each render. This prevents the reference check that React uses to determine if re-renders are needed from triggering updates when the theme hasn't actually changed in a meaningful way.

Memoization Techniques

React.memo can prevent unnecessary re-renders when theme context changes, ensuring that only components that actually use theme-dependent styles are updated. The theme provider itself can be memoized to prevent re-rendering all child components when the provider re-renders but the theme value hasn't substantively changed. Consider using the equality function parameter to customize when re-renders occur.

Bundle Size Considerations

Styled-components adds to the bundle size, though the runtime is relatively small compared to the capabilities it provides. For applications where bundle size is critical, consider whether all styled-components features are needed or if CSS custom properties might provide a lighter-weight alternative for simpler theming requirements. The Next.js CSS-in-JS documentation provides additional guidance on balancing functionality and performance.

Performance checklist:

  • Define themes as constants, not function results
  • Memoize theme provider to prevent re-renders
  • Avoid creating theme objects inside render
  • Consider CSS custom properties for simple themes
  • Profile theme switching with React DevTools

For related performance topics, see our guide on optimizing video backgrounds with CSS and JavaScript which covers similar optimization techniques for visual effects.

Organizing Theme Files for Maintainability

As applications grow, theme configurations can become unwieldy if all tokens are defined in a single file or scattered across multiple locations. Establishing a consistent organization pattern for theme files ensures that developers can easily locate and modify theme values, add new themes, and understand the relationship between different theme tokens. Grouping related tokens into logical categories such as colors, typography, spacing, and semantic tokens creates clear boundaries within the theme structure.

src/
├── themes/
│ ├── index.js # Main theme exports
│ ├── tokens/
│ │ ├── colors.js # Color palette definitions
│ │ ├── typography.js # Font and text styling
│ │ ├── spacing.js # Spacing scale
│ │ └── breakpoints.js # Responsive breakpoints
│ ├── schemes/
│ │ ├── light.js # Light theme tokens
│ │ ├── dark.js # Dark theme tokens
│ │ └── custom.js # Additional theme schemes
│ └── utils/
│ ├── mixins.js # Reusable style mixins
│ └── helpers.js # Theme value helpers
├── providers/
│ └── ThemeProvider.jsx # Theme context provider
└── hooks/
 └── useDarkMode.js # Theme state hook

Scalability Benefits

This structure supports adding new themes by simply adding new files to the schemes directory and exporting them from the main index. Token modifications become straightforward since each token type has its dedicated file. The separation between tokens (atomic design decisions) and schemes (how those tokens combine into complete themes) makes it easy to create variations without duplicating entire theme definitions.

TypeScript Integration

TypeScript users can leverage type augmentation to add custom theme properties with full type safety, preventing runtime errors from misspelled theme property access. The theme interface can be extended through declaration merging, adding new properties while maintaining compatibility with existing code that relies on the base theme structure.

// types/styled.d.ts
import 'styled-components'

declare module 'styled-components' {
 export interface DefaultTheme {
 colors: {
 background: string
 text: string
 primary: string
 secondary: string
 success: string
 error: string
 warning: string
 surface: string
 border: string
 muted: string
 }
 fonts: {
 body: string
 heading: string
 mono: string
 }
 fontSizes: {
 [key: string]: string
 }
 spacing: {
 [key: string]: string
 }
 breakpoints: {
 [key: string]: string
 }
 transitions: {
 [key: string]: string
 }
 }
}

For applications using Node.js, see our guide on how to use ECMAScript modules with Node.js which covers modern module patterns that work well with theme file organization.

Frequently Asked Questions

Ready to Build Your Custom Web Application?

Our team specializes in modern web development with Next.js, implementing best practices for performance, accessibility, and user experience.

Sources

  1. LogRocket: Theming in Next.js with styled-components and useDarkMode - Comprehensive guide covering the complete setup of styled-components with a custom useDarkMode hook, including server-side rendering considerations and theme persistence
  2. Next.js CSS-in-JS Documentation - Official documentation on using CSS-in-JS libraries including styled-components with the App Router, covering configuration requirements and runtime considerations