Use MUI CSS theme variables to prevent dark-mode SSR flickering in Next.js App

May 06, 2023

/

8 min read

--- views

•

--- comments

Last Updated May 06, 2023

Introduction

When building modern web applications, dark mode has become a popular feature that many users appreciate. However, implementing dark mode in a Next.js app with Material UI (MUI) can sometimes lead to flickering issues during Server-Side Rendering (SSR). In this blog post, we'll explore how to use MUI CSS theme variables to prevent dark-mode SSR flickering in your Next.js application.

Here is a demo of what we are going to build: https://nextjs-mui-dark-mode.vercel.app/.

Here is the link to the repository of the final project: https://github.com/arlenxuzj/nextjs-mui-dark-mode.

MUI CSS Theme Variables

CSS variables, or custom properties, are a powerful feature of CSS that allows developers to define reusable values for styling properties. By defining a value once and using it in multiple places throughout a stylesheet, developers can create consistent styling across an application, as well as make global changes to styling elements._action_1n6lm_13

MUI CSS theme variables is an experimental feature that allows developers to define custom properties and use them in MUI components. One of the advantages of using MUI CSS theme variables is that they can be used to define a dark mode theme for your application and prevent flickering issues during SSR.

Implementing Dark Mode with Next.js and MUI

Download the Starter Code

To get started, download the starter code from MUI GitHub repository:

material-ui/examples/material-next-ts at master · mui/material-ui

MUI Core: Ready-to-use foundational React components, free forever. It includes Material UI, which implements Google's Material Design. - material-ui/examples/material-next-ts at master · mui/m…

github.com

image

You can download this code by executing the following command in your terminal:

curl https://codeload.github.com/mui/material-ui/tar.gz/master | tar -xz --strip=2  material-ui-master/examples/material-next-ts
cd material-next-ts

Install it and run:

npm install
npm run dev

Add Custom Theme

Then in the project, replace the contents of src/theme.ts with the following code:

src/theme.ts

Copy
import { purple } from '@mui/material/colors';
import { experimental_extendTheme as extendTheme } from '@mui/material/styles';

const theme = extendTheme({
  colorSchemes: {
    light: {
      // palette for light mode
      palette: {
        primary: {
          main: purple[700]
        }
      }
    },
    dark: {
      // palette for dark mode
      palette: {
        primary: {
          main: purple[200]
        }
      }
    }
  }
});

export default theme;

experimental_extendTheme is an API that allows you to extend the default MUI theme with your own customizations. In this case, we are extending the default MUI theme with a custom color scheme for light and dark mode.

Add CSS Variables Provider

Next, replace the contents of pages/_app.tsx with the following code:

pages/_app.tsx

Copy
import { CacheProvider, EmotionCache } from '@emotion/react';
import CssBaseline from '@mui/material/CssBaseline';
import { Experimental_CssVarsProvider as CssVarsProvider } from '@mui/material/styles';
import type {} from '@mui/material/themeCssVarsAugmentation';
import { AppProps } from 'next/app';
import Head from 'next/head';
import createEmotionCache from '../src/createEmotionCache';
import theme from '../src/theme';

// Client-side cache, shared for the whole session of the user in the browser.
const clientSideEmotionCache = createEmotionCache();

export interface MyAppProps extends AppProps {
  emotionCache?: EmotionCache;
}

export default function MyApp(props: MyAppProps) {
  const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
  return (
    <CacheProvider value={emotionCache}>
      <Head>
        <meta name='viewport' content='initial-scale=1, width=device-width' />
      </Head>
      <CssVarsProvider theme={theme} defaultMode='system'>
        {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
        <CssBaseline />
        <Component {...pageProps} />
      </CssVarsProvider>
    </CacheProvider>
  );
}

The theme we created earlier is passed to the Experimental_CssVarsProvider component as a prop. The defaultMode prop is set to system, which means that the theme will be set to dark mode if the user's operating system is set to dark mode.

Based on the MUI document, the theme variables type is not enabled by default. You need to import the module augmentation to enable the typings by import type {} from '@mui/material/themeCssVarsAugmentation';, which is in line 4 of the code above. The import can be in any file that is included in your tsconfig.json file.

Server-Side Rendering

Now, update the MyDocument component in pages/_document.tsx to the following:

pages/_document.tsx

Copy
// ...

import { getInitColorSchemeScript } from '@mui/material/styles';

export default function MyDocument({ emotionStyleTags }: MyDocumentProps) {
  return (
    <Html lang='en'>
      <Head>
        <link rel='shortcut icon' href='/favicon.ico' />
        <meta name='emotion-insertion-point' content='' />
        {emotionStyleTags}
      </Head>
      <body>
        {getInitColorSchemeScript({
          defaultMode: 'system'
        })}
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

// ...

The getInitColorSchemeScript function is used to generate a script that will be injected into the HTML document. Placing this function before the Main component can prevent the dark-mode SSR flickering during the hydration phase.

Add Example Code

Finally, replace the contents of pages/index.tsx with the following code:

pages/index.tsx

Copy
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import {
  Box,
  Button,
  MenuItem,
  Select,
  Stack,
  Typography,
  useColorScheme
} from '@mui/material';
import { useEffect, useState } from 'react';

export default function Home() {
  const { mode, setMode, systemMode } = useColorScheme();
  const [currentMode, setCurrentMode] = useState<
    'light' | 'dark' | 'system' | undefined
  >(undefined);
  const [resolvedMode, setResolvedMode] = useState<string | undefined>(
    undefined
  );

  useEffect(() => {
    setCurrentMode(mode);
    setResolvedMode(systemMode || mode);
  }, [mode]);

  return (
    <Box
      component='main'
      sx={{
        display: 'flex',
        flexDirection: 'column',
        height: '100vh',
        width: '100%',
        alignItems: 'center',
        justifyContent: 'center',
        alignItem: 'center',
        p: 3
      }}
    >
      <Box
        sx={{
          display: 'flex',
          gap: 4
        }}
      >
        <svg
          width={200}
          height={200}
          fill='var(--mui-palette-text-primary)'
          viewBox='0 0 128 128'
          xmlns='http://www.w3.org/2000/svg'
        >
          <path d='M30.2 45.9h24.1v1.9H32.4v14.4H53v1.9H32.4v15.8h22.2v1.9H30.2V45.9zm26.3 0h2.6l11.4 15.8L82 45.9l15.8-20-26 37.5 13.4 18.4h-2.7L70.4 65 58.2 81.8h-2.6l13.5-18.4-12.6-17.5zm29.7 1.9v-1.9h27.5v1.9H101v34h-2.2v-34H86.2zM0 45.9h2.7l38.2 56.8-15.8-20.9L2.3 48.6l-.1 33.2H0zm113.5 33.4c.5 0 .8-.3.8-.8s-.3-.8-.8-.8-.8.3-.8.8.4.8.8.8zm2.2-2.1c0 1.3 1 2.2 2.4 2.2 1.5 0 2.4-.9 2.4-2.5v-5.5h-1.2v5.5c0 .9-.4 1.3-1.2 1.3-.7 0-1.2-.4-1.2-1.1h-1.2zm6.3-.1c.1 1.4 1.2 2.3 3 2.3s3-.9 3-2.4c0-1.2-.7-1.8-2.2-2.2l-.9-.2c-1-.2-1.4-.6-1.4-1.1 0-.7.6-1.2 1.6-1.2.9 0 1.5.4 1.6 1.2h1.2c-.1-1.3-1.2-2.2-2.8-2.2-1.7 0-2.8.9-2.8 2.3 0 1.1.6 1.8 2 2.1l1 .2c1 .2 1.5.6 1.5 1.2 0 .7-.7 1.2-1.7 1.2s-1.8-.5-1.9-1.2H122z'></path>
        </svg>
        <Typography
          variant='h1'
          fontWeight='400'
          sx={{
            alignSelf: 'center'
          }}
        >
          +
        </Typography>
        <svg
          width={120}
          height={120}
          viewBox='0 0 128 128'
          style={{
            alignSelf: 'center'
          }}
        >
          <path
            fill='#1FA6CA'
            d='M.2 68.6V13.4L48 41v18.4L16.1 41v36.8L.2 68.6z'
          ></path>
          <path
            fill='#1C7FB6'
            d='M48 41l47.9-27.6v55.3L64 87l-16-9.2 32-18.4V41L48 59.4V41z'
          ></path>
          <path fill='#1FA6CA' d='M48 77.8v18.4l32 18.4V96.2L48 77.8z'></path>
          <path
            fill='#1C7FB6'
            d='M80 114.6L127.8 87V50.2l-16 9.2v18.4L80 96.2v18.4zM111.9 41V22.6l16-9.2v18.4l-16 9.2z'
          ></path>
        </svg>
      </Box>
      <Stack spacing={2}>
        <Typography variant='h4'>
          Persisted {currentMode}
          {currentMode === 'system' && ` (${resolvedMode})`} mode
        </Typography>
        <Select
          value={currentMode || ''}
          fullWidth
          onChange={e => {
            setMode(e.target.value as 'light' | 'dark' | 'system');
          }}
        >
          <MenuItem value='system'>System</MenuItem>
          <MenuItem value='light'>Light</MenuItem>
          <MenuItem value='dark'>Dark</MenuItem>
        </Select>
        <Button
          variant='contained'
          endIcon={
            resolvedMode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />
          }
          sx={theme => ({
            textTransform: 'none',
            background: `linear-gradient(to top right, #0a7ffe 0%, ${theme.vars.palette.primary.main} 100%)`
          })}
          onClick={() => {
            setMode(resolvedMode === 'dark' ? 'light' : 'dark');
          }}
        >
          Toggle {resolvedMode === 'dark' ? 'Light' : 'Dark'} Mode
        </Button>
      </Stack>
    </Box>
  );
}

The useColorScheme hook in line 15 is used to read and update the current mode. It returns an object, and three properties are used:

  • mode: The current mode. Can be 'light', 'dark' or 'system'. Note: on the server, mode is always undefined.
  • setMode: A function to set the current mode. Accepts 'light', 'dark' or 'system'.
  • systemMode: The current system mode. Can be 'light' or 'dark'. If the mode is not 'system', this will be undefined.

If you want to use theme variables, you can use the theme.vars which is shown in line 104. It is an object that refers to the CSS theme variables.

Test Dark Mode Flickering

  1. Toggle dark mode in your application.
  2. Open DevTools and set the CPU throttling to the lowest value (don't close the DevTools).
  3. Refresh the page. You should see all the components in dark mode at first glance.

Also, you can see some contents will change after the page is loaded. This is because the mode is always undefined on the server. So some contents of page must be updated on the client.

To avoid this, you can add following code to pages/index.tsx:

pages/index.tsx

Copy
// ...

export default function Home() {
  const { mode, setMode, systemMode } = useColorScheme();
  const [currentMode, setCurrentMode] = useState<
    'light' | 'dark' | 'system' | undefined
  >(undefined);
  const [resolvedMode, setResolvedMode] = useState<string | undefined>(
    undefined
  );
+ const [mounted, setMounted] = useState(false);

+ useEffect(() => {
+   setMounted(true);
+ }, []);

  useEffect(() => {
    setCurrentMode(mode);
    setResolvedMode(systemMode || mode);
  }, [mode]);

+ if (!mounted) {
+   return null;
+ }

  return (
    <Box
      component='main'
      sx={{
        display: 'flex',
        flexDirection: 'column',
        height: '100vh',
        width: '100%',
        alignItems: 'center',
        justifyContent: 'center',
        alignItem: 'center',
        p: 3
      }}
    >
      {/* ... */}
    </Box>
  );
}

However, it will make your website SEO unfriendly. If you are building a blog website and only have a mode icon in the header, it is not a problem.