Fixing Theme Flash (FOUC) in Next.js with Material-UI CSS Variables

Fixing Theme Flash (FOUC) in Next.js with Material-UI CSS Variables

2025-07-21 by Remi Kristelijn

Fixing Theme Flash (FOUC) in Next.js with Material-UI CSS Variables

The Problem: Flash of Unstyled Content

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).

Why FOUC Happens

The flash occurs because:

  1. Server-Side Rendering: During SSR, the server doesn't know the user's theme preference
  2. localStorage Access: Theme preferences are stored in localStorage, which isn't available during SSR
  3. Hydration Mismatch: The server renders with a default theme, then the client switches to the user's preference
  4. Timing Issue: There's a brief moment between initial render and theme detection

The Solution: MUI CSS Theme Variables

The 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.

1. Theme Configuration with CSS Variables

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;

2. ThemeRegistry with CSS Variables Provider

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>
  );
}

3. Early Theme Detection Script

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.

4. Global CSS with CSS Variables

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 */
}

How This Solution Works

1. CSS Variables Approach

  • MUI automatically generates CSS variables for all theme colors
  • Components use these variables instead of JavaScript theme detection
  • No more theme.palette.mode === 'dark' conditions needed

2. Early Theme Detection

  • Script runs immediately in the <head> before any content renders
  • Detects user's theme preference from localStorage or system preference
  • Adds appropriate class (mui-light or mui-dark) to <html> element

3. Automatic Theme Application

  • CSS variables are automatically applied based on the class
  • All MUI components immediately use the correct theme
  • No JavaScript delay or theme switching logic needed

4. Hydration Safety

  • suppressHydrationWarning prevents errors when theme classes are added
  • Server and client render consistently
  • No hydration mismatches

Key Benefits

Before (Manual Theme Checking)

// ❌ Old approach with manual theme checking
<Box sx={{
  backgroundColor: theme.palette.mode === 'dark' ? '#1e1e1e' : '#ffffff',
  color: theme.palette.mode === 'dark' ? '#ffffff' : '#000000'
}}>

After (CSS Variables)

// ✅ New approach with CSS variables
<Box sx={{
  backgroundColor: 'background.paper',
  color: 'text.primary'
}}>

Testing the Implementation

Before the Fix

  • 🔴 White flash on page refresh
  • 🔴 Theme switching delay
  • 🔴 Manual theme condition checks
  • 🔴 Inconsistent SSR/CSR rendering

After the Fix

  • ✅ No flash on page refresh
  • ✅ Immediate theme application
  • ✅ Automatic CSS variable usage
  • ✅ Consistent server and client rendering

Best Practices

  1. Use Theme Tokens: Always use MUI theme tokens like background.paper, text.primary
  2. Avoid Manual Checks: Don't use theme.palette.mode === 'dark' conditions
  3. CSS Variables: Let MUI handle theme switching with CSS variables
  4. Class-Based: Use class-based theme switching for better performance
  5. Hydration Safety: Add suppressHydrationWarning to prevent hydration errors

Resources

Conclusion

By implementing MUI's CSS theme variables approach, we've completely eliminated the FOUC issue while simplifying the codebase. The solution provides:

  • Zero flickering on page refresh and navigation
  • Better performance with native CSS variables
  • Simplified code without manual theme checks
  • Consistent rendering between server and client

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.