Next.js ダークモード実装:next-themes 完全ガイド
Next.js プロジェクトで初めてダークモードを実装したとき、かなり痛い目に遭いました。ページ読み込みの瞬間、まず白い光が一瞬走り、そのあとダークモードに切り替わる——そのちらつきは本当にイライラします。ユーザーから「目がくらくらする」とコメントされて、初めてこの問題の深刻さに気づきました。
その後、いくつかの方法を試しました。手書き、use-dark-mode ライブラリ、無数のチュートリアルを読み漁り、最終的に next-themes が本当の救世主だと分かりました。今のプロジェクトではすべてこれを使っています。ゼロちらつき、設定も超シンプル、システムテーマも完璧に追従します。この記事では、私が踏んだ落とし穴と見つけた解決策をすべて共有します。
なぜ next-themes を選んだのか
最初は、テーマ切り替えロジックを自分で書くべきか迷いました。localStorage を読んで class を変えるだけ——簡単そうに見えますよね。でも実際に手を動かすと、Next.js のサーバーサイドレンダリング(SSR)の特性のせいで、ものすごく複雑になります。
いくつかの方法を試しました:
手書き:最大の問題はちらつきです。SSR 時にサーバーはユーザーのテーマ設定を知らないため、デフォルトのライトテーマでレンダリングされます。クライアント hydration 時に localStorage を読み取ってダークテーマに切り替えると、目立つちらつきが発生します。
use-dark-mode:悪くないライブラリですが、Next.js 専用ではなく、SSR シーンでは互換性の問題が残ります。
theme-ui:機能は豊富ですが、ダークモード切り替えだけが必要な場合は重すぎます。bundle size も大きい。
最終的に next-themes を見つけました。GitHub で 6000+ Star、Next.js 専用設計、ゼロ依存、gzip 後 1kb 未満。何より、本当にゼロちらつきを実現し、システムテーマサポートもすぐ使え、自動永続化も付いています。TypeScript サポートも充実で、使い心地がとても良いです。
完全実装手順
依存関係のインストール
まずはパッケージをインストール:
npm install next-themes
pnpm や yarn でも OK です:
pnpm add next-themes
# または
yarn add next-themes
ThemeProvider コンポーネントの作成
次に Provider コンポーネントを作成します。私は通常、providers や components ディレクトリに置きます。
providers/theme-provider.tsx を作成:
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
ここでは必ず 'use client' を付けてください。next-themes はブラウザ API にアクセスする必要があるからです。最初にこれを付け忘れて、hydration エラーが大量に出たのを覚えています。
Layout への統合
ThemeProvider をルートレイアウトに追加します。App Router(Next.js 13+)を使っている場合、app/layout.tsx です:
import { ThemeProvider } from '@/providers/theme-provider'
import './globals.css'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ja" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
)
}
重要な設定を一つずつ説明します:
attribute="class":next-themes に <html> 要素の class を変更してテーマを切り替えるよう指示します。Tailwind CSS の dark: プレフィックスと相性抜群です。
defaultTheme="system":デフォルトでシステムテーマに追従。初回訪問時に OS のテーマ設定を自動検出します。
enableSystem:システムテーマ検出機能を有効化。これを有効にしないと defaultTheme="system" は機能しません。
disableTransitionOnChange:切り替え時のトランジションアニメーションを無効化。好みで調整できますが、ダークモード切り替え時にトランジションがあると全要素が一斉にアニメーションして、見た目が逆に散らかりがちなので、有効にすることをおすすめします。
suppressHydrationWarning:<html> タグに付ける属性で、非常に重要です。next-themes はクライアント hydration 前に html 要素の class を変更するため、これがないと React が warning を出します。
テーマ切り替えボタンの作成
Provider ができたら、切り替えボタンを作りましょう。components/theme-toggle.tsx を作成:
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return null
}
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="テーマを切り替え"
>
{theme === 'dark' ? '🌞' : '🌙'}
</button>
)
}
ここに小技があります。コンポーネントの読み込み完了前は null を返すこと。なぜか?サーバーレンダリング時はテーマ情報を取得できないため、直接レンダリングすると hydration mismatch が起きます。クライアントで mounted になってから、useTheme が正しいテーマを返せます。
3 状態切り替え(light / dark / system)にしたい場合:
export function ThemeToggle() {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) return null
const cycleTheme = () => {
if (theme === 'light') setTheme('dark')
else if (theme === 'dark') setTheme('system')
else setTheme('light')
}
const getIcon = () => {
if (theme === 'light') return '🌞'
if (theme === 'dark') return '🌙'
return '💻'
}
return (
<button
onClick={cycleTheme}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
{getIcon()}
</button>
)
}
ちらつき問題の深掘り
この問題を本気で調べようと決めたのは、あの煩わしいちらつきが原因でした。仕組みを完全に理解するまで、かなり時間をかけました。
FOUC はどう発生するか
FOUC(Flash of Unstyled Content)は Next.js のダークモード実装で特に多い問題です。根本原因は SSR とクライアント状態の不一致にあります。
サーバーレンダリング時、Node.js 環境には window オブジェクトがなく、localStorage も取得できません。ユーザーのシステムテーマ設定も分かりません。だからサーバーはデフォルトテーマ(通常はライト)でしかレンダリングできません。
HTML がブラウザに送られ、hydration が始まります。このとき React がサーバーでレンダリングした静的 HTML をインタラクティブなコンポーネントに変換します。この過程で初めて JavaScript が localStorage を読み取り、ユーザーが以前選んだダークテーマを発見して DOM を変更し、dark class を追加します。
この変更が再レンダリングを引き起こし、すべてのスタイルがライトからダークに切り替わる——ちらつきはこうして生まれます。
next-themes の解決策
next-themes の解決策は巧妙です。<head> に blocking script を注入します。この script はページレンダリング前に実行され、即座に localStorage のテーマ設定を読み取り、<html> 要素に対応する class を付与します。
おおよそこんなロジックです:
(function() {
try {
const theme = localStorage.getItem('theme')
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
const currentTheme = theme || systemTheme
if (currentTheme === 'dark') {
document.documentElement.classList.add('dark')
}
} catch (e) {}
})()
この script は同期実行でページレンダリングをブロックするため、コンテンツが表示される前に正しいテーマ class が設定されます。CSS が最初から正しいスタイルを適用するので、ちらつきは発生しません。
よくある設定ミス
設定で問題が起きる人をたくさん見てきました。主にこのあたりです:
suppressHydrationWarning を付け忘れる:
<html> タグにこの属性を付け忘れると、コンソールにこんな warning が出続けます:
Warning: Prop `className` did not match. Server: "" Client: "dark"
機能には影響しませんが、見ていてうっとりします。
ThemeProvider の位置が間違っている:
ThemeProvider を Server Component に置いたり、body の外に置いたりすると問題が起きます。ThemeProvider はページコンテンツをラップし、Client Component である必要があります。
Tailwind の設定ミス:
tailwind.config.js がこうなっている場合:
module.exports = {
darkMode: 'media',
}
問題があります。media モードは純粋な CSS 方式で、システムテーマにしか追従できず、手動切り替えはできません。こう変更してください:
module.exports = {
darkMode: 'class',
}
テーマ永続化とシステム追従
永続化の仕組み
next-themes はデフォルトでテーマ選択を localStorage に保存します。キーは 'theme' です。この動作は自動で、追加コードは不要です。
storage key をカスタマイズする場合:
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
storageKey="my-theme"
>
{children}
</ThemeProvider>
localStorage ではなく Cookie を使いたい場面もあります。例えばサーバー側でユーザーのテーマ設定を知り、あらゆるちらつきを回避したい場合。手順はこうなります:
- ミドルウェアで Cookie を読み取り、レスポンスヘッダーに設定
- サーバーレンダリング時にレスポンスヘッダーに基づいてテーマをレンダリング
- クライアントで Cookie と localStorage を同期
ただ、正直なところ、ほとんどのシーンでは next-themes のデフォルト方式で十分です。
システムテーマ追従
enableSystem 設定により、next-themes はシステムテーマの変更を監視できます。ユーザーが OS 設定でダーク/ライトモードを切り替えたとき、アプリのテーマが system なら自動で追従します。
内部実装は prefers-color-scheme メディアクエリの監視です:
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
// テーマ切り替えロジック
})
ユーザーはシステムテーマを手動で上書きすることもできます。例えば OS はライトモードでも、サイト上でダークに切り替えれば、next-themes はその選択を記憶し、次回訪問時もダークのままです。
マルチテーマサポート
主にダークモードについて話してきましたが、next-themes は任意の数のテーマをサポートします。例えばパープルテーマやグリーンテーマなど:
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
themes={['light', 'dark', 'purple', 'green']}
>
{children}
</ThemeProvider>
CSS で対応するスタイルを定義:
.purple {
--background: #f3e8ff;
--foreground: #581c87;
}
.green {
--background: #dcfce7;
--foreground: #14532d;
}
CSS 変数と組み合わせると非常に柔軟です。
実践テクニックとよくある問題
Tailwind CSS との連携
Tailwind を使っているなら、設定はさらに簡単です。まず tailwind.config.js で以下を設定:
module.exports = {
darkMode: 'class',
// その他の設定...
}
あとは dark: プレフィックスを自由に使えます:
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<h1 className="text-2xl font-bold">タイトル</h1>
<p className="text-gray-600 dark:text-gray-400">段落テキスト</p>
</div>
Tailwind の dark: バリアントは <html> 要素に dark class があるときに有効になり、next-themes の動作と完璧に一致します。
アニメーションとトランジション
disableTransitionOnChange について、個人的には有効にすることをおすすめします。CSS に transition プロパティが多いと、テーマ切り替え時に全要素が一斉にアニメーションして、少し散らかった印象になります。
トランジション効果が欲しい場合:
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange={false}
>
{children}
</ThemeProvider>
グローバル CSS に追加:
* {
transition: background-color 0.2s ease, color 0.2s ease;
}
切り替え時にフェードイン/フェードアウト効果が得られます。ただ、何度か試しましたが、トランジションなしの方がすっきりしていると感じます。
TypeScript 型サポート
next-themes の TypeScript サポートは優秀です。テーマ型を拡張する場合:
import { useTheme } from 'next-themes'
type Theme = 'light' | 'dark' | 'purple'
export function useCustomTheme() {
const { theme, setTheme } = useTheme()
return {
theme: theme as Theme,
setTheme: (theme: Theme) => setTheme(theme),
}
}
使用時に型補完が効き、存在しないテーマを誤って設定するのを防げます。
よくある問題のトラブルシューティング
問題 1:テーマは切り替わるがスタイルが変わらない
以下を確認:
- Tailwind の
darkModeが'class'になっているか - CSS で
dark:プレフィックスや.darkセレクタを正しく使っているか - ブラウザの開発者ツールで
<html>要素の class が正しく追加されているか
問題 2:ページをリロードするとまだ一瞬ちらつく
ちらつきが残る場合:
<html>にsuppressHydrationWarningを付け忘れていないか- ThemeProvider の位置が正しいか
- 他の script が干渉していないか(Google Analytics など)
問題 3:システムテーマ追従が効かない
確認ポイント:
enableSystemがtrueになっているか- ブラウザが
prefers-color-schemeをサポートしているか(モダンブラウザはすべて対応) - 現在のテーマが
systemか(手動切り替え後はlightやdarkの可能性あり)
まとめ
振り返ると、最初はちらつき問題に悩まされ、今ではスムーズにダークモードを実装できるようになりました。next-themes は本当に大助かりです。技術的な問題を解決するだけでなく、ユーザー体験を大きく改善してくれます。
核心ポイントのおさらい:
next-themesで Next.js ダークモードのちらつき問題をゼロ設定で解決できる<html>にsuppressHydrationWarningを付け、ThemeProvider はクライアントコンポーネントにする- Tailwind の
darkModeを'class'に設定 - テーマ切り替えボタンは mounted 後にレンダリングし、hydration 不一致を回避
- システムテーマ追従と手動切り替えは完璧に共存できる
まだ next-themes を試していないなら、ぜひ使ってみてください。公式ドキュメントも分かりやすいです:github.com/pacocoursey/next-themes
今すぐ Next.js プロジェクトにスムーズなダークモードを追加しましょう。ユーザーはきっと喜んでくれます。
Next.js ダークモード実装の完全フロー
next-themes でゼロちらつきのダークモードを実装し、システムテーマ追従と手動切り替えに対応
⏱️ 目安時間: 30 分
- 1
ステップ1: next-themes をインストール
依存関係をインストール:
• npm install next-themes
他のパッケージマネージャーでも可:
• pnpm add next-themes
• yarn add next-themes
注意:next-themes はゼロ依存ライブラリで、サイズも非常に小さい - 2
ステップ2: ThemeProvider を設定
ルートレイアウトに追加:
• providers.tsx を作成('use client' を付与)
• ThemeProvider で children をラップ
• app/layout.tsx でインポートして使用
重要な設定:
• attribute="class":class でテーマを切り替え
• enableSystem:システムテーマ追従を有効化
• storageKey:localStorage の保存キー名
注意:ThemeProvider はクライアントコンポーネントである必要がある - 3
ステップ3: Tailwind CSS を設定
tailwind.config.js で:
• darkMode: 'class' を設定
• html タグの class に応じて Tailwind がテーマを切り替える
設定例:
module.exports = {
darkMode: 'class',
// ... その他の設定
}
dark: プレフィックスでダークスタイルを定義:
className="bg-white dark:bg-gray-900" - 4
ステップ4: hydration 警告を修正
html タグに追加:
• suppressHydrationWarning 属性
• サーバーとクライアントのテーマ不一致による警告を回避
layout.tsx で:
<html lang="ja" suppressHydrationWarning>
<body>{children}</body>
</html>
これで Next.js の hydration 警告を回避できる - 5
ステップ5: テーマ切り替えボタンを作成
useTheme フックを使用:
• ThemeToggle コンポーネントを作成('use client' を付与)
• useTheme() で theme と setTheme を取得
• mounted 後にレンダリングし、hydration 不一致を回避
例:
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
テーマを切り替え
</button> - 6
ステップ6: テストと検証
テストのポイント:
• 手動テーマ切り替え(ちらつきなし)
• システムテーマ追従
• リロード後のテーマ保持
• ページ間でのテーマ一貫性
チェックリスト:
• ページ読み込み時にちらつきなし
• テーマ切り替えがスムーズ
• localStorage に正しく保存
• システムテーマ変更時に自動追従
FAQ
ページ読み込み時にちらつくのはなぜ?
next-themes と他のテーマライブラリの違いは?
システムテーマ追従はどう実装する?
suppressHydrationWarning が必要な理由は?
テーマ切り替えボタンを mounted 後にレンダリングする理由は?
テーマ切り替えロジックをカスタマイズするには?
next-themes はどのテーマに対応している?
4分で読めます · 公開日: 2025年12月20日 · 更新日: 2026年6月8日
Next.js 完全ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Next.js 国際化と静的生成:SSG 多言語サイト構築の実践ガイド
ビルドエラーからパフォーマンス最適化まで、App Router でハマりどころのない多言語静的生成を実装する手順を解説。完全なコード例、generateStaticParams の設定詳細、ビルド時間短縮のテクニックを含みます。
第 39 / 47 記事
次の記事
Vercel からの脱出:Next.js Docker 自前ホスティング完全ガイド
Vercel の高額請求にうんざりしていませんか?本ガイドでは Docker で Next.js を自前ホスティングする方法を解説します。standalone 設定、リバースプロキシ、ストリーミングレンダリングの修正まで網羅し、月 $300〜500 の節約が可能です。
第 41 / 47 記事
関連記事
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js Middleware 実践ガイド:パスマッチ、Edge Runtime 制限とよくある落とし穴
コメント
GitHubアカウントでログインしてコメントできます