Step-By-Step Guide to Adding Dark Mode and Multiple Themes to Your Next.js App
Tags: tailwindcss, nextjs, theming, tutorial
Published: 2022-12-20 15:18:21

Adding themes to your Next.js app makes it possible for your users to personalize their experience and improve the usability of your product. With themes, users can easily switch between light and dark modes, or change the color scheme of the app, depending on their preference. In this tutorial, I will show you how to set up dark mode and add other themes to your Next.js app using the next-theme library, TailwindCSS, and CSS variables. So, let's get started!

## Getting started

### Next.js + TailwindCSS

You can follow the [**Install Tailwind CSS with Next.js**](Install Tailwind CSS with Next.js) guide.

### Next-themes

This package will do the “magic”, it provides a custom `useTheme` hook to consume and change the current theme. It can also select between dark and light modes based on the user’s system-preferred color theme. To install it, all you need to do is run the following command in your project's directory:

```bash
npm install next-themes
# or
yarn add next-themes
```

## Setting up dark mode

The most common use of the next-themes library is setting up dark mode. Later, we will explore how to add more themes to your app.

- Wrap `<Component />` with `<ThemeProvider />` exported from next-themes:

```tsx
// pages/_app.tsx

import "../styles/globals.css";
import type { AppProps } from "next/app";
import { ThemeProvider } from "next-themes";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ThemeProvider attribute="class">
      <Component {...pageProps} />
    </ThemeProvider>
  );
}
```

- Add `’class’` to `darkMode` option in the _tailwind.config.js:_  

```tsx
/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: "class",
  content: [
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};
```

We’re opting out of letting the operating system automatically modify the app’s theme, which is set via `darkMode: “media“`, in favor of toggling dark mode manually.

- We’re now able to use the `useTheme` hook provided by the library to change the current theme:

```tsx
// pages/index.tsx

import { useTheme } from "next-themes";

export default function Home() {
  const { resolvedTheme, setTheme } = useTheme();

  return (
    <div className="py-0 px-8">
      <div className="flex flex-col justify-center px-8 w-full">
        <nav className="relative flex items-center justify-between w-full max-w-2xl pt-8 pb-8 mx-auto text-gray-900 border-gray-200 dark:border-gray-700 sm:pb-16">
          <span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
            Current theme: {resolvedTheme}
          </span>
          <ToggleButton
            onClick={() =>
              setTheme(resolvedTheme === "dark" ? "light" : "dark")
            }
            selectedTheme={resolvedTheme}
          />
        </nav>
      </div>
    </div>
  );
}
```

- Fix hydration errors

If you now try to `yarn dev` your app you’ll get a Hydration error:

![Hydration errror](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/akxuw1scwqxkyftut0qy.png)

In order for hydration to function correctly, the initial client-side rendered version of the UI must be identical to the server-side one. However, in this case, the `resolvedTheme` variable is `undefined` on the server and a `string` on the client, causing the React trees rendered on the server and client to be different. As a result, hydration fails.

To fix this we need to ensure we don’t consume the `resolvedTheme` variable in the first component render:

```tsx
// pages/index.tsx

import { useTheme } from "next-themes";
import { useEffect, useState } from "react";

export default function Home() {
  const [mounted, setMounted] = useState(false);
  const { resolvedTheme, setTheme } = useTheme();

  useEffect(() => setMounted(true));

  return (
    <div className="py-0 px-8">
      <div className="flex flex-col justify-center px-8 w-full">
        <nav className="relative flex items-center justify-between w-full max-w-2xl pt-8 pb-8 mx-auto text-gray-900 border-gray-200 dark:border-gray-700 sm:pb-16">
          <span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
            Current theme: {mounted && resolvedTheme}
          </span>
          <ToggleButton
            onClick={() =>
              setTheme(resolvedTheme === "dark" ? "light" : "dark")
            }
            selectedTheme={resolvedTheme}
          />
        </nav>
      </div>
    </div>
  );
}
```

In this case, we are using the value of the `mounted` state to ensure that our component has been rendered client side at least once before attempting to access the `resolvedTheme` variable. At this moment the React trees on both client and server look like this:

```tsx
// server
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
   Current theme: undefined
</span>

// client
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
   Current theme: undefined
</span>
```

Rather than this, which was what cause the error :

```tsx
// server
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
   Current theme: undefined
</span>

// client
<span className="text-gray-700 dark:text-gray-200 p-1 sm:px-3 sm:py-2 ">
   Current theme: string
</span>
```

By doing this, we can ensure that hydration works properly because the React trees are identical.

- Styling components with the `dark:` prefix

As you’ve seen in previous code examples, you can now use the `dark:` prefix with any Tailwind class to specify a variant that will only be applied when dark mode is active. For example, in the code below, the background color of a card grid will change based on the current theme:

```tsx
const ProductsGrid = () => {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 ">
      {products.map((product) => (
        <div
          key={product.id}
          className="bg-white border hover:shadow-sm transition-all cursor-pointer border-gray-50 rounded-lg p-6 dark:bg-gray-800 dark:border-gray-600"
        >
          ...
        </div>
      ))}
    </div>
  );
};
```

## Setting up extra themes

In order to add multiple themes to our Next.js app, you need to provide a list of options. Each theme option must map to a CSS attribute selector that updates global CSS variables, which in turn should update the values of custom Tailwind classes. This leads to changes in the colors of the custom classes used throughout your app.

You need to manipulate three files, __app.tsx_, _globals.css_, and _tailwind.config.js_

- In the __app.tsx_ file, remove `attribute=’class’` as we are no longer using tailwindCSS's built-in dark mode:  

```tsx
// _app.tsx

import "../styles/globals.css";
import type { AppProps } from "next/app";
import { ThemeProvider } from "next-themes";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ThemeProvider>
      <Component {...pageProps} />
    </ThemeProvider>
  );
}
```

After making the change, instead of the `html` element having a `class` attribute with a value of "dark" or "light", it will have a `data-theme` attribute with a value of "dark" or "any theme" (or any other value you have chosen for your themes).

- In the _globals.css_ file, we will set up attribute selectors for each theme option. This will allow all of the global CSS variables to update automatically when the `useTheme` function updates the `data-theme` attribute. For example:

```css
/* globals.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

[data-theme="dark"] {
  /* dark theme styles go here */
}

[data-theme="gruvbox"] {
  /* gruvbox theme styles go here */
}

[data-theme="pink"] {
  /* pink theme styles go here */
}
```

- In the _tailwind.config.js_ file, we will set `darkMode` to `false` and define custom classes that use the CSS variables we just defined:

```js
// tailwind.config.js

module.exports = {
  darkMode: false,
  theme: {
    extend: {
      /* custom classes go here */
    },
  },
  variants: {},
  plugins: [],
};
```

By setting `darkMode` to `false`, we are disabling tailwindCSS's built-in dark mode. You can then define custom classes in the `theme.extend` object that use the CSS variables we defined in the _globals.css_ file to control the styling of our elements.

- Now you need to provide the user with a way to change the current theme:

```tsx
// pages/index.tsx

const themes = [
  { name: "Light" },
  { name: "Dark" },
  { name: "Monakai" },
  { name: "Pink" },
];

const ToggleButton = () => {
  const [mounted, setMounted] = useState(false);
  const { theme, setTheme } = useTheme();

  // When mounted on client, now we can show the UI
  useEffect(() => setMounted(true), []);

  if (!mounted) return null;

  return (
    <>
      <span className="p-1 sm:px-3 sm:py-2 text-th-secodary">
        Current theme: {mounted && theme}
      </span>
      <div>
        <label htmlFor="theme-select" className="sr-only mr-2">
          Choose theme:
        </label>
        <select
          name="theme"
          id="theme-select"
          className="bg-white text-gray-800 border-gray-800 border py-1 px-3"
          onChange={(e) => setTheme(e.currentTarget.value)}
          value={theme}
        >
          <option value="system">System</option>
          {themes.map((t) => (
            <option key={t.name.toLowerCase()} value={t.name.toLowerCase()}>
              {t.name}
            </option>
          ))}
        </select>
      </div>
    </>
  );
};
```

- That’s it! Now you can start applying your new custom classes that change their color values based on the current theme:

```tsx
// pages/index.tsx

const ProductsGrid = () => {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 ">
      {generateProducts().map((product) => (
        <div
          key={product.id}
          className="bg-white border hover:shadow-sm transition-all cursor-pointer border-gray-50 rounded-lg p-6 dark:bg-gray-800 dark:border-gray-600"
        >
          <div className="relative w-full h-80">
            <Image
              src={product.imageUrl}
              alt={product.title}
              placeholder="blur"
              blurDataURL={`data:image/svg+xml;base64,${toBase64(
                shimmer(700, 475)
              )}`}
              fill
              sizes="100vw"
              className="object-cover"
            />
          </div>
          <h2 className="font-bold text-xl mt-2 text-gray-800">
            {product.title}
          </h2>
          <p className="text-th-accent text-sm">{product.subtitle}</p>
          <p className="text-gray-700 dark:text-gray-300 mt-2">
            {product.description}
          </p>
          <div className="flex space-x-2 mt-4">
            <button className="py-2 px-4 rounded-full bg-th-accent font-bold dark:bg-blue-800">
              Buy
            </button>
            <button className="p-1 h-9 w-9 flex items-center justify-center rounded-full bg-gray-200 text-gray-700 font-bold ">
              <FiShoppingCart />
            </button>
          </div>
        </div>
      ))}
    </div>
  );
};
```

## End result
{% embed https://www.loom.com/share/59674b5173144de6a177ddc9f8c00a8f %}
-   [Dark mode example](https://dark-mode-example-pqzs2p4za-luiscadillo.vercel.app/)
-   [Multiple themes example](https://dark-mode-example-jade.vercel.app/)
-   [Repo](https://github.com/luiisca/themes-example)

## Conclusion

Creating a custom dark mode and multiple themes is a great way to give users a more personalized and tailored experience. With the help of Tailwind's built-in dark mode, CSS variables, and custom classes, it's easy to create a dynamic, lightweight theme system that changes colors and styles on the fly. This article provided a step-by-step tutorial on how to create such a system, as well as a link to the repository that contains all the code used for the provided examples.

## References

-   [Dark mode with TailwindCSS](https://tailwindcss.com/docs/dark-mode#toggling-dark-mode-manually)
-   [Next themes](https://github.com/pacocoursey/next-themes)
-   [Next.js hydration, why it happens, how to fix it](https://nextjs.org/docs/messages/react-hydration-error)
-   [next-theme docs - avoid Hydration Mismatch](https://github.com/pacocoursey/next-themes#avoid-hydration-mismatch)
-   [Multiple Themes for Next.js with next-themes, Tailwind CSS and CSS Custom Properties](https://darrenwhite.dev/blog/nextjs-tailwindcss-theming)