2025-07-21 by Remi Kristelijn
When refreshing the page or navigating between routes, users experienced a brief flash of white background before the theme switched to their preferred dark mode. This is a common issue known as Flash of Unstyled Content (FOUC).
The flash occurs because:
localStorage, which isn't available during SSRThe most effective solution is using MUI's CSS theme variables approach, which prevents SSR flickering by using CSS variables instead of JavaScript-based theme switching.
Update src/lib/theme.ts to use MUI's CSS variables:
import { createTheme } from '@mui/material/styles'; const theme = createTheme({ cssVariables: { colorSchemeSelector: 'class', // Use class-based theme switching }, colorSchemes: { light: { palette: { primary: { main: '#1976d2', light: '#42a5f5', dark: '#1565c0', contrastText: '#ffffff', }, secondary: { main: '#dc004e', light: '#ff5983', dark: '#9a0036', contrastText: '#ffffff', }, background: { default: '#fafafa', paper: '#ffffff', }, text: { primary: 'rgba(0, 0, 0, 0.87)', secondary: 'rgba(0, 0, 0, 0.6)', }, }, }, dark: { palette: { primary: { main: '#90caf9', light: '#e3f2fd', dark: '#42a5f5', contrastText: '#000000', }, secondary: { main: '#f48fb1', light: '#f8bbd9', dark: '#ec407a', contrastText: '#000000', }, background: { default: '#121212', paper: '#1e1e1e', }, text: { primary: '#ffffff', secondary: 'rgba(255, 255, 255, 0.7)', }, }, }, }, // ... typography and other theme options }); export default theme;
Update src/components/ThemeRegistry.tsx to use Experimental_CssVarsProvider:
'use client'; import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter'; import { Experimental_CssVarsProvider as CssVarsProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import { useColorScheme } from '@mui/material/styles'; import { createContext, useContext, useEffect, ReactNode } from 'react'; import theme from '../lib/theme'; // Theme context for managing theme state const ThemeContext = createContext<{ mode: 'light' | 'dark'; toggleTheme: () => void; }>({ mode: 'light', toggleTheme: () => {}, }); export const useTheme = () => useContext(ThemeContext); // Theme provider component that manages theme state function ThemeProviderWrapper({ children }: { children: ReactNode }) { const { mode, setMode } = useColorScheme(); useEffect(() => { // Load theme preference from localStorage const savedMode = localStorage.getItem('theme-mode') as 'light' | 'dark'; if (savedMode && (savedMode === 'light' || savedMode === 'dark')) { setMode(savedMode); } else { // Check system preference const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; setMode(prefersDark ? 'dark' : 'light'); } }, [setMode]); const toggleTheme = () => { const newMode = mode === 'light' ? 'dark' : 'light'; setMode(newMode); localStorage.setItem('theme-mode', newMode); }; return ( <ThemeContext.Provider value={{ mode: (mode === 'system' ? 'light' : mode) || 'light', toggleTheme }}> {children} </ThemeContext.Provider> ); } export default function ThemeRegistry({ children }: { children: React.ReactNode }) { return ( <AppRouterCacheProvider> <CssVarsProvider theme={theme} defaultColorScheme="light"> <ThemeProviderWrapper> <CssBaseline /> {children} </ThemeProviderWrapper> </CssVarsProvider> </AppRouterCacheProvider> ); }
Add a script in src/app/layout.tsx to detect and apply the theme before any content renders:
// src/app/layout.tsx <html lang="en" suppressHydrationWarning> <head> <meta name="emotion-insertion-point" content="" /> <script dangerouslySetInnerHTML={{ __html: ` (function() { try { var mode = localStorage.getItem('theme-mode'); if (!mode) { var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; mode = prefersDark ? 'dark' : 'light'; } document.documentElement.classList.add('mui-' + mode); } catch (e) { document.documentElement.classList.add('mui-light'); } })(); `, }} /> </head> <body> {/* ... rest of layout */} </body> </html>
Important: The suppressHydrationWarning attribute prevents hydration errors when the script adds theme classes to the <html> element.
Create src/app/globals.css with CSS variables support:
/* Global styles for MUI CSS theme variables */ /* Initial theme setup - prevents flash of unstyled content */ html.mui-light { color-scheme: light; } html.mui-dark { color-scheme: dark; } /* Set initial background colors to prevent flash */ body { background-color: #fafafa; /* Light theme default */ transition: background-color 0.2s ease; } html.mui-dark body { background-color: #121212; /* Dark theme default */ } /* MUI CSS Variables fallbacks */ :root { --mui-palette-primary-main: #1976d2; --mui-palette-background-default: #fafafa; --mui-palette-background-paper: #ffffff; --mui-palette-text-primary: rgba(0, 0, 0, 0.87); /* ... other light theme variables */ } html.mui-dark { --mui-palette-primary-main: #90caf9; --mui-palette-background-default: #121212; --mui-palette-background-paper: #1e1e1e; --mui-palette-text-primary: #ffffff; /* ... other dark theme variables */ }
theme.palette.mode === 'dark' conditions needed<head> before any content rendersmui-light or mui-dark) to <html> elementsuppressHydrationWarning prevents errors when theme classes are added// ❌ Old approach with manual theme checking <Box sx={{ backgroundColor: theme.palette.mode === 'dark' ? '#1e1e1e' : '#ffffff', color: theme.palette.mode === 'dark' ? '#ffffff' : '#000000' }}>
// ✅ New approach with CSS variables <Box sx={{ backgroundColor: 'background.paper', color: 'text.primary' }}>
background.paper, text.primarytheme.palette.mode === 'dark' conditionssuppressHydrationWarning to prevent hydration errorsBy implementing MUI's CSS theme variables approach, we've completely eliminated the FOUC issue while simplifying the codebase. The solution provides:
The key insight is that CSS variables provide immediate theme application without requiring JavaScript execution, making them the ideal solution for preventing SSR flickering in Next.js applications with Material-UI.