nextjs•themes

Implement Dark and Light Theme in Next.js 16 Without Dependencies

A dependency-free approach to building a dark and light theme system in Next.js 16 with Tailwind CSS.

Next.jsTailwind CSSReactTypeScriptThemeDark Mode
By Hamid Mohamadi2026-08-31

Dark and Light Theme in Next.js 16 Without Dependencies

A modern web application should provide a comfortable experience in both light and dark environments.

Theme support is now an expected feature in most applications. Users want interfaces that match their operating system preference or their personal choice.

Many developers use libraries such as @wrksz/themes, but understanding how a theme system works internally helps us build better applications.

In this article, we will create a complete dark and light theme system in Next.js 16 without installing any external theme library.

What We Will Build

Our theme system will support:

The final flow:

User changes theme
        |
        v
Theme Provider updates state
        |
        v
HTML class changes
        |
        v
CSS variables update
        |
        v
UI changes automatically

Project Stack

This implementation uses:

How Theme Switching Works

A theme system is based on changing classes on the root HTML element.

<html class="dark">

When the dark class exists, Tailwind applies dark mode styles.

When it is removed, the application returns to light mode.

Detecting System Preference

Browsers provide a native API to detect the operating system theme.

match-media.ts
window.matchMedia('(prefers-color-scheme: dark)');

This allows our application to follow the user's system preference.

Creating CSS Variables

The foundation of our theme system is CSS variables.

globals.css
:root {
  --background: #ffffff;
  --foreground: #171717;
}
.dark {
  --background: #0a0a0a;
  --foreground: #ededed;
}

The same CSS variable names are reused, but their values change depending on the active theme.

Connecting Variables With Tailwind

globals.css
@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
}

Now components use semantic colors:

<div className="bg-background text-foreground">Content</div>

Creating Theme Type

theme.ts
export type Theme = 'light' | 'dark' | 'system';

Creating Theme Provider

The provider manages the current theme state.

theme-provider.tsx
'use client';
 
import { createContext, useContext, useEffect, useState } from 'react';
 
import type { Theme } from '@/lib/theme';
 
type ThemeContextType = {
  theme: Theme;
  setTheme: (theme: Theme) => void;
};
 
const ThemeContext = createContext<ThemeContextType | null>(null);
 
function getStoredTheme(): Theme {
  if (typeof window === 'undefined') {
    return 'system';
  }
 
  const stored = localStorage.getItem('theme');
 
  if (stored === 'light' || stored === 'dark' || stored === 'system') {
    return stored;
  }
 
  return 'system';
}
 
function applyTheme(theme: Theme) {
  const root = document.documentElement;
 
  root.classList.remove('light', 'dark');
 
  if (theme === 'system') {
    const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
 
    root.classList.add(dark ? 'dark' : 'light');
 
    return;
  }
 
  root.classList.add(theme);
}
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setThemeState] = useState<Theme>(getStoredTheme);
 
  useEffect(() => {
    applyTheme(theme);
 
    localStorage.setItem('theme', theme);
  }, [theme]);
 
  return (
    <ThemeContext.Provider
      value={{
        theme,
        setTheme: setThemeState,
      }}
    >
      {children}
    </ThemeContext.Provider>
  );
}
 
export function useTheme() {
  const context = useContext(ThemeContext);
 
  if (!context) {
    throw new Error('useTheme must be inside ThemeProvider');
  }
 
  return context;
}

Applying Theme To HTML

The active theme is controlled by adding a class to the root element.

apply-theme.ts
document.documentElement.classList.remove('light', 'dark');
 
document.documentElement.classList.add('dark');

Saving User Preference

User choices should survive page refreshes.

theme-storage.ts
localStorage.setItem('theme', 'dark');

Reading:

const theme = localStorage.getItem('theme');

Handling System Theme

system-theme.ts
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

The Hydration Problem

Next.js renders HTML on the server first.

The server does not have access to:

This can create hydration mismatch errors.

Example:

Server:

<button>
  Sun
</button>

Client:

<button>
  Moon
</button>

Solving Hydration Issues

Client-dependent UI should wait until the application is mounted on the client.

Instead of manually tracking mounting with useState and useEffect, we can create a reusable hook using React's useSyncExternalStore.

use-mounted.ts
'use client';
 
import { useSyncExternalStore } from 'react';
 
const emptySubscribe = () => {
  return () => {};
};
 
export function useMounted() {
  return useSyncExternalStore(
    emptySubscribe,
    () => true,
    () => false,
  );
}

This hook returns:

This prevents React hydration mismatch errors.

Example:

Server:

<button>
  Sun
</button>

Client:

<button>
  Moon
</button>

The HTML structure is the same, but the content is different. React detects this difference during hydration.

Using The Mounted Hook

The hook can be used in components that depend on browser-only features.

theme-toggle.tsx
'use client';
 
import { useMounted } from '@/hooks/use-mounted';
 
export function ThemeToggle() {
  const mounted = useMounted();
 
  if (!mounted) {
    return null;
  }
 
  return <button>Dark Mode</button>;
}

Now the server renders a stable result, and the client renders the interactive theme controls after hydration.

Creating Theme Toggle

theme-toggle.tsx
'use client';
 
import { Moon, Sun } from 'lucide-react';
 
import { useTheme } from './theme-provider';
import { useMounted } from '@/hooks/use-mounted';
 
export function ThemeToggle() {
  const { theme, setTheme } = useTheme();
 
  const mounted = useMounted();
 
  if (!mounted) {
    return (
      <button className="rounded-full border p-3" aria-label="Theme toggle" />
    );
  }
 
  return (
    <button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      className="hover:bg-muted rounded-full border p-3 transition"
      aria-label="Theme toggle"
    >
      {theme === 'dark' ? <Moon size={20} /> : <Sun size={20} />}
    </button>
  );
}

For simplicity, this toggle switches between explicit light and dark modes. A production application may provide a third option for system preference.

Final Project Structure

components/
└── theme/
    ├── theme-provider.tsx
    └── theme-toggle.tsx
 
hooks/
└── use-mounted.ts
 
lib/
└── theme.ts
 
app/
└── layout.tsx
 
globals.css

Responsibilities

PartResponsibility
CSS VariablesStore theme colors
Theme ProviderManage theme state
localStoragePersist user choice
matchMediaDetect system theme
useMounted HookPrevent hydration mismatch
Toggle ComponentUser interaction

Conclusion

Building a theme system without dependencies helps us understand what libraries like @wrksz/themes do internally.

For learning projects and small applications, a custom implementation is lightweight and provides complete control.

The important part is understanding the mechanism behind the tools we use.