2025-07-21 by Remi Kristelijn
When building a modern web application, dark theme support is no longer optional—it's expected. Users spend significant time on screens, and dark themes reduce eye strain and provide a better experience in low-light environments. However, implementing a proper dark theme in Next.js with Material-UI can be tricky, especially when dealing with hardcoded colors and theme switching.
In this post, I'll walk through the challenges we faced and the solutions we implemented to create a robust dark theme system for our Next.js blog.
Our initial implementation had several issues:
The main issue was that we were trying to use MUI v7's experimental CSS variables approach, but our setup wasn't compatible. The Experimental_CssVarsProvider was deprecated, and we needed a different approach.
1// ❌ This doesn't work properly with MUI v7 2import { Experimental_CssVarsProvider as CssVarsProvider } from '@mui/material/styles'; 3 4export default function ThemeRegistry({ children }: { children: React.ReactNode }) { 5 return ( 6 <CssVarsProvider theme={theme}> 7 <CssBaseline /> 8 {children} 9 </CssVarsProvider> 10 ); 11}
We implemented a custom theme context that provides full control over theme switching and ensures all components use theme-aware colors.
1// src/components/ThemeRegistry.tsx 2import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; 3import { ThemeProvider, createTheme } from '@mui/material/styles'; 4 5// Theme context for managing theme state 6const ThemeContext = createContext<{ 7 mode: 'light' | 'dark'; 8 toggleTheme: () => void; 9}>({ 10 mode: 'light', 11 toggleTheme: () => {}, 12}); 13 14export const useTheme = () => useContext(ThemeContext);
1function ThemeProviderWrapper({ children }: { children: ReactNode }) { 2 const [mode, setMode] = useState<'light' | 'dark'>('light'); 3 4 useEffect(() => { 5 // Load theme preference from localStorage 6 const savedMode = localStorage.getItem('theme-mode') as 'light' | 'dark'; 7 if (savedMode && (savedMode === 'light' || savedMode === 'dark')) { 8 setMode(savedMode); 9 } else { 10 // Check system preference 11 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 12 setMode(prefersDark ? 'dark' : 'light'); 13 } 14 }, []); 15 16 const toggleTheme = () => { 17 const newMode = mode === 'light' ? 'dark' : 'light'; 18 setMode(newMode); 19 localStorage.setItem('theme-mode', newMode); 20 }; 21 22 // Create theme based on current mode 23 const theme = createTheme({ 24 palette: { 25 mode, 26 primary: { 27 main: '#1976d2', 28 light: '#42a5f5', 29 dark: '#1565c0', 30 contrastText: '#ffffff', 31 }, 32 secondary: { 33 main: '#dc004e', 34 light: '#ff5983', 35 dark: '#9a0036', 36 contrastText: '#ffffff', 37 }, 38 background: { 39 default: mode === 'light' ? '#fafafa' : '#121212', 40 paper: mode === 'light' ? '#ffffff' : '#1e1e1e', 41 }, 42 text: { 43 primary: mode === 'light' ? 'rgba(0, 0, 0, 0.87)' : '#ffffff', 44 secondary: mode === 'light' ? 'rgba(0, 0, 0, 0.6)' : 'rgba(255, 255, 255, 0.7)', 45 }, 46 }, 47 // ... typography and shape configurations 48 }); 49 50 return ( 51 <ThemeContext.Provider value={{ mode, toggleTheme }}> 52 <ThemeProvider theme={theme}> 53 <CssBaseline /> 54 {children} 55 </ThemeProvider> 56 </ThemeContext.Provider> 57 ); 58}
1// ❌ Hardcoded color that doesn't adapt 2<Box component="footer" sx={{ bgcolor: 'grey.100', py: 4 }}>
1// ✅ Theme-aware color that adapts 2<Box component="footer" sx={{ bgcolor: 'background.paper', py: 4 }}>
1// ❌ Hardcoded light background 2'& code': { 3 backgroundColor: '#f5f5f5', 4 padding: '0.125rem 0.25rem', 5 borderRadius: '0.25rem', 6 fontFamily: 'monospace', 7 fontSize: '0.875rem' 8}, 9'& pre': { 10 backgroundColor: '#f5f5f5', 11 padding: '1rem', 12 borderRadius: '0.5rem', 13 overflow: 'auto', 14 mb: 1.5 15}
1// ✅ Theme-aware code styling 2'& code': { 3 backgroundColor: 'action.hover', 4 color: 'text.primary', 5 padding: '0.125rem 0.25rem', 6 borderRadius: '0.25rem', 7 fontFamily: 'monospace', 8 fontSize: '0.875rem' 9}, 10'& pre': { 11 backgroundColor: 'background.paper', 12 border: 1, 13 borderColor: 'divider', 14 padding: '1rem', 15 borderRadius: '0.5rem', 16 overflow: 'auto', 17 mb: 1.5, 18 '& code': { 19 backgroundColor: 'transparent', 20 padding: 0, 21 borderRadius: 0, 22 color: 'text.primary' 23 } 24}
1// ❌ This wasn't working properly 2import { useColorScheme } from '@mui/material'; 3 4export default function ThemeToggle() { 5 const { mode, setMode } = useColorScheme(); 6 // ... complex logic that wasn't working 7}
1// ✅ Simple and reliable 2import { useTheme } from './ThemeRegistry'; 3 4export default function ThemeToggle() { 5 const { mode, toggleTheme } = useTheme(); 6 7 return ( 8 <Tooltip title={`Switch to ${mode === 'light' ? 'dark' : 'light'} mode`}> 9 <IconButton onClick={toggleTheme} color="inherit"> 10 {mode === 'light' ? <DarkModeIcon /> : <LightModeIcon />} 11 </IconButton> 12 </Tooltip> 13 ); 14}
Always use MUI's theme tokens instead of hardcoded colors:
color: 'text.primary' instead of color: '#000000'backgroundColor: 'background.paper' instead of backgroundColor: '#ffffff'borderColor: 'divider' instead of borderColor: '#e0e0e0'MUI provides semantic color names that automatically adapt:
text.primary - Main text colortext.secondary - Secondary text colorbackground.default - Page backgroundbackground.paper - Card/component backgroundaction.hover - Hover state backgrounddivider - Border/divider colorAlways test your components in both light and dark modes to ensure:
Don't mix different theme approaches in the same application. Stick to one method consistently.
Always persist user theme preferences to localStorage for better UX.
Respect the user's system theme preference as the default.
Never use hardcoded colors in components. Always use theme tokens.
Consider adding theme tests to your test suite:
1test('theme toggle changes mode', () => { 2 render(<ThemeToggle />); 3 const toggle = screen.getByRole('button'); 4 fireEvent.click(toggle); 5 expect(localStorage.getItem('theme-mode')).toBe('dark'); 6});
Implementing a proper dark theme in Next.js with Material-UI requires careful attention to detail and avoiding common pitfalls. By using a custom theme context, theme-aware colors, and proper testing, we created a robust theme system that provides an excellent user experience.
The key takeaways are:
With these principles in place, your application will provide a consistent and accessible experience across all themes and user preferences.
Now that we have a solid theme foundation, we could enhance it further with:
The foundation we've built makes these enhancements much easier to implement in the future.