Skip to content
Engineering

Theme Switcher in Next.js

Cookie-persisted theming that keeps server components on the server

Before getting into the implementation, one misconception is worth clearing up because I believed it for a while: wrapping children in a client context provider does not turn those children into client components. When children are passed as props through a client component, they stay server components; this is the composition pattern Next.js has documented since version 13. So the reason to rethink the classic "wrap the app in a ThemeProvider" setup is not that it drags your whole tree onto the client.

The real problems are more practical:

  • Flash of unstyled content (FOUC): if the theme is only resolved on the client (from localStorage or the system preference), the server renders the default theme and the page visibly flips once the client corrects it
  • Server-side rendering: for the server to render the right theme on first paint, the visitor's choice has to live somewhere the server can read. That means a cookie, not localStorage
  • Hydration mismatches: any component that renders differently per theme needs guarding, or React will complain that server and client markup disagree

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 a ThemeSwitcher component into your application to toggle the theme.
  3. Integrate Theme Provider: Wire the ThemeProvider into your application layout so the client theme is synced with the 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 backed by :

ThemeProviders.js
jsx
'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 reliance on the useTheme hook from the next-themes package to get the current theme and toggle it.

ThemeSwitcher.js
jsx
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.

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

This works, but the theme choice lives only on the client. On a hard refresh the server has no idea what the visitor picked, so it renders the default theme and the page flashes when next-themes kicks in.

The new way

This is the setup this blog runs on today (Next.js 15). The provider still wraps the layout, but two things change: the theme choice is persisted to a cookie so the server can read it, and the layout is a server component that resolves the theme before rendering. The children it wraps remain server components.

1. Create Theme Provider Component

The provider is a thin wrapper around next-themes with a helper component that writes the current theme to a cookie whenever it changes. This makes sure the choice is persisted across sessions and, more importantly, is visible to the server:

ThemeProvider.js
jsx
'use client';
import { useEffect } from 'react';import { setCookie } from 'cookies-next';import { ThemeProvider, useTheme } from 'next-themes';
// Application theme providerfunction 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 no longer creates any provider of its own; it simply consumes the shared context from the app-level ThemeProvider above. We still need a mounted-state check to prevent hydration mismatches, since the button's icon depends on the resolved theme:

ThemeSwitcher.js
jsx
'use client';
import { useEffect, useState } from 'react';import { useTheme } from 'next-themes';import { RiMoonClearFill, RiSunFill } from 'react-icons/ri';
function ThemeSwitch() { 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' aria-label='Toggle Dark Mode'> <RiSunFill /> </button> ); }
return ( <button type='button' aria-label='Toggle Dark Mode' onClick={ () => setTheme(theme === 'dark' ? 'light' : 'dark') }> {theme === 'dark' ? (<RiMoonClearFill />) : (<RiSunFill />)} </button> );}
export default ThemeSwitch;

This sample is simplified for the post; it uses react-icons for brevity, while the production component in my design system renders its own Icon set.

3. Integrate Theme Provider

The layout container is a server component. It reads the theme cookie with await cookies() (cookies are async in Next.js 15) and passes the value as the provider's default theme, so the very first server render already has the correct theme and there is no flash on load. The provider is dynamically imported and wraps the children; since they are passed through as props, they stay server components:

LayoutContainer.js
jsx
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 cookieStore = await cookies(); const theme = cookieStore.get('__theme__')?.value || siteMetadata.theme || 'system';
return ( <ThemeProvider attribute='class' defaultTheme={ theme } enableSystem disableTransitionOnChange> <main>{children}</main> </ThemeProvider> );}

Key Benefits

This approach provides several advantages:

  1. Server Components Stay Server Components: children passed through the client provider are not converted to client components; only the provider itself ships to the client
  2. Server-Side Theme Detection: reading the cookie on the server ensures the correct theme is applied on the very first render
  3. No FOUC: prevents the flash of the incorrect theme on initial load
  4. Persistent Theme Selection: the choice persists across sessions via the cookie
  5. Hydration Safety: the mounted-state check in the switcher 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 the cookies API is now async
  • Dynamic Import: don't set ssr: false on the ThemeProvider dynamic import. We want SSR, just with the correct theme from the cookie
  • 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.