言語を切り替える
テーマを切り替える

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 コンポーネントを作成します。私は通常、providerscomponents ディレクトリに置きます。

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 を使いたい場面もあります。例えばサーバー側でユーザーのテーマ設定を知り、あらゆるちらつきを回避したい場合。手順はこうなります:

  1. ミドルウェアで Cookie を読み取り、レスポンスヘッダーに設定
  2. サーバーレンダリング時にレスポンスヘッダーに基づいてテーマをレンダリング
  3. クライアントで 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:システムテーマ追従が効かない

確認ポイント:

  • enableSystemtrue になっているか
  • ブラウザが prefers-color-scheme をサポートしているか(モダンブラウザはすべて対応)
  • 現在のテーマが system か(手動切り替え後は lightdark の可能性あり)

まとめ

振り返ると、最初はちらつき問題に悩まされ、今ではスムーズにダークモードを実装できるようになりました。next-themes は本当に大助かりです。技術的な問題を解決するだけでなく、ユーザー体験を大きく改善してくれます。

核心ポイントのおさらい:

  1. next-themes で Next.js ダークモードのちらつき問題をゼロ設定で解決できる
  2. <html>suppressHydrationWarning を付け、ThemeProvider はクライアントコンポーネントにする
  3. Tailwind の darkMode'class' に設定
  4. テーマ切り替えボタンは mounted 後にレンダリングし、hydration 不一致を回避
  5. システムテーマ追従と手動切り替えは完璧に共存できる

まだ next-themes を試していないなら、ぜひ使ってみてください。公式ドキュメントも分かりやすいです:github.com/pacocoursey/next-themes

今すぐ Next.js プロジェクトにスムーズなダークモードを追加しましょう。ユーザーはきっと喜んでくれます。

Next.js ダークモード実装の完全フロー

next-themes でゼロちらつきのダークモードを実装し、システムテーマ追従と手動切り替えに対応

⏱️ 目安時間: 30 分

  1. 1

    ステップ1: next-themes をインストール

    依存関係をインストール:
    • npm install next-themes

    他のパッケージマネージャーでも可:
    • pnpm add next-themes
    • yarn add next-themes

    注意:next-themes はゼロ依存ライブラリで、サイズも非常に小さい
  2. 2

    ステップ2: ThemeProvider を設定

    ルートレイアウトに追加:
    • providers.tsx を作成('use client' を付与)
    • ThemeProvider で children をラップ
    • app/layout.tsx でインポートして使用

    重要な設定:
    • attribute="class":class でテーマを切り替え
    • enableSystem:システムテーマ追従を有効化
    • storageKey:localStorage の保存キー名

    注意:ThemeProvider はクライアントコンポーネントである必要がある
  3. 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

    ステップ4: hydration 警告を修正

    html タグに追加:
    • suppressHydrationWarning 属性
    • サーバーとクライアントのテーマ不一致による警告を回避

    layout.tsx で:
    <html lang="ja" suppressHydrationWarning>
    <body>{children}</body>
    </html>

    これで Next.js の hydration 警告を回避できる
  5. 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

    ステップ6: テストと検証

    テストのポイント:
    • 手動テーマ切り替え(ちらつきなし)
    • システムテーマ追従
    • リロード後のテーマ保持
    • ページ間でのテーマ一貫性

    チェックリスト:
    • ページ読み込み時にちらつきなし
    • テーマ切り替えがスムーズ
    • localStorage に正しく保存
    • システムテーマ変更時に自動追従

FAQ

ページ読み込み時にちらつくのはなぜ?
SSR 時にサーバーはユーザーのテーマ設定を知らないため、デフォルトテーマでレンダリングされます。クライアントの hydration 時に localStorage を読み取ってテーマを切り替えるため、ちらつきが発生します。next-themes はサーバーレンダリング時に script タグを注入してテーマを事前に読み取り、この問題を完璧に解決します。
next-themes と他のテーマライブラリの違いは?
next-themes は Next.js 専用設計で SSR のちらつき問題を完璧に解決し、ゼロ依存でサイズも小さい(1kb 未満)。use-dark-mode は Next.js 向けではなく、SSR シーンで互換性の問題がある。theme-ui は機能豊富だが重く、ダークモード切り替えだけが必要な場合は過剰設計。
システムテーマ追従はどう実装する?
ThemeProvider で enableSystem={true} を設定すると、next-themes がシステムのテーマ設定を自動検出して適用します。ユーザーは手動でテーマを切り替えることもでき、手動設定はシステムテーマを上書きします。light、dark、system の 3 モードに対応。
suppressHydrationWarning が必要な理由は?
サーバーレンダリング時はユーザーのテーマ設定が分からずデフォルトテーマでレンダリングされますが、クライアント hydration 時に localStorage に基づいてテーマを切り替えるため、サーバーとクライアントの HTML が不一致になります。suppressHydrationWarning は React にこれが想定内であることを伝え、警告を抑制します。
テーマ切り替えボタンを mounted 後にレンダリングする理由は?
hydration 不一致を避けるためです。サーバーレンダリング時は localStorage のテーマを知ることができず、ボタンを直接レンダリングするとサーバーとクライアントの HTML が不一致になり、React hydration エラーが発生します。mounted 後にレンダリングすれば、クライアント側でのみ描画されます。
テーマ切り替えロジックをカスタマイズするには?
useTheme フックの setTheme メソッドでカスタム切り替えロジックを実装できます。例:setTheme(theme === 'dark' ? 'light' : 'dark')。特定のテーマを直接設定することも可能:setTheme('dark')、setTheme('light')、setTheme('system')。
next-themes はどのテーマに対応している?
デフォルトでは light と dark の 2 テーマをサポート。ThemeProvider の themes プロパティでカスタムテーマを追加することも可能。例:themes={['light', 'dark', 'blue', 'green']}。各テーマは異なる CSS クラス名に対応。

4分で読めます · 公開日: 2025年12月20日 · 更新日: 2026年6月8日

関連記事

コメント

GitHubアカウントでログインしてコメントできます