Loading article…
Loading article…
Modern web applications need to support multiple themes - typically light and dark modes - while ensuring a seamless user experience. An effective theme system should:
In this post, I'll explain how we implemented our theme management system using Next.js server actions, cookies for persistence, and React context for state management.
Our theme system consists of three main components:
Let's look at each component in detail.
The foundation of our theme system is a set of server actions that handle theme persistence. Here's how we implement it:
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import type { Theme } from "providers/theme";
// Get the current theme from cookies
export async function getCurrentTheme() {
const cookieCache = await cookies();
const theme = cookieCache.get("current-theme")?.value || "dark";
return theme as Theme;
}
// Set a new theme in cookies
export async function setCurrentTheme(theme: string) {
const cookieCache = await cookies();
cookieCache.set("current-theme", theme);
revalidatePath("/", "layout");
return theme;
}
// Toggle between dark and light themes
export async function toggleDarkTheme() {
const theme = await getCurrentTheme();
const newTheme = theme === "dark" ? "light" : "dark";
await setCurrentTheme(newTheme);
return newTheme;
}
The key function here is setCurrentTheme, which:
cookies() APIrevalidatePath("/", "layout") to revalidate the entire app's layoutThis server action approach leverages Next.js's built-in capabilities to handle cookies securely on the server side.
To make the theme available throughout our application, we use a React context provider:
// providers/theme.tsx
"use client";
import { createContext, useContext, useState } from "react";
import { useRouter } from "next/navigation";
import { setCurrentTheme } from "lib/headers";
export type Theme = "light" | "dark" | "system";
// Create the theme context
const ThemeContext = createContext<{
theme: Theme;
selectTheme: (theme: Theme) => Promise<void>;
toggleMode: () => Promise<void>;
}>({
theme: "dark",
selectTheme: async () => {},
toggleMode: async () => {},
});
// Theme provider component
export function ThemeProvider({
children,
currentThemeName
}: {
children: React.ReactNode;
currentThemeName: Theme;
}) {
const router = useRouter();
const [theme, setTheme] = useState<Theme>(currentThemeName);
// Function to select a specific theme
const selectTheme = async (theme: Theme) => {
document.body.setAttribute("data-theme", theme);
await setCurrentTheme(theme);
router.refresh();
setTheme(theme);
};
// Function to toggle between light and dark
const toggleMode = async () => {
const newTheme = theme === "dark" ? "light" : "dark";
await selectTheme(newTheme);
};
return (
<ThemeContext.Provider value={{ theme, selectTheme, toggleMode }}>
{children}
</ThemeContext.Provider>
);
}
// Custom hook to use the theme
export const useTheme = () => useContext(ThemeContext);
The theme provider does several important things:
useStateselectTheme function that:
data-theme attribute on the bodytoggleMode function to switch between dark and light themesuseTheme hookWith our theme provider in place, we can easily create UI components to change themes:
// components/theme-switcher.tsx
"use client";
import { useTheme } from "providers/theme";
export function ThemeSwitcher() {
const { theme, toggleMode } = useTheme();
return (
<button
onClick={toggleMode}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
);
}
When a user changes their theme preference, here's what happens:
toggleMode or selectTheme functiondata-theme attribute on the body elementsetCurrentTheme is called, which:
This approach gives us several benefits:
To make this work with CSS, we use CSS variables with different values based on the data-theme attribute:
:root {
--background: #ffffff;
--text: #000000;
/* Other light theme variables */
}
[data-theme="dark"] {
--background: #121212;
--text: #ffffff;
/* Other dark theme variables */
}
body {
background-color: var(--background);
color: var(--text);
transition: background-color 0.3s ease, color 0.3s ease;
}
This theme management system provides a robust solution for Next.js applications. By combining server actions, cookies, and React context, we've created a system that:
The use of Next.js server actions makes this approach particularly elegant, as it leverages the framework's built-in capabilities for handling server-side state while maintaining a clean separation of concerns.
Feel free to adapt this approach for your own projects, and let me know if you have any questions or suggestions for improvement!