Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/p1-9-streaming-ssr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'typestyles': minor
---

Document streaming SSR and RSC patterns against request-scoped collection (P1.9). Add `TYPESTYLES_STYLE_ID`, `typestylesStyleHtml`, `injectStylesIntoHtml`, and `streamingDocumentShell` helpers on `typestyles/server`. Expand the SSR guide with request-safe collection, RSC decision table, and streaming examples.
5 changes: 4 additions & 1 deletion IMPROVEMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,12 @@ Bugs and credibility issues that lose evaluations on contact. Do these first.
cross-link from best-practices/performance pages (which currently just say
"use inline styles").

- [ ] **P1.9 — Streaming SSR story** (PR: )
- [ ] **P1.9 — Streaming SSR story** (PR: #94)
- Document streaming SSR (`renderToPipeableStream`) and RSC patterns against the
request-scoped collection from P0.4; add helpers where they remove boilerplate.
- Shipped: expanded [SSR guide](/docs/ssr) (request-safe collection, RSC decision
table, streaming helpers); `typestylesStyleHtml`, `injectStylesIntoHtml`,
`streamingDocumentShell`, `TYPESTYLES_STYLE_ID` on `typestyles/server`.

## P2 — Ecosystem & DX

Expand Down
8 changes: 8 additions & 0 deletions docs/content/docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ cx(card(), isElevated && card.elevated, externalClassName);
- `reset()`, `flushSync()`, `ensureDocumentStylesAttached()`: Primarily for tests and advanced setup; see [Testing](/docs/testing)
- `insertRules(rules)`: Low-level rule insertion (mainly for library authors)

### SSR helpers (`typestyles/server`)

- `collectStyles(renderFn)`: Wrap a sync or async render; returns `{ html, css }`. Request-isolated on Node via `AsyncLocalStorage`. See [SSR](/docs/ssr).
- `TYPESTYLES_STYLE_ID`: Stable `"typestyles"` id for the managed `<style>` element (must match client hydration)
- `typestylesStyleHtml(css)`: Render `<style id="typestyles">…</style>` (empty string when `css` is empty)
- `injectStylesIntoHtml(html, css)`: Insert collected CSS before `</head>`
- `streamingDocumentShell(css)`: Open doctype + `<head>` + `<body>` for `renderToPipeableStream` (pair with `collectStyles` for the CSS pass)

### Class naming helpers

- `mergeClassNaming(partial?)`: Build a full `ClassNamingConfig` from partial options
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ The `collectStyles` call:
**Performance tips:**

- Cache SSR output when possible
- Use streaming SSR with care (requires two renders for style collection)
- Use streaming SSR with care — see [Streaming SSR](/docs/ssr#streaming-ssr-express--node) (two renders for custom Node servers; Next.js App Router should use `TypestylesStylesheet` or build-time extraction instead)
- The collected CSS string is often modest, but measure it for your app

### CSS size
Expand Down
121 changes: 109 additions & 12 deletions docs/content/docs/ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ description: Render typestyles on the server for better performance and SEO

TypeStyles supports SSR out of the box. Instead of injecting styles into the DOM during rendering, you can collect all the CSS on the server and include it in the HTML response.

## Request-safe collection

Every `collectStyles()` call runs inside an **isolated sheet store**. On Node, that isolation uses `AsyncLocalStorage`, so concurrent SSR requests (multiple Express handlers, parallel Remix bots, overlapping `renderToString` passes) each get their own CSS buffer. CSS from request A never leaks into request B.

This matters for:

- **Concurrent HTTP handlers** — two users hitting your server at the same time
- **The two-pass streaming pattern** — a sync `renderToString` pass for CSS, then `renderToPipeableStream` for the response (see below)
- **Async renders** — `collectStyles(async () => …)` is supported; isolation holds for the full await

`collectStylesFromModules()` (build extraction) uses the same isolation. In the browser bundle, isolation is a no-op because there is only one document.

## Basic setup

Import `collectStyles` from `typestyles/server`:
Expand Down Expand Up @@ -70,6 +82,81 @@ On the client:
2. **Reuse**: If found, it reuses that element and avoids re-injecting the CSS
3. **Seamless transition**: No flicker or style recalculation during hydration

## React Server Components (Next.js App Router)

Next.js streams HTML by default. TypeStyles supports three patterns — pick based on whether you extract CSS at build time or collect at request time.

| Pattern | When to use |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Build-time extraction** | Production apps — static `typestyles.css`, no runtime sheet. See [Zero-runtime extraction](/docs/zero-runtime). |
| **`getRegisteredCss()` in root layout** | Runtime mode; styles are imported on the server before layout runs. Simplest request-time setup. |
| **`TypestylesStylesheet` + `useServerInsertedHTML`** | Runtime mode; styles register during the **same streamed render** as `{children}`. Use when lazy boundaries or client-only imports would make Option B miss rules. |

### Build-time extraction (recommended for production)

Import the extracted stylesheet in your root layout. No `collectStyles()` per request — CSS is static:

```tsx
// app/layout.tsx
import './typestyles.css';

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
```

Configure extraction with `@typestyles/next/build` — see [Zero-runtime extraction](/docs/zero-runtime).

### Runtime: server layout + `getRegisteredCss`

When style modules load synchronously on the server, the registered sheet already contains everything the layout needs:

```tsx
// app/layout.tsx
import { getRegisteredCss } from '@typestyles/next';

export default function RootLayout({ children }: { children: React.ReactNode }) {
const css = getRegisteredCss();

return (
<html lang="en">
<head>
{css ? <style id="typestyles" dangerouslySetInnerHTML={{ __html: css }} /> : null}
</head>
<body>{children}</body>
</html>
);
}
```

This works in **Server Components** — no `'use client'` required. Request isolation from `AsyncLocalStorage` applies when you also call `collectStyles()` elsewhere in the same request (for example a streaming pre-pass).

### Runtime: streaming collection with `TypestylesStylesheet`

For styles that register during the streamed React tree (client boundaries, lazy imports, or `useServerInsertedHTML` timing), use the client component wrapper:

```tsx
// app/layout.tsx
import { TypestylesStylesheet } from '@typestyles/next/client';

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<TypestylesStylesheet />
{children}
</body>
</html>
);
}
```

`TypestylesStylesheet` calls React's `useServerInsertedHTML` so CSS collected during the real SSR pass is injected into the streamed document — no separate `renderToString` pass and no CSS leakage between concurrent requests.

## Next.js

Install the official integration so App Router, `useServerInsertedHTML`, and server helpers stay aligned:
Expand All @@ -80,9 +167,9 @@ pnpm add @typestyles/next typestyles

See the [`@typestyles/next` package README](https://github.com/type-styles/typestyles/tree/main/packages/next) for build-time extraction (`withTypestylesExtract`) and Turbopack notes.

### App Router (recommended)
### App Router (runtime) — quick reference

**Option A — server layout + `getRegisteredCss`:** simplest when your typestyles modules are loaded on the server before layout runs. Outputs the full registered stylesheet (everything in `allRules` after imports and any sync registration).
The [RSC section above](#react-server-components-nextjs-app-router) has full examples and a decision table. The options below are the same patterns in brief:

```tsx
// app/layout.tsx
Expand Down Expand Up @@ -120,7 +207,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
}
```

**Subtree-only CSS (advanced):** `collectStylesFromComponent` / `getTypestylesMetadata` from `@typestyles/next/server` run `renderToString` on a specific element and return CSS registered during that pass. Use when you intentionally scope extraction to a component tree; you still need to place the returned string in `<head>` yourself (Next `metadata` cannot carry arbitrary `<style>` bodies).
**Subtree-only CSS (advanced):** `collectStylesFromComponent` / `getTypestylesMetadata` from `@typestyles/next/server` run `renderToString` on a specific element and return the CSS registered during that pass. Each call is request-isolated via `collectStyles()`.

### Pages Router

Expand Down Expand Up @@ -151,7 +238,7 @@ Custom `pages/_document.tsx` with `collectStyles(() => ctx.renderPage())` is fra
import type { EntryContext } from '@remix-run/node';
import { RemixServer } from '@remix-run/react';
import { renderToString } from 'react-dom/server';
import { collectStyles } from 'typestyles/server';
import { collectStyles, injectStylesIntoHtml } from 'typestyles/server';

export default function handleRequest(
request: Request,
Expand All @@ -171,7 +258,7 @@ export default function handleRequest(
);
}

let documentHtml = html.replace('</head>', `<style id="typestyles">${css}</style></head>`);
let documentHtml = injectStylesIntoHtml(html, css);
if (!documentHtml.trimStart().toLowerCase().startsWith('<!doctype')) {
documentHtml = `<!DOCTYPE html>${documentHtml}`;
}
Expand All @@ -184,14 +271,24 @@ export default function handleRequest(

## Streaming SSR (Express / Node)

You need CSS before the streamed shell is sent. That usually means **one synchronous `renderToString` pass** inside `collectStyles`, then a **second** pass with `renderToPipeableStream` for the same UI (two renders, same trade-off as above).
React's `renderToPipeableStream` does not give you a complete HTML string up front, so TypeStyles cannot collect CSS from the stream itself. The supported pattern is **two renders**:

1. **CSS pass** — `collectStyles(() => renderToString(<App />))` inside the same request (isolated via `AsyncLocalStorage`)
2. **Stream pass** — `renderToPipeableStream(<App />)` for the response body

Open your HTML shell (including `<style id="typestyles">…</style>`) **before** `pipe(res)`, then finish the document when React says the stream is done. Exact callbacks depend on whether you use Suspense—see [React `renderToPipeableStream`](https://react.dev/reference/react-dom/server/renderToPipeableStream).
Open your HTML shell (including the style tag) **before** `pipe(res)`, then finish the document when React signals the stream is done. Exact callbacks depend on Suspense boundaries — see [React `renderToPipeableStream`](https://react.dev/reference/react-dom/server/renderToPipeableStream).

Helpers from `typestyles/server` reduce boilerplate:

- `typestylesStyleHtml(css)` — `<style id="typestyles">…</style>`
- `streamingDocumentShell(css)` — doctype + `<head>` with charset and styles + `<body>`
- `injectStylesIntoHtml(html, css)` — insert before `</head>` (Remix-style full documents)
- `TYPESTYLES_STYLE_ID` — the stable `"typestyles"` id (must match client hydration)

```tsx
import { renderToString, renderToPipeableStream } from 'react-dom/server';
import type { Response } from 'express';
import { collectStyles } from 'typestyles/server';
import { collectStyles, streamingDocumentShell } from 'typestyles/server';

app.get('/', (req, res: Response) => {
const { css } = collectStyles(() => renderToString(<App />));
Expand All @@ -200,9 +297,7 @@ app.get('/', (req, res: Response) => {
onShellReady() {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.write(
`<!DOCTYPE html><html><head><meta charset="utf-8"/><style id="typestyles">${css}</style></head><body>`,
);
res.write(streamingDocumentShell(css));
pipe(res);
},
onShellError(error) {
Expand All @@ -216,6 +311,8 @@ app.get('/', (req, res: Response) => {

If your shell opens wrappers around `<App />`, add the matching closing tags in the appropriate callback (`onAllReady` when streaming deferred content, or follow the full pattern in the React docs) so you never write to `res` after it has ended.

**Next.js App Router** already streams — prefer `TypestylesStylesheet` or build-time extraction instead of manual two-pass rendering.

## Important considerations

### Style deduplication
Expand All @@ -228,7 +325,7 @@ All CSS is included by default. For large applications, you might want to implem

### Client-side hydration

Always use the same `id="typestyles"` on both server and client:
Always use the same `id="typestyles"` on both server and client. Import `TYPESTYLES_STYLE_ID` from `typestyles/server` or use `typestylesStyleHtml()` so the id stays consistent:

```html
<!-- Server -->
Expand Down
9 changes: 5 additions & 4 deletions docs/content/docs/zero-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,17 +208,18 @@ Wire it into `package.json` before `next build` (see `@examples/next`):

For Vite/Rollup, pass the emitted asset path (e.g. `dist/typestyles.css`) and omit `manifestFile` unless you write one yourself.

Add the `TypestylesProvider` to your root layout to handle streaming SSR (React 18 App Router):
Add `TypestylesStylesheet` to your root layout to handle streaming SSR (React 18 App Router):

```tsx
// app/layout.tsx
import { TypestylesProvider } from '@typestyles/next';
import { TypestylesStylesheet } from '@typestyles/next/client';

export default function RootLayout({ children }) {
return (
<html>
<body>
<TypestylesProvider>{children}</TypestylesProvider>
<TypestylesStylesheet />
{children}
</body>
</html>
);
Expand Down Expand Up @@ -278,4 +279,4 @@ const isStaticCSS =

- **Dynamic styles** — styles that are created based on runtime data (e.g. user-provided values) cannot be extracted at build time. Use the `hybrid` mode or keep those styles in runtime mode.
- **Lazy routes** — styles imported via dynamic `import()` in route code-splitting may not be captured unless those modules are also listed in `extract.modules`.
- **Server Components (Next.js)** — the `TypestylesProvider` handles streaming SSR for React Server Components. Ensure it is present in your root layout.
- **Server Components (Next.js)** — `TypestylesStylesheet` handles streaming SSR for React Server Components. Ensure it is present in your root layout, or use build-time extraction. See [SSR — RSC patterns](/docs/ssr#react-server-components-nextjs-app-router).
43 changes: 42 additions & 1 deletion packages/typestyles/src/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { collectStyles } from './server';
import {
collectStyles,
injectStylesIntoHtml,
streamingDocumentShell,
typestylesStyleHtml,
TYPESTYLES_STYLE_ID,
} from './server';
import { defaultClassNamingConfig } from './class-naming';
import { createComponent } from './component';
import { createTokens } from './tokens';
Expand Down Expand Up @@ -83,3 +89,38 @@ describe('collectStyles', () => {
expect(b.css).not.toContain('.iso-a');
});
});

describe('SSR helpers', () => {
it('exports the stable style element id', () => {
expect(TYPESTYLES_STYLE_ID).toBe('typestyles');
});

it('typestylesStyleHtml returns empty for empty css', () => {
expect(typestylesStyleHtml('')).toBe('');
});

it('typestylesStyleHtml wraps css in a style tag', () => {
expect(typestylesStyleHtml('.a{color:red}')).toBe(
'<style id="typestyles">.a{color:red}</style>',
);
});

it('injectStylesIntoHtml inserts before </head>', () => {
const html = '<html><head><title>x</title></head><body></body></html>';
expect(injectStylesIntoHtml(html, '.a{color:red}')).toBe(
'<html><head><title>x</title><style id="typestyles">.a{color:red}</style></head><body></body></html>',
);
});

it('injectStylesIntoHtml prepends when no head', () => {
expect(injectStylesIntoHtml('<div>hi</div>', '.a{color:red}')).toBe(
'<style id="typestyles">.a{color:red}</style><div>hi</div>',
);
});

it('streamingDocumentShell opens a document with css in head', () => {
expect(streamingDocumentShell('.a{color:red}')).toBe(
'<!DOCTYPE html><html><head><meta charset="utf-8"/><style id="typestyles">.a{color:red}</style></head><body>',
);
});
});
41 changes: 39 additions & 2 deletions packages/typestyles/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,45 @@
import './sheet-node';
import { startCollection, flushSync, getRegisteredCss, subscribeRegisteredCss } from './sheet';
import {
startCollection,
flushSync,
getRegisteredCss,
subscribeRegisteredCss,
TYPESTYLES_STYLE_ID,
} from './sheet';
import { runWithIsolatedSheet } from './sheet-context';

export { getRegisteredCss, subscribeRegisteredCss };
export { getRegisteredCss, subscribeRegisteredCss, TYPESTYLES_STYLE_ID };

/**
* Render a `<style id="typestyles">` tag for embedding in HTML.
* Returns an empty string when `css` is empty.
*/
export function typestylesStyleHtml(css: string): string {
if (!css) return '';
return `<style id="${TYPESTYLES_STYLE_ID}">${css}</style>`;
}

/**
* Insert collected CSS before `</head>` in a full or partial HTML document.
* When no `</head>` is present, prepends the style tag to the string.
*/
export function injectStylesIntoHtml(html: string, css: string): string {
const tag = typestylesStyleHtml(css);
if (!tag) return html;
if (html.includes('</head>')) {
return html.replace('</head>', `${tag}</head>`);
}
return `${tag}${html}`;
}

/**
* Open an HTML document shell for `renderToPipeableStream`: doctype, `<head>` with
* charset meta and collected CSS, and `<body>`. Pair with `collectStyles()` for the
* CSS pass, then close `</body></html>` when the stream finishes.
*/
export function streamingDocumentShell(css: string): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8"/>${typestylesStyleHtml(css)}</head><body>`;
}

export type CollectStylesResult<T> = { html: T; css: string };

Expand Down
5 changes: 4 additions & 1 deletion packages/typestyles/src/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {
} from './registry';
import { getSheetState, getGlobalSheetState, resetSheetState } from './sheet-context';

const STYLE_ELEMENT_ID = 'typestyles';
/** Stable id for the managed `<style>` element (SSR, hydration, and client runtime). */
export const TYPESTYLES_STYLE_ID = 'typestyles';

const STYLE_ELEMENT_ID = TYPESTYLES_STYLE_ID;

function duplicateRuleKeyConflictWarningsEnabled(): boolean {
if (typeof process === 'undefined') return true;
Expand Down
Loading