はじめに
Next.js App Router に Emotion を導入して外部スタイルシートと組み合わせると、CSS の適用順が意図通りにならないケースがあります。
私が実際にハマったのは、通常のページでは問題なく表示されているのに、エラーページのみアイコンのサイズがおかしくなるという状況でした。原因は <head> 内のスタイル関連タグの挿入位置を制御していなかったことで、エラーページでは通常ページと異なる位置にタグが挿入され、CSS の適用順が逆転していたことでした。
この記事では以下の内容を扱います。
- App Router で Emotion を動かすための基本セットアップ
- なぜエラーページでだけ問題が起きたのか
-
useServerInsertedHTMLで挿入順を制御する解決策
対象読者: Next.js App Router + Emotion を使っていて、外部 CSS とのスタイル衝突に悩んでいる方
Next.js App Router で Emotion を使うための基本設定
App Router で Emotion を使うには、通常の Next.js プロジェクトに比べてひと手間必要です。これは Emotion が App Router に正式対応していないことが背景にあります。Next.js の公式ドキュメント(CSS-in-JS)でも、Emotion は「currently working on support」と記載されており、対応作業が進行中です。
tsconfig.json
jsxImportSource に @emotion/react を指定することで、JSX が自動的に Emotion の jsx 関数を通るようになります。
{
"compilerOptions": {
"jsxImportSource": "@emotion/react"
}
}
Server Component での注意点
App Router ではコンポーネントがデフォルトで Server Component になります。jsxImportSource: "@emotion/react" を設定すると全 JSX が Emotion の jsx 関数を通るようになりますが、Server Component 環境ではこれが動作しません。そのため、Server Component のファイル先頭に以下のプラグマを書いて React のデフォルトに戻します。
/* @jsxImportSource react */
Client Component では 'use client' を付ければ Emotion がそのまま使えます。
詳しくはこちらの記事が参考になりました。
発生した問題: エラーページでのアイコンサイズ崩れ
私が開発していたページでは、アイコンに Material Symbols(Google が提供する外部フォント CSS)を使っていました。Material Symbols は next/font/google に対応していないため、layout.tsx の <head> に <link> タグを直接書いていました。
<head>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:..."
/>
</head>
この状態でアイコンのフォントサイズを Emotion で指定したところ、ページによって効いたり効かなかったりする現象が発生しました。
- 通常のページ → 正しく表示される
- エラーページ → アイコンのサイズだけがおかしい(他の Emotion スタイルは正常)
原因を紐解く
<head> 内のタグ順序がページによって違う
まず、エラーページと記事ページの <head> の中身を比べてみました。記事ページでは Material Symbols の <link> が Emotion の <style> より前にあるのに対し、エラーページでは逆順になっていました。
<!-- 記事ページ(CSR後)-->
<head>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+...">
<style data-emotion="css ...">/* Emotion のスタイル */</style>
</head>
<!-- エラーページ(CSR後)-->
<head>
<style data-emotion="css ...">/* Emotion のスタイル */</style>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+...">
</head>
CSS の同一詳細度では後に定義された方が優先されます。エラーページでは Material Symbols の <link> が Emotion の <style> より後に来るため、.material-symbols-outlined の font-size に関して Material Symbols 側が勝ってしまっていました。
他の Emotion スタイルが正常に動いていたのは、Material Symbols CSS が .material-symbols-outlined などのアイコン専用セレクターしか持たないため、他の Emotion スタイルとは競合しなかったからです。
エラーページでは layout.tsx の挙動が違う
なぜ順序が逆になるのかを調べると、エラーページの SSR 時点での HTML に違いがありました。
not-found.tsx などのエラーページでは、おそらく Next.js の内部的な処理の違いにより、layout.tsx に書いた静的 <head> の JSX が SSR 時点での HTML に含まれないようです。実際に 404 ページの HTML を確認すると、layout.tsx の <head> に書いていた <link> タグが一切出力されていませんでした。(代わりに、これらのリソースは React の Flight Protocol を通じて <link> を <head> に挿入されているようです。)
一方、Emotion はデフォルトではハイドレーション時に <style> タグを <head> に挿入します。この挿入が Flight Protocol より前に行われるため、エラーページでは Emotion → Material Symbols という順序になってしまっていました。
-
Emotion CSS: ハイドレーション時に
<style>を<head>へ挿入(先) -
Material Symbols CSS: Flight Protocol を通じて
<link>を<head>へ挿入(後)
<head> への挿入を明示的に制御する必要がある
記事ページでは静的 <head> JSX に Material Symbols <link> が含まれるため、SSR 時点での HTML の時点で Emotion より前に存在します。しかしエラーページではその保証がなく、挿入順序が変わってしまいます。
ページ種別に関わらず CSS の適用順を安定させるには、<head> へのタグ挿入を明示的に制御する必要があります。
解決: useServerInsertedHTML で挿入順を明示制御
Material Symbols の <link> も useServerInsertedHTML 経由で挿入することで、<head> への挿入順序を制御できます。
外部スタイルシートの挿入コンポーネント
useRef で二重挿入を防ぎながら <link> を挿入します。
'use client'
import { useRef } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
export function MaterialSymbolsStylesheet() {
const isInserted = useRef(false)
useServerInsertedHTML(() => {
if (isInserted.current) return null
isInserted.current = true
return (
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:..."
/>
)
})
return null
}
useServerInsertedHTML は Next.js が SSR 時に HTML を生成する際に </head> の直前にコンテンツを挿入するフックです。これにより静的 <head> JSX を使わなくても、エラーページを含む全ページで確実に <head> に出力されます。
EmotionRegistry コンポーネント
Next.js の公式ドキュメント(CSS-in-JS)では、styled-components や styled-jsx での useServerInsertedHTML の使い方が紹介されています。Emotion も同じアプローチで対応できます。
Streaming に対応するには cache.insert をモンキーパッチして差分のみを追跡する必要がありますが、ここでは概念を示す簡略版を載せます。詳細な実装は Next.js 公式ドキュメントの CSS-in-JS を参考に、AIエージェントによしなに実装させるとよいと思います。
'use client'
import createCache from '@emotion/cache'
import { CacheProvider } from '@emotion/react'
import { useServerInsertedHTML } from 'next/navigation'
import { useState } from 'react'
export function EmotionRegistry({ children }: { children: React.ReactNode }) {
const [cache] = useState(() => {
const cache = createCache({ key: 'css' })
cache.compat = true
return cache
})
useServerInsertedHTML(() => (
<style
data-emotion={`${cache.key} ${Object.keys(cache.inserted).join(' ')}`}
dangerouslySetInnerHTML={{
__html: Object.values(cache.inserted).join(' '),
}}
/>
))
return <CacheProvider value={cache}>{children}</CacheProvider>
}
layout.tsx での配置順
ポイントは MaterialSymbolsStylesheet を EmotionRegistry より前に配置することです。useServerInsertedHTML の呼び出し順がそのまま </head> への挿入順になります。
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{/* 先に挿入 = CSS 上で先に定義される */}
<MaterialSymbolsStylesheet />
{/* 後に挿入 = CSS 上で後に定義される = こちらが優先 */}
<EmotionRegistry>
{children}
</EmotionRegistry>
</body>
</html>
)
}
これにより、SSR 時に HTML の <head> 内に「Material Symbols → Emotion」の順で CSS が挿入されます。
<head>
<!-- useServerInsertedHTML で挿入(Material Symbols が先) -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+...">
<!-- useServerInsertedHTML で挿入(Emotion が後 = 優先) -->
<style data-emotion="css ...">/* Emotion のスタイル */</style>
</head>
おわりに
今回の内容を簡単にまとめます。
- App Router のエラーページでは
layout.tsxの静的<head>JSX が SSR 時点での HTML に出力されず、スタイル関連タグが通常ページと異なる位置に挿入されることがある -
<head>内のタグ挿入位置を制御していないと、ページ種別によって CSS の適用順が変わり、意図しないスタイルが適用されることがある -
useServerInsertedHTMLを使うことで全ページ共通で<head>への挿入を確実に行え、挿入順も制御できる
同じ構成で困っている方の参考になれば幸いです。