Note: This document describes a workaround for a known limitation in Emotion's handling of React 18+ streaming SSR with Suspense. This should be considered a temporary fix until Emotion or the React ecosystem provides first-class support.
Hydration mismatch errors occur when using Emotion (or Emotion-based libraries like Material-UI, Chakra UI) with React 18+ streaming SSR and Suspense boundaries.
-
Emotion's SSR behavior in Suspense: When Emotion encounters styles in a Suspense boundary, it renders inline
<style data-emotion="key id1 id2 ...">...</style>tags within the suspended content (inside<div hidden id="S:0">...</div>containers). -
React's streaming hydration: When the Suspense boundary resolves, React's internal
$RCfunction moves the content from the hidden placeholder into the visible DOM. -
Client-side Emotion interference: As this content is inserted, client-side Emotion intercepts the inline style tags and hoists them to
<head>, stripping the style IDs from thedata-emotionattribute. This transformsdata-emotion="mui 17h54qd-MuiTypography-root"intodata-emotion="mui". -
Hydration mismatch: The server sent
data-emotion="mui 17h54qd-MuiTypography-root", but the client DOM showsdata-emotion="mui"after Emotion's processing. React detects this attribute difference and throws a hydration error.
The initial shell styles (for non-suspended content and fallback UI) are rendered into <head> before hydration starts, so they don't trigger this problematic code path.
Remove inline style tags from Suspense chunks during server-side streaming.
This approach intercepts the HTML stream on the server and strips out the inline <style data-emotion="..."> tags from Suspense boundary content before they reach the client.
- Intercept the HTML stream from React's streaming renderer
- Detect Suspense boundary chunks by looking for
<div hidden id="S:markers - Remove inline Emotion style tags from those chunks using regex
- Preserve initial shell styles in
<head>
- Styles are available: Either from the initial SSR render in
<head>or via Emotion's client-side cache. When Suspense content hydrates, Emotion injects any needed styles synchronously. - No client-side interference: Without inline tags to intercept, Emotion can't modify the DOM in ways that cause hydration mismatches.
- Minimal overhead: Simple string operations, no buffering, only processes Suspense chunks.
See emotion-stream-utils.ts for the complete implementation. Here's the basic approach:
function createEmotionStyleFixerStream(options: EmotionStyleFixerOptions) {
const { cacheKey, suspenseBoundaryMarker = '<div hidden id="S:' } = options
const inlineStyleRegex = new RegExp(
`<style data-emotion="${cacheKey}[^"]*">.*?</style>`,
'g',
)
return new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
let html = decoder.decode(chunk, { stream: true })
// Only process Suspense boundary chunks
if (html.includes(suspenseBoundaryMarker)) {
html = html.replace(inlineStyleRegex, '')
}
controller.enqueue(encoder.encode(html))
},
})
}- Regex-based HTML processing: Not a robust HTML parser, but Emotion's style tags are well-formed and predictable.
- Split style tags: If a
<style>tag is split across chunk boundaries, the regex won't match it. React's streaming typically sends complete chunks, but this is a theoretical edge case. - Maintenance: Depends on React's Suspense markers (
<div hidden id="S:) and Emotion'sdata-emotionformat. Future changes may require updates.
The core transformation logic is framework-agnostic. Only the integration point differs by framework. Here's an example that works in TanStack Start:
// server.ts
import { createStartHandler, defaultStreamHandler, defineHandlerCallback } from '@tanstack/react-start/server'
import { createEmotionStyleFixerStream } from './emotion-stream-utils'
const customHandler = defineHandlerCallback(async ({ request, router, responseHeaders }) => {
const response = await defaultStreamHandler({ request, router, responseHeaders })
if (!response.body) return response
const emotionFixer = createEmotionStyleFixerStream({
cacheKey: 'mui',
debug: process.env.NODE_ENV === 'development'
})
return new Response(response.body.pipeThrough(emotionFixer), {
status: response.status,
headers: response.headers,
})
})
export default { fetch: createStartHandler(customHandler) }This approach should work with other frameworks. Consider the following steps if adapting to another framework:
- Identify where your framework exposes the SSR output stream
- Create a stream transformer that removes
<style data-emotion="...">from Suspense chunks - Ensure the Emotion cache key matches across server, client, and transformer
- Test with multiple Suspense boundaries, nested Suspense, and error boundaries
- Verify no hydration errors occur in the browser console
This workaround resolves Emotion + React 18+ streaming SSR hydration mismatches by removing inline style tags from Suspense chunks during streaming. The solution has minimal overhead and requires no component changes.
The core logic is extracted into emotion-stream-utils.ts and can be adapted to any framework that supports stream transformation.
Community feedback and contributions are welcome.