Efficient Theme Management in Next.js with Server Actions

A deep dive into implementing a robust theme management system using Next.js server actions, cookies, and React context
March 26, 2025

The Challenge of Theme Management

Modern web applications need to support multiple themes - typically light and dark modes - while ensuring a seamless user experience. An effective theme system should:

  • Persist user preferences across sessions
  • Apply theme changes instantly without page reloads
  • Work with server-side rendering
  • Minimize layout shifts during theme transitions
  • Be easily extensible for additional themes

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.

The Architecture

Our theme system consists of three main components:

  1. Server-side storage - Using Next.js cookies to persist theme preferences
  2. Theme provider - A React context to make theme state available throughout the application
  3. Theme switcher components - UI elements that allow users to change themes

Let's look at each component in detail.

Server-Side Theme Storage

The foundation of our theme system is a set of server actions that handle theme persistence. Here's how we implement it:

1"use server";
2import { revalidatePath } from "next/cache";
3import { cookies } from "next/headers";
4import type { Theme } from "providers/theme";
5
6// Get the current theme from cookies
7export async function getCurrentTheme() {
8 const cookieCache = await cookies();
9 const theme = cookieCache.get("current-theme")?.value || "dark";
10 return theme as Theme;
11}
12
13// Set a new theme in cookies
14export async function setCurrentTheme(theme: string) {
15 const cookieCache = await cookies();
16 cookieCache.set("current-theme", theme);
17 revalidatePath("/", "layout");
18 return theme;
19}
20
21// Toggle between dark and light themes
22export async function toggleDarkTheme() {
23 const theme = await getCurrentTheme();
24 const newTheme = theme === "dark" ? "light" : "dark";
25 await setCurrentTheme(newTheme);
26 return newTheme;
27}
28

The key function here is setCurrentTheme, which:

  1. Gets the cookie store using Next.js's cookies() API
  2. Sets a cookie named "current-theme" with the new theme value
  3. Calls revalidatePath("/", "layout") to revalidate the entire app's layout
  4. Returns the new theme value

This server action approach leverages Next.js's built-in capabilities to handle cookies securely on the server side.

Theme Provider Context

To make the theme available throughout our application, we use a React context provider:

1// providers/theme.tsx
2"use client";
3import { createContext, useContext, useState } from "react";
4import { useRouter } from "next/navigation";
5import { setCurrentTheme } from "lib/headers";
6
7export type Theme = "light" | "dark" | "system";
8
9// Create the theme context
10const ThemeContext = createContext<{
11 theme: Theme;
12 selectTheme: (theme: Theme) => Promise<void>;
13 toggleMode: () => Promise<void>;
14}>({
15 theme: "dark",
16 selectTheme: async () => {},
17 toggleMode: async () => {},
18});
19
20// Theme provider component
21export function ThemeProvider({
22 children,
23 currentThemeName
24}: {
25 children: React.ReactNode;
26 currentThemeName: Theme;
27}) {
28 const router = useRouter();
29 const [theme, setTheme] = useState<Theme>(currentThemeName);
30
31 // Function to select a specific theme
32 const selectTheme = async (theme: Theme) => {
33 document.body.setAttribute("data-theme", theme);
34 await setCurrentTheme(theme);
35 router.refresh();
36 setTheme(theme);
37 };
38
39 // Function to toggle between light and dark
40 const toggleMode = async () => {
41 const newTheme = theme === "dark" ? "light" : "dark";
42 await selectTheme(newTheme);
43 };
44
45 return (
46 <ThemeContext.Provider value={{ theme, selectTheme, toggleMode }}>
47 {children}
48 </ThemeContext.Provider>
49 );
50}
51
52// Custom hook to use the theme
53export const useTheme = () => useContext(ThemeContext);
54

The theme provider does several important things:

  1. Maintains the current theme state using React's useState
  2. Provides a selectTheme function that:
    • Updates the DOM by setting a data-theme attribute on the body
    • Calls our server action to persist the theme in cookies
    • Refreshes the router to apply server-side changes
    • Updates the local state
  3. Offers a convenient toggleMode function to switch between dark and light themes
  4. Exposes these functions through a custom useTheme hook

Applying Themes in the UI

With our theme provider in place, we can easily create UI components to change themes:

1// components/theme-switcher.tsx
2"use client";
3import { useTheme } from "providers/theme";
4
5export function ThemeSwitcher() {
6 const { theme, toggleMode } = useTheme();
7
8 return (
9 <button
10 onClick={toggleMode}
11 aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
12 >
13 {theme === 'dark' ? '☀️' : '🌙'}
14 </button>
15 );
16}
17

The Complete Flow

When a user changes their theme preference, here's what happens:

  1. The user clicks a theme toggle button, triggering the toggleMode or selectTheme function
  2. The function immediately updates the DOM by setting a data-theme attribute on the body element
  3. The server action setCurrentTheme is called, which:
    • Stores the new theme preference in a cookie
    • Revalidates the app's layout to ensure server components reflect the change
  4. The router is refreshed to apply any server-side changes
  5. The local state is updated, causing any components that depend on the theme to re-render

This approach gives us several benefits:

  • Immediate visual feedback - The theme changes instantly by updating the DOM directly
  • Persistence across sessions - Theme preferences are stored in cookies
  • Server-side rendering support - The theme is available during SSR through cookies
  • No layout shifts - By updating the DOM before server revalidation, we avoid layout shifts

CSS Implementation

To make this work with CSS, we use CSS variables with different values based on the data-theme attribute:

1:root {
2 --background: #ffffff;
3 --text: #000000;
4 /* Other light theme variables */
5}
6
7[data-theme="dark"] {
8 --background: #121212;
9 --text: #ffffff;
10 /* Other dark theme variables */
11}
12
13body {
14 background-color: var(--background);
15 color: var(--text);
16 transition: background-color 0.3s ease, color 0.3s ease;
17}
18

Conclusion

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:

  • Persists user preferences
  • Works seamlessly with SSR
  • Provides instant visual feedback
  • Avoids layout shifts
  • Is easily extensible

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!