.tsx
import type { Metadata } from 'next';
import { AppearanceProvider } from '@/providers/AppearanceProvider';
import { GlobalStyles } from '@/styles/global.css';
export const metadata: Metadata = {
title: 'Application Shell',
description: 'Root layout with theme synchronization',
};
export default function BaseLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<GlobalStyles />
</head>
<body className="antialiased">
<AppearanceProvider
storageKey="app-appearance"
defaultTheme="system"
enableSystem
attribute="class"
>
{children}
</AppearanceProvider>
</body>
</html>
);
}
**Architecture Rationale:**
- `suppressHydrationWarning` is applied to `<html>` because the server cannot know the client's theme preference. This flag tells React to ignore the mismatch during hydration, preventing console warnings and layout breaks.
- `attribute="class"` instructs the provider to toggle a `.dark` class on the root element rather than using `data-theme` or `color-scheme`. Tailwind v4's CSS variant system expects this class selector.
- `defaultTheme="system"` combined with `enableSystem` ensures the first render respects the OS preference. Subsequent interactions persist the choice to `localStorage` under the specified `storageKey`.
### Step 2: Configure Tailwind v4 CSS Pipeline
Tailwind v4 abandons JavaScript configuration files in favor of CSS-first definitions. Theme variant resolution is now handled entirely within the stylesheet.
```css
/* styles/global.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-surface: #ffffff;
--color-surface-dark: #0f172a;
--color-text-primary: #111827;
--color-text-primary-dark: #f3f4f6;
}
Architecture Rationale:
@custom-variant dark (&:where(.dark, .dark *)); creates a CSS selector that matches the .dark class on the root or any descendant. The :where() pseudo-class ensures zero specificity weight, preventing cascade conflicts with utility classes.
@theme block defines CSS custom properties that can be referenced across the application. This decouples color values from utility classes, making theme overrides predictable.
- No
tailwind.config.js is required. The CSS pipeline compiles variants at build time, reducing JavaScript bundle size and eliminating configuration synchronization errors.
Step 3: Implement the Client-Side Toggle
The toggle component must guard against server-side rendering to prevent hydration mismatches. It should also provide immediate visual feedback without blocking the main thread.
// components/ui/AppearanceSwitch.tsx
'use client';
import { useAppearance } from '@/hooks/useAppearance';
import { useEffect, useState, useCallback } from 'react';
export function AppearanceSwitch() {
const { currentTheme, updateTheme } = useAppearance();
const [isClientReady, setIsClientReady] = useState(false);
useEffect(() => {
setIsClientReady(true);
}, []);
const handleToggle = useCallback(() => {
const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
updateTheme(nextTheme);
}, [currentTheme, updateTheme]);
if (!isClientReady) {
return (
<div
className="w-10 h-10 rounded-lg bg-gray-200 dark:bg-gray-800 animate-pulse"
aria-hidden="true"
/>
);
}
return (
<button
type="button"
onClick={handleToggle}
className="flex items-center justify-center w-10 h-10 rounded-lg transition-colors duration-200 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label={`Switch to ${currentTheme === 'dark' ? 'light' : 'dark'} mode`}
>
{currentTheme === 'dark' ? (
<svg className="w-5 h-5 text-yellow-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 7a5 5 0 100 10 5 5 0 000-10zM12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72l1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
) : (
<svg className="w-5 h-5 text-gray-700" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
</svg>
)}
</button>
);
}
Architecture Rationale:
- The
isClientReady guard prevents the component from rendering theme-dependent UI before React hydration completes. This eliminates FOUC and hydration mismatches.
useCallback memoizes the toggle handler, preventing unnecessary re-renders when the component tree updates.
- SVG icons replace emoji for consistent rendering across operating systems and browsers. The
aria-label ensures accessibility compliance.
- The placeholder skeleton maintains layout stability during the hydration window, preventing cumulative layout shift (CLS).
Step 4: Apply Theme Utilities in Components
Once the provider and CSS pipeline are configured, theme-aware styling becomes declarative.
// components/dashboard/StatsCard.tsx
export function StatsCard({ title, value }: { title: string; value: string }) {
return (
<div className="p-6 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900 shadow-sm">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">{title}</h3>
<p className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">{value}</p>
</div>
);
}
Architecture Rationale:
- The
dark: prefix utilities are resolved at build time by Tailwind v4's CSS pipeline. No runtime JavaScript is required for style switching.
- CSS custom properties defined in
@theme can be referenced directly if dynamic theming is needed, but utility classes remain the recommended approach for performance and tree-shaking.
Pitfall Guide
1. Omitting suppressHydrationWarning on <html>
Explanation: Next.js 16 enforces strict hydration matching. If the server renders without a theme class and the client applies one immediately, React throws a hydration mismatch warning. This can break layout stability and trigger console errors in production.
Fix: Always apply suppressHydrationWarning to the root <html> element when using a theme provider. This safely delegates the mismatch to the provider's hydration logic.
2. Rendering Theme-Dependent UI Before Hydration
Explanation: Components that read theme context during server rendering will output incorrect styles because the server lacks access to localStorage or OS preferences. This causes FOUC and layout shifts.
Fix: Implement a client-ready guard using useState and useEffect. Render a neutral placeholder until isClientReady becomes true.
3. Misdefining the CSS Custom Variant Selector
Explanation: Using &.dark instead of &:where(.dark, .dark *) introduces specificity conflicts. Tailwind utilities may fail to override base styles, leading to broken theme transitions.
Fix: Use @custom-variant dark (&:where(.dark, .dark *)); to ensure zero specificity weight and proper descendant matching.
4. Forcing a Static Default Theme
Explanation: Setting defaultTheme="dark" or "light" ignores user OS preferences on first visit. This degrades accessibility and increases bounce rates for users who rely on system-level appearance settings.
Fix: Always use defaultTheme="system" with enableSystem. This respects OS preferences initially and persists manual overrides to storage.
5. Ignoring enableSystem Flag Behavior
Explanation: Without enableSystem, the provider defaults to the specified defaultTheme regardless of OS settings. Users must manually toggle to match their system preference, creating friction.
Fix: Include enableSystem in the provider configuration. This activates the matchMedia listener for OS preference synchronization.
6. CSS Specificity Wars with dark: Utilities
Explanation: Inline styles or high-specificity CSS rules can override Tailwind's dark: utilities, causing theme inconsistencies. This is common when mixing CSS modules or styled-components with Tailwind.
Fix: Rely exclusively on Tailwind utilities for theme styling. If custom CSS is required, use CSS custom properties defined in @theme and reference them via var().
7. Not Handling Theme Persistence in Middleware
Explanation: Server components that render theme-dependent content without reading the stored preference will output incorrect markup. This is especially problematic for SEO-critical pages.
Fix: Read the theme cookie or localStorage fallback in middleware or server components using cookies() from next/headers. Pass the resolved theme as a prop to server components.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Marketing site with static content | Server-side theme detection via cookies | Improves LCP and prevents FOUC on critical pages | Low (middleware overhead) |
| Dashboard with heavy client interactivity | Client-only theme provider with hydration guard | Simplifies architecture and reduces server complexity | None |
| Multi-brand SaaS with dynamic theming | CSS custom properties + provider context | Enables runtime theme switching without rebuilds | Medium (CSS variable management) |
| Legacy migration from v3 | Gradual CSS variant adoption with config fallback | Prevents breaking changes during transition | Low (parallel config support) |
Configuration Template
/* styles/global.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-bg-primary: #ffffff;
--color-bg-primary-dark: #0b0f19;
--color-text-primary: #111827;
--color-text-primary-dark: #e5e7eb;
--color-border: #e5e7eb;
--color-border-dark: #374151;
}
body {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
transition: background-color 0.2s ease, color 0.2s ease;
}
.dark body {
background-color: var(--color-bg-primary-dark);
color: var(--color-text-primary-dark);
}
// providers/AppearanceProvider.tsx
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ComponentProps } from 'react';
type ProviderProps = ComponentProps<typeof NextThemesProvider>;
export function AppearanceProvider({ children, ...props }: ProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange={false}
{...props}
>
{children}
</NextThemesProvider>
);
}
Quick Start Guide
- Install dependencies: Run
npm install next-themes in your project root.
- Update global CSS: Add
@import "tailwindcss"; and @custom-variant dark (&:where(.dark, .dark *)); to your main stylesheet.
- Wrap the root layout: Import the provider into
app/layout.tsx, apply suppressHydrationWarning to <html>, and pass attribute="class", defaultTheme="system", and enableSystem.
- Create a guarded toggle: Build a client component with a
mounted state check, render a placeholder during hydration, and wire the toggle to useTheme().setTheme().
- Apply utilities: Use
dark: prefixes in component classes. Verify theme switching works across pages and survives hard refreshes.