Skip to content

Instantly share code, notes, and snippets.

@evanweible-wf
Last active December 28, 2025 04:00
Show Gist options
  • Select an option

  • Save evanweible-wf/6787e1d62e1aa71623fffdf8d5a5a1fe to your computer and use it in GitHub Desktop.

Select an option

Save evanweible-wf/6787e1d62e1aa71623fffdf8d5a5a1fe to your computer and use it in GitHub Desktop.

Emotion + Streaming SSR + Suspense

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.

The Problem

Hydration mismatch errors occur when using Emotion (or Emotion-based libraries like Material-UI, Chakra UI) with React 18+ streaming SSR and Suspense boundaries.

Root Cause

  1. 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).

  2. React's streaming hydration: When the Suspense boundary resolves, React's internal $RC function moves the content from the hidden placeholder into the visible DOM.

  3. 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 the data-emotion attribute. This transforms data-emotion="mui 17h54qd-MuiTypography-root" into data-emotion="mui".

  4. Hydration mismatch: The server sent data-emotion="mui 17h54qd-MuiTypography-root", but the client DOM shows data-emotion="mui" after Emotion's processing. React detects this attribute difference and throws a hydration error.

Why Initial Styles Work Fine

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.


The Solution

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.

How It Works

  1. Intercept the HTML stream from React's streaming renderer
  2. Detect Suspense boundary chunks by looking for <div hidden id="S: markers
  3. Remove inline Emotion style tags from those chunks using regex
  4. Preserve initial shell styles in <head>

Why This Works

  • 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.

Implementation

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))
    },
  })
}

Caveats

  • 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's data-emotion format. Future changes may require updates.

Framework Integration

The core transformation logic is framework-agnostic. Only the integration point differs by framework. Here's an example that works in TanStack Start:

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) }

Adaptation Checklist

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

Conclusion

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.

/**
* Utility for fixing Emotion SSR streaming hydration issues with React 18+ Suspense.
*
* Problem: Emotion renders inline style tags in Suspense boundaries. When React hydrates,
* client-side Emotion modifies these tags' data-emotion attributes, causing hydration mismatches.
*
* Solution: Remove inline style tags from Suspense chunks during server streaming.
* Styles remain available via <head> or Emotion's client cache.
*/
/**
* Default Emotion cache key for Material-UI.
*/
export const EMOTION_CACHE_KEY = 'mui'
/**
* Configuration options for the Emotion style fixer stream.
*/
export interface EmotionStyleFixerOptions {
/**
* The Emotion cache key to target (e.g., "mui", "chakra", "css").
* Must match the key used in your Emotion cache configuration.
*/
cacheKey: string
/**
* React Suspense boundary marker pattern.
* @default '<div hidden id="S:'
*/
suspenseBoundaryMarker?: string
}
/**
* Creates a TransformStream that removes inline Emotion style tags from Suspense chunks.
*
* @param options - Configuration options
* @returns TransformStream for use with .pipeThrough()
*
* @example
* ```typescript
* const emotionFixer = createEmotionStyleFixerStreamSimple({ cacheKey: 'mui' })
* const transformed = response.body.pipeThrough(emotionFixer)
* ```
*/
export function createEmotionStyleFixerStreamSimple(
options: EmotionStyleFixerOptions,
): TransformStream<Uint8Array, Uint8Array> {
const { cacheKey, suspenseBoundaryMarker = '<div hidden id="S:' } = options
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const inlineStyleRegex = new RegExp(
`<style data-emotion="${cacheKey}[^"]*">.*?</style>`,
'g',
)
return new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
try {
let html = decoder.decode(chunk, { stream: true })
if (html.includes(suspenseBoundaryMarker)) {
html = html.replace(inlineStyleRegex, '')
}
controller.enqueue(encoder.encode(html))
} catch (error) {
console.error('[EmotionStyleFixer] Error processing chunk:', error)
controller.enqueue(chunk)
}
},
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment