The Error
Pop open your browser console and you'll see something like this:
Warning: Prop `className` did not match. Server: "sc-bdVTJa" Client: "sc-fzoLsD"
The page looks fine visually. React fires this hydration mismatch warning, and it's not just noise. In dev it drowns your console. In production, React throws away the server-rendered HTML and re-renders everything client-side โ slower first paint, worse SEO, and a Core Web Vitals hit.
Why This Happens
styled-components doesn't use fixed class names. It generates them at runtime using an internal counter that starts fresh every time it runs.
On the server, the counter starts at zero and produces something like sc-bdVTJa. Then the browser loads JavaScript, React begins hydrating, and the counter resets โ outputting a completely different sequence: sc-fzoLsD.
The server HTML says className="sc-bdVTJa". React's client render says className="sc-fzoLsD". They don't match. Warning fired.
CSS Modules can trigger the same issue, but for a different reason: dynamically assembled class name strings, or a mismatched localIdentName hash pattern between server and client webpack configs.
Quick Diagnosis
Before reaching for a fix, confirm what's actually broken:
- Right-click โ View Page Source. Do you see
<style>tags in<head>? Missing styles mean SSR collection isn't running. - Check your SSR render function for
ServerStyleSheet. Not there? That's your culprit. - On Next.js, verify the styled-components compiler option is enabled in
next.config.js.
Fix 1: Install babel-plugin-styled-components (Classic React / Express SSR)
The root cause is non-deterministic class names. This babel plugin solves it by deriving class names from each component's file path and display name instead of a counter โ so sc-bdVTJa becomes the same stable hash on server and client, every single run.
npm install --save-dev babel-plugin-styled-components
# or
yarn add -D babel-plugin-styled-components
Add it to .babelrc or babel.config.js:
{
"plugins": [
[
"babel-plugin-styled-components",
{
"ssr": true,
"displayName": true,
"preprocess": false
}
]
]
}
"ssr": true is the one that matters โ it switches class name generation from counter-based to deterministic. "displayName": true is a bonus: it prefixes each class with the component name, making DevTools debugging significantly easier.
Fix 2: Wire Up ServerStyleSheet in Your SSR Render
The babel plugin alone isn't enough. styled-components also needs to collect styles server-side and inject them into the HTML response before it's sent to the browser. Skip this step and the client starts from scratch, re-generating everything.
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() wraps your app and intercepts every styled-components style as components render. sheet.getStyleTags() hands back ready-to-inject <style> tags.
The finally block is non-negotiable. Skip sheet.seal() and memory leaks accumulate across requests โ each server response leaves a ghost style sheet behind that never gets freed.
Fix 3: Next.js โ Enable the Built-in Styled-Components Compiler
Next.js 12 shipped a Rust-based styled-components compiler that makes the babel plugin obsolete. One config line in next.config.js turns it on:
/** @type {import('next').NextConfig} */
const nextConfig = {
compiler: {
styledComponents: true,
},
}
module.exports = nextConfig
Need finer control in development? Expand the option object:
compiler: {
styledComponents: {
displayName: true,
ssr: true,
fileName: true,
meaninglessFileNames: ['index', 'styles'],
minify: true,
transpileTemplateLiterals: true,
pure: true,
},
},
Once this is on, remove babel-plugin-styled-components from your babel config. Running both causes conflicts โ and they're the subtle, hard-to-diagnose kind.
The Pages Router also needs a custom _document.tsx to handle server-side style collection:
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()
}
}
}
The App Router (Next.js 13+) is a different story. React Server Components have no React context, so ServerStyleSheet simply doesn't work there. Your options: switch to Tailwind CSS, next/font, or another RSC-compatible library โ or keep styled-components confined to 'use client' components using a style registry:
'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>
)
}
Wrap your root layout with this registry and styled-components will flush its styles into the server response correctly.
Fix 4: CSS Modules โ Avoid Dynamic Class Name Building
CSS Module class names are stable by design. They break when you assemble them with string concatenation, or when your webpack localIdentName hash differs between server and client bundles โ even by one character.
Switch to clsx instead of string building:
import styles from './Button.module.css'
import clsx from 'clsx'
function Button({ primary, disabled }) {
// Bad โ fragile string building:
// const cls = `${styles.button} ${primary ? styles.primary : ''}`
// Good โ clsx handles undefined/false gracefully:
const cls = clsx(styles.button, {
[styles.primary]: primary,
[styles.disabled]: disabled,
})
return <button className={cls}>Click me</button>
}
On a custom webpack config, pin localIdentName to the exact same pattern in both server and client configs:
// webpack.config.js โ identical on BOTH server and client
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[path][name]__[local]--[hash:base64:5]',
},
},
}
Verify the Fix
- Clear the warning โ do a full dev server restart (
Ctrl+C, thennpm run dev). Hot reload won't pick up babel config changes. TheProp className did not matchwarning must be gone entirely. - Check View Source โ the server-rendered HTML should already contain
<style>tags in<head>on the very first load, before any JavaScript runs. - Compare class names โ inspect a styled element in DevTools and note its class. Find the same element in View Source. They must be character-for-character identical.
- Run a production build:
# Next.js
npm run build && npm run start
# Open browser โ F12 โ Console tab
# Filter for "className" โ zero results means you're clean
Common Mistakes After Fixing
- Both babel plugin and Next.js compiler active simultaneously โ they conflict. Pick one and remove the other from your config.
- Skipped the dev server restart โ babel changes aren't picked up by hot reload. Full restart required.
- Missing
sheet.seal()โ style sheets pile up across requests and eventually leak memory. Always seal in afinallyblock. - Using
createGlobalStylewithout SSR collection โ global styles run through the same counter system and need to pass throughServerStyleSheettoo. - App Router + styled-components without a registry โ no React context means no style injection. The registry pattern isn't optional in this setup.

