Theme Switcher in Next.js

Toggle between dark and light theme without wrapping children in context

5 min read

In web applications, theming enhances user experience by offering personalization and visual appeal. Typically, a ThemeProvider context manages themes throughout the application. This works well for fully client-side apps. However, developers need a solution for apps that combine client and server components, where theming depends on color-schema or className.

With , wrapping children in a Context automatically makes them client components. This blog post explores how to implement a ThemeProvider that avoids the need to wrap the entire application in a context provider, addressing the challenges of mixed client-server environments.

Generally speaking; implementing a theme switcher involves three main steps:

  1. Create Theme Provider Component: Develop a custom ThemeProvider component that handles theme switching and cookie management
  2. Use Theme Switcher: Integrate the ThemeProvider component into your application to switch theme.
  3. Integrate Theme Provider: Integrate the ThemeProvider component into your application layout to make sure client theming is synced with server on hydration.

The old way

1. Create Theme Provider Component

In a typical React application, you would wrap the entire application in a ThemeProvider context provider. This approach works well for client-side applications but can be cumbersome for Next.js applications that combine client and server components.

'use client';
 
import { ThemeProvider } from 'next-themes';
 
import siteMetadata from '@/data/meta/metadata';
 
export function ThemeProviders({ children }) {
  return (
    <ThemeProvider attribute='class' defaultTheme={ siteMetadata.theme }>
      {children}
    </ThemeProvider>
  );
}
ThemeProviders.js

2. Use Theme Switcher

To accompany the ThemeProvider, you would need a ThemeSwitcher component to toggle between themes. You can already see the relianse on useTheme hook from next-themes package to get the current theme and toggle it.

import { useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
 
import IconDarkMode from '@/static/icons/darkMode.svg';
import IconLightMode from '@/static/icons/lightMode.svg';
 
const ThemeSwitch = () => {
  const [ mounted, setMounted ] = useState(false);
  const { theme, setTheme, resolvedTheme } = useTheme();
 
  useEffect(() => setMounted(true), []);
 
  return (
    <button type='button' onClick={ () => setTheme(theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark') }>
        {mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (<IconDarkMode />) : (<IconLightMode />)}
    </button>
  );
};
 
export default ThemeSwitch;
ThemeSwitcher.js

3. Integrate Theme Provider

What happens now is that in our application we will need to wrap all of our content with the ThemeProvider as shown below.

...
import { ThemeProviders } from '@/components/utils/ThemeProviders';
...
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeProviders>
            <main className='mb-8'>{children}</main>
        </ThemeProviders>
      </body>
    </html>
  );
}
App.js

The new way

1. Create Theme Provider Component

You can notice the integration of the useEffect hook to set the cookie with the theme choice. This will make sure that the theme choice is persisted even after the user refreshes the page.

'use client';
 
import { useEffect } from 'react';
import { setCookie } from 'cookies-next';
import { ThemeProvider, useTheme } from 'next-themes';
 
// Application theme provider
function AppThemeProvider({ children, ...props }) {
  return (
    <ThemeProvider enableColorScheme { ...props }>
      <AppThemeProviderHelper />
      {children}
    </ThemeProvider>
  );
}
 
function AppThemeProviderHelper() {
  const { theme } = useTheme();
 
  useEffect(() => {
    setCookie('__theme__', theme, {
      'expires': new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
      'path': '/'
    });
  }, [ theme ]);
 
  return null;
}
 
export default AppThemeProvider;
ThemeProviders.js

2. Use Theme Switcher

The ThemeSwitcher component now handles theme switching with proper hydration handling. Note that we still need a mounted state check to prevent hydration mismatches between server and client renders.

"use client";
 
import { useEffect, useState } from 'react';
import ThemeProvider from "@/components/utils/ThemeProvider";
import { RiMoonClearFill, RiSunFill } from 'react-icons/ri';
import { useTheme } from "next-themes";
 
function Provider() {
  const { setTheme, theme } = useTheme();
  const [ mounted, setMounted ] = useState(false);
 
  useEffect(() => {
    setMounted(true);
  }, []);
 
  // Prevent hydration mismatch by showing a placeholder until mounted
  if (!mounted) {
    return (
      <button type='button' style={{ 'outline': 'none' }}>
        <RiSunFill />
      </button>
    );
  }
 
  return (
    <button type='button' style={{ 'outline': 'none' }} onClick={ () => setTheme(theme === 'dark' ? 'light' : 'dark') }>
        {theme === 'dark'  ? (<RiMoonClearFill />) : (<RiSunFill />)}
    </button>
  );
}
 
function ThemeSwitch() {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
      <Provider />
    </ThemeProvider>
  );
}
 
export default ThemeSwitch;
ThemeSwitcher.js

3. Integrate Theme Provider

In the layout container, the ThemeProvider is dynamically imported to ensure client-side rendering. A key improvement is reading the theme cookie on the server side to prevent flash of unstyled content (FOUC) during initial page load. This approach maintains theme consistency between server and client renders.

import dynamic from "next/dynamic";
import { cookies } from 'next/headers';
import siteMetadata from '@/data/meta/metadata';
...
 
const ThemeProvider = dynamic(() => import("@/components/utils/ThemeProvider"));
 
export default async function LayoutContainer({ children }) {
  // Read theme from cookie, fallback to site default
  const themeCookie = await cookies();
  const theme = themeCookie.get("__theme__")?.value || siteMetadata.theme;
 
  return (
    <>
     <ThemeProvider attribute="class" defaultTheme={theme} enableSystem/>
     <main>{children}</main>
    </>
  );
}
layoutContainer.js

Key Benefits

This approach provides several advantages:

  1. No Full App Client Wrapping: Unlike traditional approaches, you don't need to wrap your entire application in a client component
  2. Server-Side Theme Detection: Reading the cookie on the server ensures the correct theme is applied immediately
  3. No FOUC: Prevents the flash of incorrect theme on initial load
  4. Persistent Theme Selection: Themes persist across sessions via cookies
  5. Hydration Safety: Proper handling of mounted state prevents hydration mismatches

Common Gotchas

When implementing this pattern, watch out for:

  • Cookie Reading: Make sure to use await cookies() in Next.js 15+ as cookies are now async
  • Dynamic Import: Don't set ssr: false on the ThemeProvider dynamic import - we want SSR but with the correct theme from cookies
  • Hydration Mismatches: Always check mounted state before rendering theme-dependent content in your theme switcher
  • Default Theme: Have a sensible fallback theme in case the cookie doesn't exist
The opinions and views expressed on this blog are solely my own and do not reflect the opinions, views, or positions of my employer or any affiliated organizations. All content provided on this blog is for informational purposes only.
Theme Switcher in Next.js | Ahmad Assaf's Personal Space