Server-Side Rendering (SSR)
Use Stoop with Next.js App Router for perfect SSR support
Stoop supports Next.js App Router with zero FOUC (Flash of Unstyled Content). Use useServerInsertedHTML to capture styles during SSR streaming.
For API details, see getCssText and preloadTheme.
SSR Entry Point (stoop/ssr)
For Server Components in Next.js App Router, use the SSR entry point to avoid bundling React code:
Important: Even for SSR, you should create your Stoop instance in stoop.theme.ts at module level, not inside components:
// stoop.theme.ts - Create SSR instance at module level
import { createStoop } from "stoop/ssr";
const stoop = createStoop({
theme: {
colors: { primary: "#0070f3" },
space: { medium: "16px" },
},
});
// Export APIs in Server Components
export const { css, globalCss, keyframes, getCssText, warmCache, preloadTheme, createTheme } = stoop;
// app/components/ServerComponent.tsx
import { css } from "../../stoop.theme";
// Usage in Server Component
const className = css({
padding: "$medium",
color: "$primary",
});
Note: SSR instance returns StoopServerInstance (no React APIs)
- Available:
css,globalCss,keyframes,getCssText,warmCache,preloadTheme,createTheme,theme,config - NOT available:
styled,Provider,useTheme(React APIs)
When to use:
- Server Components (no React APIs needed)
- Server-side CSS generation
- SSR with
getCssText()
When NOT to use:
- Client Components (use regular
stoopimport) - Components using
styled()oruseTheme()
Note: The core stoop package generates CSS at runtime. We're exploring build-time extraction for future releases. If you need it today, consider Vanilla Extract or Panda CSS.
Next.js App Router
Step 1: Create Styles Component
Create a client component that handles SSR style injection:
// app/components/Styles.tsx
"use client";
import { useServerInsertedHTML } from "next/navigation";
import { getCssText } from "../../stoop.theme";
export function Styles({ initialTheme }: { initialTheme: string }) {
useServerInsertedHTML(() => {
// getCssText() includes all themes, global CSS, and component styles
const cssText = getCssText();
if (!cssText) return null;
return (
<style
id="stoop-ssr"
dangerouslySetInnerHTML={{ __html: cssText }}
suppressHydrationWarning
/>
);
});
return null;
}
Step 2: Use in Root Layout
Add the Styles component to your root layout:
// app/layout.tsx
import { cookies } from "next/headers";
import { type ReactNode } from "react";
import { Provider } from "../stoop.theme";
import { Styles } from "./components/Styles";
export default async function RootLayout({ children }: { children: ReactNode }) {
// Detect theme from cookies on server
const cookieStore = await cookies();
const themeCookie = cookieStore.get("stoop-theme");
const initialTheme = themeCookie?.value || "light";
return (
<html lang="en" data-theme={initialTheme} suppressHydrationWarning>
<body>
{/* Inject SSR styles - prevents FOUC */}
<Styles initialTheme={initialTheme} />
{/* Wrap app with theme provider */}
<Provider defaultTheme={initialTheme} storageKey="stoop-theme">
{children}
</Provider>
</body>
</html>
);
}
Preventing FOUC (Flash of Unstyled Content)
To prevent FOUC when loading a theme from localStorage, use preloadTheme():
// app/layout.tsx
"use client";
import { useEffect, type ReactNode } from "react";
import { preloadTheme } from "./theme";
export default function RootLayout({ children }: { children: ReactNode }) {
useEffect(() => {
// Preload all themes before React renders to prevent FOUC
preloadTheme();
}, []);
return <>{children}</>;
}
For a more robust solution, create a script that runs before React:
// app/layout.tsx
import Script from "next/script";
import { type ReactNode } from "react";
import { getCssText } from "./theme";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html>
<head>
<style
id="stoop-styles"
dangerouslySetInnerHTML={{ __html: getCssText() }}
/>
<Script
id="stoop-theme-preload"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
const savedTheme = localStorage.getItem('stoop-theme');
if (savedTheme) {
// Preload theme CSS variables synchronously
// This prevents FOUC
}
})();
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}
Next.js Pages Router
Basic Setup
In your pages/_document.tsx, inject the CSS and set the initial theme:
import { Html, Head, Main, NextScript } from "next/document";
import { getCssText } from "../theme";
export default function Document() {
const cssText = getCssText();
return (
<Html data-theme="light" lang="en">
<Head>
<style
id="stoop"
dangerouslySetInnerHTML={{ __html: cssText }}
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
Important: The data-theme="light" attribute on <Html> is required to prevent FOUC. Without it, CSS variables won't apply until the Provider mounts client-side.
With Theme Support in _app.tsx
Wrap your app with the Provider in pages/_app.tsx:
import type { AppProps } from "next/app";
import { Provider } from "../theme";
export default function App({ Component, pageProps }: AppProps) {
return (
<Provider>
<Component {...pageProps} />
</Provider>
);
}
The Provider will:
- Read theme from localStorage on mount
- Update
data-themeattribute if different from default - Handle theme switching via
useThemehook
Why data-theme is Required
Stoop generates CSS with attribute selectors:
[data-theme="light"] {
--colors-primary: #0070f3;
}
[data-theme="dark"] {
--colors-primary: #3291ff;
}
Without data-theme on the HTML element, these selectors don't match and CSS variables are undefined, causing FOUC.
How It Works
- SSR: HTML renders with
data-theme="light"→ CSS variables apply immediately - Hydration: Provider mounts and checks localStorage
- Theme Sync: If stored theme differs, Provider updates
data-themeattribute - Result: No FOUC, smooth theme transitions
Advanced: Cookie-Based Theme Detection
For perfect SSR theme matching (no flash even for returning users with dark mode):
// pages/_document.tsx
import Document, { DocumentContext, Html, Head, Main, NextScript } from "next/document";
import { getCssText } from "../theme";
class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx);
const theme = ctx.req?.cookies["stoop-theme"] || "light";
return { ...initialProps, theme };
}
render() {
const { theme } = this.props as { theme: string };
const cssText = getCssText();
return (
<Html data-theme={theme} lang="en">
<Head>
<style
id="stoop"
dangerouslySetInnerHTML={{ __html: cssText }}
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;
This reads the theme from cookies server-side, ensuring perfect SSR/client match.
Hydration Behavior
The Provider starts with the default theme to match SSR output, preventing hydration mismatches. Theme preferences restore from storage in useLayoutEffect after mount:
- Server renders with default theme
- Client hydrates with default theme (matches server)
- Theme preference restores after hydration (no mismatch)
Best Practices
- Call
getCssText()in your document/layout so styles are available on initial render - Use Provider for theme management; it handles theme preloading and prevents FOUC
getCssText()includes CSS variables for all configured themes- Call
getCssText()with no parameters; it includes all themes - Use SSR entry point for Server Components; import from
stoop/ssrto avoid bundling React code
Common Issues
Styles not appearing on initial load
Make sure you're calling getCssText() in your document/layout and the styles are injected before React hydrates.
FOUC with theme switching
The Provider handles theme preloading. For manual control, use preloadTheme() before React renders, but this is usually not necessary.
Theme not found warning
This warning should not occur since getCssText() doesn't use the theme parameter. If you see this, ensure your themes config is properly set up in createStoop.
Related Pages
- Theme Setup - Configure themes for SSR
- getCssText API - Get all generated CSS text for SSR
- preloadTheme API - Preload theme CSS variables