How to add dark mode to your Next.js blog

After I moved my blog from Jekyll to Next.js, the next step was to add themes. I wanted to provide at least four themes to the user, namely - dark, lunar, solar, light, ordered in the increasing level or luminosity. Let's have a quick look at the theme switcher in action:

Theme switcher demo

You can try it yourself from the theme switcher located top right of this page. Now that we know what we are going to build, let get right into the action.

Using css variables to theme the page

You can use several techniques for applying theme to a page. For example, if you are familiar with styled-components, you can use ThemeProvider to pass down the theme object in the component hierarchy. You can also style elements under different theme class names and based on user selection you can switch the class name of root level element. There's also CSS variables.

CSS variables could make your life much more simpler. It's an intuitive approach towards theming. For example, take a look at the following example:

body {
  --color-bg: #fff;
  --color-fg: #3d3d3d;
  --color-link: lighblue;
}
body[data-theme="dark"] {
  --color-bg: #000;
  --color-fg: #fafafa;
  --color-link: yellow;
}

You define a default theme variables in the body tag. You then customize those variables under body[data-theme="dark"] tag for a dark themed page. When you want to switch theme, you just need to set a data attribute name data-theme with a value of 'dark' or what ever the theme name you chose. You can define as many themes as you wish in this approach.

Later in your CSS files,you consume the defined CSS variable, as below:

.content {
  background-color: var(--color-bg);
  color: var(--color-fg);

  a {
    color: var(--color-link);
  }
}

Preserving user theme preference

Once the user selects a theme, you need to remember this selection. We could use localStorage for this purpose.

// I read the theme from local storage and set it onto body data attribute earlier in my code, so
const [theme, setTheme] = useState(document.body.dataset.theme);

// sync the changed theme value to local storage and body data attribute
useEffect(() => {
  if (theme && theme !== document.body.dataset.theme) {
    window.localStorage.setItem("theme", theme);
    document.body.dataset.theme = theme;
  }
}, [theme]);

const selectTheme = (e) => {
  const nextTheme = e.currentTarget.value;
  setTheme(nextTheme);
};

Make a note that we are accessing document in the above code, which is not available in you normally load this component in Next.Js. Because the regular components are rendered at server side / build time, they may not have access to browser features such as document or localStorage. For this, you need to import the code dynamically. In my case, my ThemeSwitcher component is part of the Header component, which I imported as a dynamic component as below.

import dynamic from "next/dynamic";

const DynamicHeader = dynamic(() => import("../components/header"), {
  ssr: false,
});

Restoring saved theme and avoiding flash of light

If you try to read from local storage and then switch theme - then the user would see a flash of default theme first (light theme, in our case) and then the selected theme. This is not a great user experience. To avoid this, we need to place the JavaScript code that reads from localstorage as a blocking code, right before any JS code executes or DOM is rendered. Remember browser blocks the page rendering until it executes any JS code that comes before markup.

In Next.js, we can place the theme initialization code in a special file called pages\_document.js.

// pages/__document.js
+ const themeInitializerScript = `
+       (function () {
+         document.body.dataset.theme = window.localStorage.getItem("theme") || "light";
+       })();
+   `;

class MyDocument extends Document {
  render() {
    return (
      <Html>
        <Head />
        <body>
+          <script
+            dangerouslySetInnerHTML={{ __html: themeInitializerScript }}
+          />
        </body>
      </Html>
    );
  }
}

Theme initializer code is placed as the first child of body - so, until this little JavaScript executes, browser would block rest of page rendering. When the page is finally rendered, we would have set the proper theme at the body tag, thus we will avoid the flash of light between page refresh or navigation.

That brings us to the end of the article. Thanks for reading.