エラーの内容
ブラウザのコンソールを開くと、次のような警告が表示されます:
Warning: Prop `className` did not match. Server: "sc-bdVTJa" Client: "sc-fzoLsD"
ページの見た目は問題ないように見えます。しかし、Reactがこのハイドレーションの不一致警告を発するのは単なるノイズではありません。開発環境ではコンソールが埋め尽くされ、本番環境ではReactがサーバーレンダリングされたHTMLを破棄してクライアント側で全て再レンダリングします。その結果、初回描画が遅くなり、SEOが悪化し、Core Web Vitalsにも悪影響を与えます。
発生する原因
styled-componentsは固定のクラス名を使用しません。実行のたびにゼロからリセットされる内部カウンターを使用して、実行時にクラス名を生成します。
サーバー側では、カウンターはゼロから始まり sc-bdVTJa のような名前を生成します。次にブラウザがJavaScriptを読み込み、Reactがハイドレーションを開始するとカウンターがリセットされ、全く異なるシーケンス sc-fzoLsD が出力されます。
サーバーのHTMLは className="sc-bdVTJa" と言っています。クライアント側のReactのレンダリングは className="sc-fzoLsD" と言っています。これらが一致しないため、警告が発生します。
CSS Modulesも同様の問題を引き起こすことがありますが、原因は異なります。動的に組み立てられたクラス名文字列、またはサーバーとクライアントのwebpackの設定間で localIdentName のハッシュパターンが一致していない場合に発生します。
問題の診断
修正に取り掛かる前に、実際に何が壊れているかを確認しましょう:
- 右クリック → ページのソースを表示。
<head>内に<style>タグが見えますか?スタイルがない場合、SSRのスタイル収集が実行されていません。 - SSRのレンダリング関数に
ServerStyleSheetがあるか確認してください。なければそれが原因です。 - Next.jsの場合、
next.config.jsでstyled-componentsのコンパイラオプションが有効になっているか確認してください。
修正1:babel-plugin-styled-componentsのインストール(通常のReact / Express SSR)
根本的な原因は非決定論的なクラス名です。このbabelプラグインは、カウンターの代わりに各コンポーネントのファイルパスと表示名からクラス名を導出することで問題を解決します。これにより、sc-bdVTJa はサーバーとクライアントで毎回同じ安定したハッシュになります。
npm install --save-dev babel-plugin-styled-components
# または
yarn add -D babel-plugin-styled-components
.babelrc または babel.config.js に追加します:
{
"plugins": [
[
"babel-plugin-styled-components",
{
"ssr": true,
"displayName": true,
"preprocess": false
}
]
]
}
重要なのは "ssr": true です。これによりクラス名の生成がカウンターベースから決定論的な方式に切り替わります。"displayName": true はボーナス機能で、各クラスにコンポーネント名のプレフィックスを付けてDevToolsでのデバッグを大幅に楽にします。
修正2:SSRレンダリングにServerStyleSheetを組み込む
babelプラグインだけでは不十分です。styled-componentsはサーバー側でスタイルを収集し、ブラウザに送信する前にHTMLレスポンスに注入する必要があります。このステップを省略すると、クライアントはゼロから始めて全てを再生成します。
import { ServerStyleSheet } from 'styled-components'
import { renderToString } from 'react-dom/server'
import App from './App'
function handleRequest(req, res) {
const sheet = new ServerStyleSheet()
try {
const html = renderToString(
sheet.collectStyles(<App />)
)
const styleTags = sheet.getStyleTags()
res.send(`
<!DOCTYPE html>
<html>
<head>${styleTags}</head>
<body><div id="root">${html}</div></body>
</html>
`)
} finally {
sheet.seal()
}
}
sheet.collectStyles() はアプリをラップし、コンポーネントのレンダリング時にすべてのstyled-componentsのスタイルをインターセプトします。sheet.getStyleTags() は注入可能な <style> タグを返します。
finally ブロックは必須です。sheet.seal() を省略すると、リクエストをまたいでメモリリークが蓄積されます。各サーバーレスポンスが解放されないゴーストスタイルシートを残し続けます。
修正3:Next.js — 組み込みStyled-Componentsコンパイラを有効にする
Next.js 12にはRustベースのstyled-componentsコンパイラが搭載されており、babelプラグインは不要になりました。next.config.js に1行追加するだけで有効になります:
/** @type {import('next').NextConfig} */
const nextConfig = {
compiler: {
styledComponents: true,
},
}
module.exports = nextConfig
開発環境でより細かい制御が必要な場合は、オプションオブジェクトを展開できます:
compiler: {
styledComponents: {
displayName: true,
ssr: true,
fileName: true,
meaninglessFileNames: ['index', 'styles'],
minify: true,
transpileTemplateLiterals: true,
pure: true,
},
},
これを有効にしたら、babelの設定から babel-plugin-styled-components を削除してください。両方を実行すると競合が発生します。しかも、その競合は発見しにくい種類のものです。
Pages Router の場合、サーバー側のスタイル収集を処理するためにカスタムの _document.tsx も必要です:
import Document, { DocumentContext, DocumentInitialProps } from 'next/document'
import { ServerStyleSheet } from 'styled-components'
export default class MyDocument extends Document {
static async getInitialProps(
ctx: DocumentContext
): Promise<DocumentInitialProps> {
const sheet = new ServerStyleSheet()
const originalRenderPage = ctx.renderPage
try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) =>
sheet.collectStyles(<App {...props} />),
})
const initialProps = await Document.getInitialProps(ctx)
return {
...initialProps,
styles: [initialProps.styles, sheet.getStyleElement()],
}
} finally {
sheet.seal()
}
}
}
App Router(Next.js 13以降)は別の話です。React Server ComponentsにはReactコンテキストがないため、ServerStyleSheet は機能しません。選択肢としては、Tailwind CSS、next/font、または他のRSC対応ライブラリへの移行があります。あるいは、スタイルレジストリを使用して styled-components を 'use client' コンポーネントに限定することもできます:
'use client'
// lib/registry.tsx
import React, { useState } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
import { ServerStyleSheet, StyleSheetManager } from 'styled-components'
export default function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode
}) {
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet())
useServerInsertedHTML(() => {
const styles = styledComponentsStyleSheet.getStyleElement()
styledComponentsStyleSheet.instance.clearTag()
return <>{styles}</>
})
if (typeof window !== 'undefined') return <>{children}</>
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
)
}
このレジストリでルートレイアウトをラップすると、styled-componentsのスタイルが正しくサーバーレスポンスに出力されます。
修正4:CSS Modules — 動的なクラス名の組み立てを避ける
CSS Moduleのクラス名は設計上安定しています。文字列の連結で組み立てたり、サーバーとクライアントのバンドル間でwebpackの localIdentName ハッシュが1文字でも異なる場合に問題が発生します。
文字列の組み立ての代わりに clsx を使用してください:
import styles from './Button.module.css'
import clsx from 'clsx'
function Button({ primary, disabled }) {
// 悪い例 — 壊れやすい文字列の組み立て:
// const cls = `${styles.button} ${primary ? styles.primary : ''}`
// 良い例 — clsxはundefined/falseを適切に処理する:
const cls = clsx(styles.button, {
[styles.primary]: primary,
[styles.disabled]: disabled,
})
return <button className={cls}>Click me</button>
}
カスタムwebpack設定の場合、サーバーとクライアントの両方の設定で localIdentName を全く同じパターンに固定してください:
// webpack.config.js — サーバーとクライアントの両方で同一にする
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[path][name]__[local]--[hash:base64:5]',
},
},
}
修正の確認
- 警告の消去 — 開発サーバーを完全に再起動します(
Ctrl+C後にnpm run dev)。ホットリロードではbabelの設定変更は反映されません。Prop className did not matchの警告が完全に消えていることを確認してください。 - ページのソースを確認 — サーバーレンダリングされたHTMLには、JavaScriptが実行される前の最初のロード時点で既に
<head>内に<style>タグが含まれているはずです。 - クラス名を比較する — DevToolsでstyled要素を調査してそのクラスを確認します。ページのソース表示で同じ要素を探してください。一文字も違わず完全に一致していなければなりません。
- 本番ビルドを実行する:
# Next.js
npm run build && npm run start
# ブラウザを開く → F12 → Consoleタブ
# "className" でフィルタリング — 結果ゼロなら問題なし
修正後によくある間違い
- babelプラグインとNext.jsコンパイラを同時に有効にしている — 競合が発生します。どちらか一方を選び、もう一方を設定から削除してください。
- 開発サーバーの再起動を省略した — babelの変更はホットリロードでは反映されません。完全な再起動が必要です。
sheet.seal()が抜けている — スタイルシートがリクエストをまたいで蓄積され、最終的にメモリリークが発生します。必ずfinallyブロックでsealしてください。- SSRコレクションなしで
createGlobalStyleを使用している — グローバルスタイルも同じカウンターシステムを通るため、ServerStyleSheetを通す必要があります。 - App Router + styled-componentsでレジストリを使用していない — Reactコンテキストがないとスタイルが注入されません。この構成ではレジストリパターンは省略できません。

