how to avoid the flicker of incorrect theme in Next.js

Aug 20, 2022

since Next.js renders pages on the server, this is expected. It will display content in the theme that’s shipped as default for a brief moment, because any code that we write to pick up the stored theme only runs after the page is displayed on the browser.

so if we could have a way to run some JavaScript on the server, that should be a way to avoid this problem. Next.js provides the ability to override the default Document through a file named _document.js in the pages folder, which renders on the server. So injecting the JavaScript we need to a <Script /> tag on this file and setting strategy="beforeInteractive" on it should do what we need.

in this script we’ll first check for the theme in localStorage, and if it doesn’t exist, match theme to the system preference and store this value in localStorage.

import { Html, Head, Main, NextScript } from 'next/document'
import Script from 'next/script'

export default function Document() {
  return (
    <Html>
      <Head />
      <body>
        <Main />
        <NextScript />
        <Script
          strategy="beforeInteractive"
          dangerouslySetInnerHTML={{
            __html: `
              (
                function () {
                  let initialTheme = localStorage.getItem("theme") ?
                  localStorage.getItem("theme") :
                    window.matchMedia('(prefers-color-scheme: dark)').matches ?
                      "dark" :
                      "light"
                
                  localStorage.setItem("theme", initialTheme)

		  // any global css that is not component specific
                  if (initialTheme === "dark") {
                    // style
                  } else {
                    // style
                  }
                }
              )();
            `,
          }}
        ></Script>
      </body>
    </Html>
  )
}

then on _app.js we can implement our dark mode toggle as we’d do on a React application, except that we’d need to read localStorage inside a useEffect hook in order to set initial state for theme. this is because localStorage is not defined on the server-side, so we have to wait until browser renders our component in order to read localStorage.

  const [theme, setTheme] = useState(null)

  // logic for toggle button (state change)
  // reset localStorage and apply any global css
  useEffect(() => {
    if (theme === "dark") {
      localStorage.setItem("theme", "dark")

      // global css
    } else if (theme === "light") {
      localStorage.setItem("theme", "light")

      // global css
    }
  }, [theme])

  // listener function for system theme changes
  // reset localStorage and state
  const listenerFunc = () => {
    const newTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? "dark" : "light"

    localStorage.setItem("theme", newTheme)

    setTheme(newTheme)
  }

  // initial state set
  useEffect(() => {
    const initalTheme = localStorage.getItem("theme")

    setTheme(initalTheme)

    // set event listener
    if (window.matchMedia)
      window.matchMedia('(prefers-color-scheme: dark)').addEventListener("change", listenerFunc)

    // cleanup event listener
    return () => {
      if (window.matchMedia)
        window.matchMedia('(prefers-color-scheme: dark)').removeEventListener("change", listenerFunc)
    }
  }, [])