Effortless Dark and Light Theme Switching in Next.js: A Proposal

Guide to adding a Next.js theme switcher, tackling SSR and user preferences.

Implementing a Robust Dark Mode in Next.js with Theme Switching

Implementing a properly working dark mode is a seemingly challenging task.

The next-themes package makes implementing a dark mode much simpler and more efficient. It provides a straightforward way to implement it for Next.js, and, by extension, TailwindCSS.

After a few moments into your implementation, you'll likely consider implementing a theme switcher. - Which is great!

This seemingly easy task, given the API of the next-themes package, starts to become quite a burden when the developer realizes, that Server-Side Rendering (SSR), the system default theme, and the various ways to determine the user's theme preference all need to be taken into account.

In this post, I am proposing a React component that respects the user's theme preference and avoids hydration mismatches. It is also used in the current version of this blog's implementation, as you can see above.
Feel free to try it out.

The component uses shadcn/ui and @radix-ui/react-icons.

With a right-click on the button, the theme can be reset to the user's preference.

The implementation assumes that the developer has a working setup regarding next-themes, shadcn/ui as well as Next.js overall.

"use client"; import { Button } from "@/components/ui/button"; import { LaptopIcon, MoonIcon, ReloadIcon, SunIcon, } from "@radix-ui/react-icons"; import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; export function ThemeSwitcher() { // resolvedTheme is the theme that is currently being used, either "light" or "dark", avoiding "system" const { setTheme, resolvedTheme, theme } = useTheme(); const [mounted, setMounted] = useState(false); // Using the useEffect hook to set mounted to true once the component has mounted useEffect(() => setMounted(true), []); function toggleTheme() { if (resolvedTheme === "dark") setTheme("light"); if (resolvedTheme === "light") setTheme("dark"); } function resetToSystem(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) { e.preventDefault(); setTheme("system"); } // If the component has not yet mounted, return a disabled button if (!mounted) return ( <Button variant={"ghost"} size="icon" disabled> <ReloadIcon className="animate-spin" /> </Button> ); if (theme === "system") return ( <Button variant={"ghost"} onContextMenu={resetToSystem} onClick={toggleTheme} size="icon" > <LaptopIcon /> </Button> ); if (resolvedTheme === "dark") return ( <Button variant={"ghost"} // handle right click to reset to system onContextMenu={resetToSystem} onClick={toggleTheme} size="icon" > <MoonIcon /> </Button> ); return ( <Button variant={"ghost"} onContextMenu={resetToSystem} onClick={toggleTheme} size="icon" > <SunIcon /> </Button> ); }

Further Reading


Stay ahead of the curve with my newsletter. Receive valuable insights on the blog updates, trends, techniques, and tools in web development and tech.

I respect your privacy. Unsubscribe at any time. I am using Mailchimp as my marketing platform. By clicking below to subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. Learn more about Mailchimp's privacy practices here.