Light & Dark Mode in Next.js App Router + Tailwind with No Flicker

Light & Dark Mode in Next.js App Router + Tailwind with No Flicker

ยท

5 min read

Applying light and dark mode themes with TailwindCSS is easy. However, if you want to allow users to toggle between light and dark mode themes while also identifying the system preference setting at load time and avoiding a page flicker, things get a little more complicated.

Why This Is Challenging

Next.js generates static pages on the server before sending them to the browser (aka "the client"). This helps keep things fast and websites seem snappy, but the server cannot read what user preferences will be. The server has no idea in advance that John prefers light-mode in his browser and Jane prefers dark-mode in her browser.

The best you can do is apply the TailwindCSS dark mode variant in advance that will read the prefers-color-scheme user system setting and apply the dark mode theme if that is preferred.

When you want to allow a user to manually toggle the light-dark mode theme setting, you need to read the system preference first. If they do toggle the setting, you want to save the new setting in localStorage. However, localStorage is only available in the browser. Your server components won't be able to read this setting, and this situation can cause a theme flash when your site loads due to an incorrect theme being applied for a split second.

Fortunately, the next-themes package can help avoid this issue while allowing users the ability to toggle between light and dark mode in your Next.js website.

Here's how I applied next-themes with TailwindCSS to my blog:

1. Edit your TailwindCSS config file

You need to make one addition to your tailwind.config.ts file if you want to toggle dark mode manually. You must add a darkMode setting with a class value.

// tailwind.config.ts 
/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: 'class',
  // ...
}

2. next-themes

Now you are ready to add next-themes to your project by typing the following in your terminal:

npm i next-themes

The next-themes GitHub repository provides directions for use with the Next.js App Router. The directions do not include TypeScript, but I'm adding some TypeScript below.

First, create a providers.tsx file inside your app folder like this:

// app/providers.tsx
'use client'

import { ThemeProvider } from 'next-themes'

export function Providers({ children }: { children: React.ReactNode }) {
    return <ThemeProvider attribute="class" defaultTheme='system' enableSystem>{children}</ThemeProvider>
}

Next, add the <Providers> component to your root layout.tsx by placing it inside the <body> tag:

// app/layout.tsx
import { Providers } from './providers'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <Providers>
            {children}
        </Providers>
      </body>
    </html>
  )
}

3. suppressHydrationWarning

Notice the suppressHydrationWarning setting inside the <html> tag. As noted in the GitHub repo for next-themes, if you do not apply this setting, you will get warnings because the <html> element is being updated by next-themes.

The React docs discuss suppressHydrationWarning. The use of it by next-themes is exactly as intended. You are telling Next.js you want to override what the server may have sent because it does not match the user setting saved in localStorage.

4. useTheme

Your implementation of next-themes is not complete until you create a component allowing users to manually change the themes. I called my component ThemeSwitch.tsx.

Here it is:

// app/components/ThemeSwitch.tsx
'use client'

import { FiSun, FiMoon } from "react-icons/fi"
import { useState, useEffect } from 'react'
import { useTheme } from 'next-themes'
import Image from "next/image"

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

  useEffect(() =>  setMounted(true), [])

  if (!mounted) return (
    <Image
      src="data:image/svg+xml;base64,PHN2ZyBzdHJva2U9IiNGRkZGRkYiIGZpbGw9IiNGRkZGRkYiIHN0cm9rZS13aWR0aD0iMCIgdmlld0JveD0iMCAwIDI0IDI0IiBoZWlnaHQ9IjIwMHB4IiB3aWR0aD0iMjAwcHgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIiB4PSIyIiB5PSIyIiBmaWxsPSJub25lIiBzdHJva2Utd2lkdGg9IjIiIHJ4PSIyIj48L3JlY3Q+PC9zdmc+Cg=="
      width={36}
      height={36}
      sizes="36x36"
      alt="Loading Light/Dark Toggle"
      priority={false}
      title="Loading Light/Dark Toggle"
    />
  )

  if (resolvedTheme === 'dark') {
    return <FiSun onClick={() => setTheme('light')} />
  }

  if (resolvedTheme === 'light') {
    return <FiMoon onClick={() => setTheme('dark')} />
  }

}

There's a lot going on above, but I'll guide you through.

The goal is to allow users to click an icon to change the theme. If they click the sun icon provided with dark mode, they will switch to light mode, and if they click the moon icon provided with light mode, they will switch to dark mode.

I start by importing the icons I want to use. Then I bring in both useState and useEffect followed by useTheme from next-themes and the <Image> component from Next.js.

You must keep track of the mounted state because you cannot use the setTheme function from the useTheme hook unless you know your code is running in the browser. Without this check, you will get a hydration mismatch warning when initially rendering the component on the server. Components rendering on the server will not use hooks and cannot access client-side localStorage.

You need to set the mount state inside of the useEffect hook that only runs on component mount in the client when it has an empty dependency array. This insures it will only run in the browser.

If the component is not mounted, it renders a placeholder image to avoid cumulative layout shift.

If the component is mounted, it checks the resolvedTheme value from the useTheme hook and provides the correct icon.

Note: I did not have to add code for saving the theme choice in localStorage. next-themes handles that for you.

5. End Result

You should now have a Next.js website that not only applies the user system preference, but also remembers any user changes to the light and dark mode theme preference specifically for your site. Therefore, a user may have a system preference of light mode, but can still choose a default dark mode for your website.

This should not result in a page flash, but the next-themes docs do say your website may still flash in dev mode. Even if so, in production mode, there should be no flash.

I have implemented next-themes with TailwindCSS on my blog as of this writing. Please check it out if you want to see a deployed example.


Let's Connect!

If you enjoyed this article, you might enjoy my other content, too.

My Stuff: Courses, Cheat Sheets, Roadmaps

YouTube: @davegrayteachescode

X: @yesdavidgray

GitHub: gitdagray

LinkedIn: /in/davidagray

Buy Me A Coffee: You will have my sincere gratitude

Thank you for joining me on this journey.

Dave