Theme Switcher in Next.js
Toggle between dark and light theme without wrapping children in context
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:
- Create Theme Provider Component: Develop a custom
ThemeProvider
component that handles theme switching and cookie managementThis will be needed to persist theme choice - Use Theme Switcher: Integrate the
ThemeProvider
component into your application to switch theme. - 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>
);
}
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;
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>
);
}
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;
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;
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>
</>
);
}
Key Benefits
This approach provides several advantages:
- No Full App Client Wrapping: Unlike traditional approaches, you don't need to wrap your entire application in a client component
- Server-Side Theme Detection: Reading the cookie on the server ensures the correct theme is applied immediately
- No FOUC: Prevents the flash of incorrect theme on initial load
- Persistent Theme Selection: Themes persist across sessions via cookies
- 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