Material-UI v9 と Next.js App Router の SSR を両立する ThemeRegistry 実装
MUI(Material-UI)v9 と Next.js App Router の Server Components で SSR とスタイル一貫性を両立する ThemeRegistry の実装方法を解説。Emotion キャッシュ・ハイドレーション・テーマ切替まで網羅します。
はじめに
Material-UI(MUI)を Next.js App Router で使うと、Server Components の SSR と Emotion のスタイル管理が衝突しやすく、初期描画でスタイルなし HTML が一瞬見える「FOUC(Flash of Unstyled Content)」が発生しがちです。本記事では ThemeRegistry パターンを解説します。nagiyu-platform 自体では後述する MUI の公式プロバイダ構成を採用していますが、その背景理解として ThemeRegistry パターンの仕組みは押さえておく価値があります。
何が問題か
App Router では、ページ全体を Server Component として SSR した HTML をブラウザに返し、その後ハイドレーションが走ります。一方 MUI は Emotion がスタイルをクライアントで動的注入するので、SSR された HTML には Emotion のスタイルが含まれていません。結果としてスタイルなし HTML が見えてしまいます。
これを解決するには、SSR 時に Emotion の <style> を HTML head に流し込む必要があります。Next.js では useServerInsertedHTML フックがその受け皿です。
ThemeRegistry の実装
// src/components/ThemeRegistry.tsx
'use client';
import { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';
import { ThemeProvider, CssBaseline } from '@mui/material';
import { theme } from '@/theme';
export default function ThemeRegistry({ children }: { children: React.ReactNode }) {
const [{ cache, flush }] = useState(() => {
const cache = createCache({ key: 'mui', prepend: true });
cache.compat = true;
const prevInsert = cache.insert;
let inserted: string[] = [];
cache.insert = (...args) => {
const serialized = args[1];
if (cache.inserted[serialized.name] === undefined) {
inserted.push(serialized.name);
}
return prevInsert(...args);
};
const flush = () => {
const prev = inserted;
inserted = [];
return prev;
};
return { cache, flush };
});
useServerInsertedHTML(() => {
const names = flush();
if (names.length === 0) return null;
let styles = '';
for (const name of names) {
styles += cache.inserted[name];
}
return (
<style
data-emotion={`${cache.key} ${names.join(' ')}`}
dangerouslySetInnerHTML={{ __html: styles }}
/>
);
});
return (
<CacheProvider value={cache}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</CacheProvider>
);
}
ポイントを整理します。
'use client'が必要:useStateとuseServerInsertedHTMLはクライアント境界の APIprepend: true:Emotion のスタイルを<head>の先頭に挿入。CSS の優先順位を下げて、ユーザーの CSS が上書きしやすくなるcache.insertのラップ:挿入されたクラス名を控えておき、useServerInsertedHTMLのタイミングで一括出力flush():1 回出力した分は控えから消す。連続した SSR で重複出力を防ぐ
レイアウトでの使い方
// app/layout.tsx
import ThemeRegistry from '@/components/ThemeRegistry';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<body>
<ThemeRegistry>{children}</ThemeRegistry>
</body>
</html>
);
}
ThemeRegistry 配下では useTheme() や MUI コンポーネントが Server Components の HTML 出力でも正しいスタイルで描画されます。
テーマの定義
// src/theme.ts
import { createTheme } from '@mui/material';
export const theme = createTheme({
palette: {
primary: { main: '#1976d2' },
secondary: { main: '#ec407a' },
},
typography: {
fontFamily: ['system-ui', '-apple-system', 'sans-serif'].join(','),
},
components: {
MuiButton: {
styleOverrides: {
root: { textTransform: 'none' }, // ボタンを大文字化しない
},
},
},
});
textTransform: 'none' は日本語サイトの定番設定。デフォルトの uppercase は英語前提なので、日本語ボタンが歪に見える問題を解消します。
ダークモード対応
ダークモードを切り替える場合、テーマを切り替えるためのコンテキストを ThemeRegistry 内に持たせます。
'use client';
import { useState, createContext, useContext } from 'react';
import { createTheme, ThemeProvider } from '@mui/material';
const ColorModeContext = createContext({ toggle: () => {} });
export const useColorMode = () => useContext(ColorModeContext);
export default function ThemeRegistry({ children }: { children: React.ReactNode }) {
const [mode, setMode] = useState<'light' | 'dark'>('light');
const theme = createTheme({ palette: { mode } });
const ctx = { toggle: () => setMode((m) => (m === 'light' ? 'dark' : 'light')) };
// ... cache / flush 部分は同じ
return (
<CacheProvider value={cache}>
<ColorModeContext.Provider value={ctx}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</ColorModeContext.Provider>
</CacheProvider>
);
}
初期値は localStorage から復元する、サーバーから OS の preferred-color-scheme を読む、などの工夫が必要ですが、まずはトグルが動くことから始めると良いです。
Server Components と Client Components の境界
MUI のコンポーネントの多くは内部で useState や Context を使うので、呼び出すファイルは 'use client' が必要です。Server Components から MUI を直接使おうとするとエラーになります。
実用的な切り分け:
- Server Component: データ取得 / レイアウト構造
- Client Component: フォーム / インタラクティブ要素 / MUI のコンポーネント呼び出し
// app/tech/[slug]/page.tsx は Server Component のまま
import ArticleBody from './ArticleBody'; // 'use client' のクライアントコンポーネント
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const article = await getArticle(slug);
return <ArticleBody article={article} />;
}
データ取得を Server で済ませ、props として Client Component に渡すパターンが基本形です。
実装ノート
ここまで手書きの ThemeRegistry を解説してきましたが、正直に書くと、nagiyu-platform 本体では私はこの自前キャッシュを使っていません。実際に採用しているのは MUI 公式の AppRouterCacheProvider(@mui/material-nextjs/v16-appRouter)で、Emotion キャッシュと useServerInsertedHTML 相当の処理はライブラリ側に任せています。理由はシンプルで、自前で cache.insert をラップして flush() を回す保守コストを、私が背負い続けたくなかったからです。MUI も v9 系まで上がり、公式実装が安定したので、車輪の再発明をやめました。
さらに私は、このプロバイダを各サービスにコピペするのではなく、@nagiyu/ui の AppThemeProvider という 1 つの Client Component に集約しています。中身は AppRouterCacheProvider → ThemeProvider → CssBaseline → children というだけの薄いラッパーで、Portal の layout.tsx からは <AppThemeProvider> を呼ぶだけ。テーマ実装が複数サービスで散らからないよう、共通ライブラリに寄せたのが自分の設計判断です。
ハマったポイント
記事のサンプルコードと違って公式プロバイダに寄せた今でも、私が実際に踏んだ地雷はテーマ定義側に残りました。
- palette に CSS 変数を入れて壊した: 最初は
tokens.cssのvar(--...)をそのまま palette に流し込もうとしたのですが、MUI 内部のalpha()/decomposeColor()はvar()を色として解釈できず、ホバー時の半透明色などで例外が出ました。結局theme.tsの palette には#1565c0のような具体的な色値を直書きし、角丸・影・トランジションなどの非カラー値だけvar(--radius-md)のように CSS 変数を参照する、という割り切りに落ち着いています。 'use client'の置き場所:AppThemeProvider自体は'use client'ですが、その子に Server Component を素直に渡せることを最初は半信半疑で確かめました。textTransformの上書き忘れ: 日本語ボタンが大文字化されないよう、自分のテーマではbutton.textTransform: 'none'を必ず入れています。これを忘れると英語ラベルだけ妙に大文字になります。- 複数の Emotion インスタンス: 依存ライブラリが別バージョンの Emotion を持ち込むと別キャッシュで二重スタイルになるため、
npm ls @emotion/reactで重複していないかは今でも確認します。
現在の運用
現状 nagiyu-platform では、テーマはライト 1 種類で固定運用しています。本記事ではダークモード切替の例も載せましたが、これは「やろうと思えばできる」という解説であって、Portal 本番にトグルはまだ入れていません。私の優先順位として、まずは複数サービスで見た目を揃えること(共通 AppThemeProvider + tokens.css)を先に固め、ダークモードは後回しにした、というのが正直なところです。逆に言えば、テーマ実装を 1 箇所に集約してあるおかげで、ダークモード対応が必要になっても触る場所は @nagiyu/ui 配下だけで済む見込みです。
まとめ
ThemeRegistry パターンは、MUI v9 と Next.js App Router の SSR 互換を最小コストで実現する定番手法です。Emotion キャッシュをカスタムしてスタイルを useServerInsertedHTML で吐き出すこと、Server Components / Client Components の境界を意識することで、ハイドレーション崩れや FOUC のない安定した描画が得られます。