vercel / vercel/next.js

CSS Modules break selective hydration in React 18 & 19 causing FOUC

Open
#77,239 6 comments 12 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

CSS Lazy Loading linear: next Pages Router React
Dominant language
JavaScript
Stars
142k
Forks
32.4k
Avg merge
2d 14h
Merged PRs (30d)
351

Description

Link to the code that reproduces this issue

Pages Router: https://stackblitz.com/edit/stackblitz-starters-h9tuzhzs

App Router: https://stackblitz.com/edit/stackblitz-starters-u1jswo3f

⨯ [Error [InvariantError]: Invariant: Expected workStore to exist when handling searchParams in a client Page. This is a bug in Next.js.] { digest: '3850182586' }

To Reproduce
  1. Open the StackBlitz project:
    1.1 (pages router) Stackblitz
    1.2. (app router) Stackblitz
  2. Look at the footer
    2.1 (pages router) See that the Footer beeing NOT red for 5 seconds
    2.2 (app router) See that the Footer is NOT using the SSR but rendering the fallback
  3. After 5 seconds (hydration), the Footer turns red:

https://github.com/user-attachments/assets/2b1015d1-30bd-4615-93b3-4329b5c737d2

The key elements of the reproduction:

A main page that lazily loads a footer component inside Suspense and uses CSS Modules:

// pages/index.tsx
import React, { useState, Suspense } from 'react';

export default function Home() {
  return (
    <h1>
      Lazy Load Hydration Demo
      <Suspense fallback={<div>Loading...</div>}>
        <Footer />
      </Suspense>
    </h1>
  );
}

// Less important footer component with 5-second simulated delay
const Footer = React.lazy(async () => {
  // Simulated delay
  await sleep(5000); 
  const { Footer } = await import('../components/footer');
  return { default: Footer };
});
  1. A footer component using CSS Modules:
// components/footer.tsx
import { useState, useEffect } from 'react';
import * as styles from "./footer.module.css";

export const Footer = () => {
  const [isHydrated, setIsHydrated] = useState(false);
  
  useEffect(() => {
    setIsHydrated(true);
  }, []);

  return (
    <div className={styles.footer}>
      {isHydrated ? '💦' : '🌵'}
      Footer
    </div>
  );
};
  1. Simple CSS Module:
/* components/footer.module.css */
.footer {
  color: red;
}
Current vs. Expected behavior

React 18 introduced selective hydration, a powerful feature that improves performance by prioritizing the hydration of critical UI elements. However, when using this feature with CSS Modules in Next.js Pages Router, it causes a Flash of Unstyled Content (FOUC).

The issue arises because Next.js does not recognize that a lazy-loaded component was rendered during SSR and therefore loads the associated CSS lazily as well. While the component's HTML is correctly rendered server-side and the class name is applied, the CSS is not loaded until the component hydrates on the client, causing a visual flash.

Current Behavior
  • During initial page load, the server pre-renders the entire page (including the footer)
  • The footer component is correctly server-rendered with the proper class name
  • However, the CSS Module styling is not included in the initial CSS bundle
  • When the page loads, the footer is visible but unstyled (no red color)
  • After ~5 seconds, the footer's JavaScript chunk loads and hydrates
  • Only then does the CSS Module load and apply the styles, causing a visible flash of styling change
Expected Behavior
  • When using selective hydration with React.lazy and Suspense, Next.js should recognize components that were SSR'd
  • CSS Modules associated with lazily-loaded, SSR'd components should be included in the initial CSS bundle
  • Even though JavaScript hydration is deferred, the styling should be present from the start
  • No flash of unstyled content should occur as the component transitions from SSR to hydrated state

This issue effectively breaks one of the key benefits of React 18's selective hydration feature - which is to provide a seamless experience where pre-rendered HTML looks identical to the hydrated component while JavaScript loads in the background.

Why This Matters

Selective hydration is one of React 18's most powerful features for improving perceived performance. It allows:

  • Prioritizing critical UI elements for hydration
  • Letting users interact with the most important parts of the UI while less important components load
  • Maintaining a visually stable UI throughout the hydration process

When working correctly, selective hydration allows sites to deliver complex interfaces that are visually complete and stable on initial load, with interactivity progressively enhancing as JavaScript loads

The current issue forces developers to choose between:

  1. Using CSS Modules but accepting FOUC
  2. Not using selective hydration's performance benefits
  3. Switching to inline styles or global CSS
Provide environment information
Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 24.3.0: Thu Jan  2 20:24:16 PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6000
  Available memory (MB): 65536
  Available CPU cores: 10
Binaries:
  Node: 20.18.2
  npm: 10.8.2
  Yarn: 1.22.22
  pnpm: 10.2.0
Relevant Packages:
  next: 15.3.0-canary.12 // Latest available version is detected (15.3.0-canary.12).
  eslint-config-next: N/A
  react: 18.2.0
  react-dom: 18.2.0
  typescript: N/A
Next.js Config:
  output: N/A
Which area(s) are affected? (Select all that apply)

CSS, Lazy Loading, App Router, Pages Router, React

Which stage(s) are affected? (Select all that apply)

next build (local), next start (local), next dev (local), Vercel (Deployed), Other (Deployed)

Additional context

More information about selective hydration:

React 18 introduction to selective hydration: https://youtu.be/pj5N-Khihgc?t=688
Demonstration of selective hydration advantages: https://www.youtube.com/watch?v=pj5N-Khihgc&t=966s
React Working Group discussion: https://github.com/reactwg/react-18/discussions/37

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by running both StackBlitz reproductions and compare the Pages Router files components/footer.tsx and components/footer.module.css with the App Router files app/layout.tsx, app/page.tsx, and app/index.css. Trace how the lazy Footer, Suspense, SSR output, and CSS Module are handled during build and hydration. Done means SSR-rendered lazy components receive their CSS before hydration, with no FOUC in either router.

Written by the indexing model from the issue text.

Assessment

Tech stack
css, javascript, nextjs, react
Domain
frontend, performance, web-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.