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.
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:
- Light mode
- Dark mode
- System preference
- Persistent user selection
- Tailwind CSS integration
- Hydration-safe rendering
The final flow:
User changes theme
|
v
Theme Provider updates state
|
v
HTML class changes
|
v
CSS variables update
|
v
UI changes automaticallyProject Stack
This implementation uses:
- Next.js 16 App Router
- React
- TypeScript
- Tailwind CSS v4
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.
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.
: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
@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
export type Theme = 'light' | 'dark' | 'system';Creating Theme Provider
The provider manages the current theme state.
'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.
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add('dark');Saving User Preference
User choices should survive page refreshes.
localStorage.setItem('theme', 'dark');Reading:
const theme = localStorage.getItem('theme');Handling System Theme
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:
- window
- localStorage
- browser preferences
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 client';
import { useSyncExternalStore } from 'react';
const emptySubscribe = () => {
return () => {};
};
export function useMounted() {
return useSyncExternalStore(
emptySubscribe,
() => true,
() => false,
);
}This hook returns:
falseduring server renderingtrueafter hydration on the client
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.
'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
'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.cssResponsibilities
| Part | Responsibility |
|---|---|
| CSS Variables | Store theme colors |
| Theme Provider | Manage theme state |
| localStorage | Persist user choice |
| matchMedia | Detect system theme |
| useMounted Hook | Prevent hydration mismatch |
| Toggle Component | User 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.

