diff --git a/.changeset/p2-15-per-route-critical-css.md b/.changeset/p2-15-per-route-critical-css.md
new file mode 100644
index 0000000..f52866b
--- /dev/null
+++ b/.changeset/p2-15-per-route-critical-css.md
@@ -0,0 +1,6 @@
+---
+'@typestyles/build-runner': minor
+'@typestyles/next': minor
+---
+
+Per-route critical CSS for Next.js App Router (P2.15): `buildTypestylesForNext` emits route-scoped stylesheets and manifest v2 with a `routes` map; `getRouteCss` reads them at request time instead of the full `getRegisteredCss()` buffer.
diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md
index d17f3af..3c9dabc 100644
--- a/IMPROVEMENTS.md
+++ b/IMPROVEMENTS.md
@@ -120,7 +120,7 @@ Bugs and credibility issues that lose evaluations on contact. Do these first.
variants with JSX call-site rewrites.
- Parse `@media` (and other at-rules) in static templates into nested style objects.
-- [ ] **P2.15 — Per-route critical CSS** (PR: )
+- [x] **P2.15 — Per-route critical CSS** (PR: #100)
- `getRegisteredCss()` returns everything ever registered. Use the extraction
manifest in `@typestyles/next/build` to emit route-level CSS.
diff --git a/docs/content/docs/ssr.md b/docs/content/docs/ssr.md
index e394625..89a3d89 100644
--- a/docs/content/docs/ssr.md
+++ b/docs/content/docs/ssr.md
@@ -321,7 +321,41 @@ TypeStyles automatically deduplicates CSS during collection. If multiple compone
### Critical CSS
-All CSS is included by default. For large applications, you might want to implement critical CSS extraction (only including styles for above-the-fold content). This isn't built into TypeStyles—you'd need to implement it at the framework level.
+By default, `getRegisteredCss()` returns every rule registered in the process. For large apps, prefer **build-time per-route CSS** on Next.js: `buildTypestylesForNext` writes route-scoped stylesheets and a v2 manifest (see [zero-runtime — Next.js](/docs/zero-runtime#nextjs) and [per-route critical CSS](#per-route-critical-css-nextjs) below).
+
+### Per-route critical CSS (Next.js)
+
+`buildTypestylesForNext` traces each App Router `page` plus its layout chain, extracts CSS for modules that import `typestyles`, and writes self-contained files under `app/_typestyles/routes/` (default). The manifest maps route paths to those files:
+
+```json
+{
+ "version": 2,
+ "css": "app/typestyles.css",
+ "routes": {
+ "/": { "css": "app/_typestyles/routes/index.css" },
+ "/about": { "css": "app/_typestyles/routes/about.css" }
+ }
+}
+```
+
+At request time, read the pre-built CSS for the current route instead of the full global sheet:
+
+```tsx
+// app/about/layout.tsx (Server Component)
+import { getRouteCss } from '@typestyles/next/server';
+
+export default function AboutLayout({ children }: { children: React.ReactNode }) {
+ const css = getRouteCss('/about', { root: process.cwd() });
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
+```
+
+Keep your convention entry minimal (tokens, reset, fonts). Route-specific `styles.*` modules imported only from that page's tree stay out of other routes' CSS files. Pass `routeCss: false` to `buildTypestylesForNext` when you only want the monolithic `typestyles.css`.
### Client-side hydration
diff --git a/docs/content/docs/zero-runtime.md b/docs/content/docs/zero-runtime.md
index f9456ef..da45d8f 100644
--- a/docs/content/docs/zero-runtime.md
+++ b/docs/content/docs/zero-runtime.md
@@ -223,6 +223,8 @@ export default withTypestyles({
Run extraction before `next build` (CI or a `prebuild` script). **Defaults** match the Vite story: `buildTypestylesForNext({ root })` discovers a convention entry, then writes **`app/typestyles.css`** and **`app/typestyles.manifest.json`** (override with `cssOutFile`, `manifestOutFile`, or `modules` when you need a custom layout).
+When an **`app/`** directory exists, extraction also emits **per-route critical CSS** under **`app/_typestyles/routes/`** and upgrades the manifest to **version 2** with a `routes` map. Disable with `routeCss: false` or customize `routeCssOutDir`.
+
```ts
// scripts/typestyles-build.mts
import { buildTypestylesForNext } from '@typestyles/next/build';
@@ -230,7 +232,7 @@ import { buildTypestylesForNext } from '@typestyles/next/build';
await buildTypestylesForNext({ root: process.cwd() });
```
-Import the emitted CSS from your App Router layout (`import './typestyles.css'`).
+Import the emitted CSS from your App Router layout (`import './typestyles.css'`), or load route-scoped CSS at request time with **`getRouteCss`** (see [SSR — per-route CSS](/docs/ssr#per-route-critical-css-nextjs)).
For full manual control, **`withTypestylesExtract`** is still available (always merges production defines). **`discoverDefaultExtractModules`** and **`DEFAULT_EXTRACT_MODULE_CANDIDATES`** are re-exported from `@typestyles/next/build` (same as `@typestyles/build-runner` / `@typestyles/vite`).
@@ -258,7 +260,8 @@ Checks performed:
- CSS file exists and meets `minBytes` (defaults to non-empty when omitted)
- Optional `requiredCssSubstrings` — catch missing imports from the entry barrel
-- Optional manifest exists with `version: 1` and `css` pointing at your stylesheet path
+- Optional manifest exists with `version: 1` or `version: 2` and `css` pointing at your stylesheet path
+- For v2 manifests, optional `minRouteEntries` and per-route CSS file checks
Wire it into `package.json` before `next build` (see `@examples/next`):
diff --git a/examples/next-app/README.md b/examples/next-app/README.md
index 3947516..5c53d0a 100644
--- a/examples/next-app/README.md
+++ b/examples/next-app/README.md
@@ -33,6 +33,7 @@ pnpm build # typestyles:build → typestyles:verify → next build
| ----------------------------------- | ----------------------------------------------------------------- |
| Convention entry (no `src/` prefix) | `styles/typestyles-entry.ts` |
| Pre-build extraction | `scripts/typestyles-build.mts` → `buildTypestylesForNext` |
+| Per-route critical CSS | manifest v2 `routes` map + `app/_typestyles/routes/` |
| CI verification | `scripts/verify-typestyles.mts` → `verifyTypestylesBuild` |
| Static CSS in layout | `app/layout.tsx` imports `./typestyles.css` |
| Prod runtime disabled | `next.config.mjs` → `withTypestyles` |
@@ -49,7 +50,8 @@ styles/site.ts # app shell (scoped createTypeStyles)
scripts/typestyles-build.mts # extraction before next build
scripts/verify-typestyles.mts
app/typestyles.css # generated — do not hand-edit
-app/typestyles.manifest.json # generated manifest for verify step
+app/typestyles.manifest.json # generated manifest (v2 with routes map)
+app/_typestyles/routes/ # per-route critical CSS (when app/ exists)
app/layout.tsx # links extracted CSS
next.config.mjs # withTypestyles()
```
diff --git a/examples/next-app/scripts/verify-typestyles.mts b/examples/next-app/scripts/verify-typestyles.mts
index 14926e9..9f4dffc 100644
--- a/examples/next-app/scripts/verify-typestyles.mts
+++ b/examples/next-app/scripts/verify-typestyles.mts
@@ -10,6 +10,8 @@ try {
root,
cssFile: 'app/typestyles.css',
manifestFile: 'app/typestyles.manifest.json',
+ manifestVersion: 2,
+ minRouteEntries: 1,
minBytes: 500,
// Semantic class names are prefixed with the sanitized scopeId
// (`example-ds` / `example-app`), matching how tokens are scoped.
diff --git a/packages/build-runner/README.md b/packages/build-runner/README.md
index b23b09b..7ba4df2 100644
--- a/packages/build-runner/README.md
+++ b/packages/build-runner/README.md
@@ -138,6 +138,11 @@ Throws `VerifyTypestylesBuildError` with a `code` on failure.
| `manifestCssPath` | Expected `css` field in manifest |
| `minBytes` | Minimum file size (default: non-empty) |
| `requiredCssSubstrings` | Sanity-check strings that must appear in CSS |
+| `minRouteEntries` | For manifest v2: minimum route CSS entries |
+
+### Per-route CSS (Next.js)
+
+`collectAndWriteRouteCss`, `discoverNextAppRoutes`, `traceTypestylesModules`, and `getRouteCss` support App Router critical CSS. `@typestyles/next/build` wires these into `buildTypestylesForNext` by default when `app/` exists.
### `discoverDefaultExtractModules(root)`
diff --git a/packages/build-runner/src/index.ts b/packages/build-runner/src/index.ts
index c0df57a..e4798f5 100644
--- a/packages/build-runner/src/index.ts
+++ b/packages/build-runner/src/index.ts
@@ -18,6 +18,27 @@ export {
type VerifyTypestylesBuildOptions,
type VerifyTypestylesBuildResult,
} from './verify';
+export {
+ isManifestV2,
+ normalizeRoutePath,
+ type TypestylesExtractManifest,
+ type TypestylesExtractManifestV2,
+ type TypestylesRouteCssEntry,
+} from './manifest';
+export { discoverNextAppRoutes, pageRelPathToRoutePath, type NextAppRoute } from './next-routes';
+export { createModuleLoader, traceTypestylesModules } from './trace-imports';
+export {
+ buildManifestV2,
+ collectAndWriteRouteCss,
+ type CollectRouteCssOptions,
+ type CollectRouteCssResult,
+} from './route-css';
+export {
+ getRouteCss,
+ readCssFile,
+ readTypestylesManifest,
+ type GetRouteCssOptions,
+} from './route-css-read';
import { build as esbuildBuild } from 'esbuild';
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
diff --git a/packages/build-runner/src/manifest.ts b/packages/build-runner/src/manifest.ts
new file mode 100644
index 0000000..16c0961
--- /dev/null
+++ b/packages/build-runner/src/manifest.ts
@@ -0,0 +1,31 @@
+export interface TypestylesExtractManifestV1 {
+ version: 1;
+ css: string;
+}
+
+export interface TypestylesRouteCssEntry {
+ css: string;
+}
+
+export interface TypestylesExtractManifestV2 {
+ version: 2;
+ /** Full-app CSS (union of all registered styles). */
+ css: string;
+ /** Per-route critical CSS paths relative to project root. */
+ routes: Record;
+}
+
+export type TypestylesExtractManifest = TypestylesExtractManifestV1 | TypestylesExtractManifestV2;
+
+export function isManifestV2(
+ manifest: TypestylesExtractManifest,
+): manifest is TypestylesExtractManifestV2 {
+ return manifest.version === 2;
+}
+
+/** Normalize App Router paths for manifest lookup (`/about`, no trailing slash). */
+export function normalizeRoutePath(routePath: string): string {
+ if (!routePath || routePath === '/') return '/';
+ const trimmed = routePath.replace(/\/+$/, '');
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
+}
diff --git a/packages/build-runner/src/next-routes.test.ts b/packages/build-runner/src/next-routes.test.ts
new file mode 100644
index 0000000..397a8fe
--- /dev/null
+++ b/packages/build-runner/src/next-routes.test.ts
@@ -0,0 +1,55 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { describe, expect, it } from 'vitest';
+import { discoverNextAppRoutes, pageRelPathToRoutePath } from './next-routes';
+
+describe('pageRelPathToRoutePath', () => {
+ it('maps root page to /', () => {
+ expect(pageRelPathToRoutePath('page.tsx')).toBe('/');
+ });
+
+ it('maps nested pages and omits route groups', () => {
+ expect(pageRelPathToRoutePath('about/page.tsx')).toBe('/about');
+ expect(pageRelPathToRoutePath('(marketing)/pricing/page.tsx')).toBe('/pricing');
+ expect(pageRelPathToRoutePath('blog/[slug]/page.tsx')).toBe('/blog/[slug]');
+ });
+});
+
+describe('discoverNextAppRoutes', () => {
+ it('discovers pages and layout chains', () => {
+ const root = mkdtempSync(join(tmpdir(), 'typestyles-next-routes-'));
+ mkdirSync(join(root, 'app/about'), { recursive: true });
+ writeFileSync(
+ join(root, 'app/layout.tsx'),
+ 'export default function Layout() { return null; }',
+ );
+ writeFileSync(join(root, 'app/page.tsx'), 'export default function Home() { return null; }');
+ writeFileSync(
+ join(root, 'app/about/layout.tsx'),
+ 'export default function AboutLayout() { return null; }',
+ );
+ writeFileSync(
+ join(root, 'app/about/page.tsx'),
+ 'export default function About() { return null; }',
+ );
+
+ try {
+ const routes = discoverNextAppRoutes(root);
+ expect(routes).toEqual([
+ {
+ routePath: '/',
+ pageFile: 'app/page.tsx',
+ layoutFiles: ['app/layout.tsx'],
+ },
+ {
+ routePath: '/about',
+ pageFile: 'app/about/page.tsx',
+ layoutFiles: ['app/layout.tsx', 'app/about/layout.tsx'],
+ },
+ ]);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/build-runner/src/next-routes.ts b/packages/build-runner/src/next-routes.ts
new file mode 100644
index 0000000..e29d543
--- /dev/null
+++ b/packages/build-runner/src/next-routes.ts
@@ -0,0 +1,117 @@
+import { existsSync, readdirSync, statSync } from 'node:fs';
+import { dirname, join, relative, resolve } from 'node:path';
+
+const PAGE_BASENAMES = new Set(['page.tsx', 'page.ts', 'page.jsx', 'page.js']);
+const LAYOUT_BASENAMES = new Set(['layout.tsx', 'layout.ts', 'layout.jsx', 'layout.js']);
+
+export interface NextAppRoute {
+ /** App Router path, e.g. `/`, `/about`, `/blog/[slug]`. */
+ routePath: string;
+ /** Page file relative to project root with `/` separators. */
+ pageFile: string;
+ /** Layout files from root to route segment, relative to project root. */
+ layoutFiles: string[];
+}
+
+function normalizeRelPath(root: string, absPath: string): string {
+ return relative(root, absPath).replace(/\\/g, '/');
+}
+
+function isRouteGroupSegment(segment: string): boolean {
+ return segment.startsWith('(') && segment.endsWith(')');
+}
+
+/**
+ * Convert `app/about/page.tsx` (relative to `appDir`) to `/about`.
+ * Route groups `(marketing)` are omitted from the URL.
+ */
+export function pageRelPathToRoutePath(pageRelPath: string): string {
+ const dir = dirname(pageRelPath).replace(/\\/g, '/');
+ if (dir === '.' || dir === '') return '/';
+
+ const segments = dir
+ .split('/')
+ .filter((segment) => segment.length > 0 && !isRouteGroupSegment(segment));
+
+ return segments.length === 0 ? '/' : `/${segments.join('/')}`;
+}
+
+function walkForPages(appAbs: string, currentRel: string, pages: string[]): void {
+ const currentAbs = join(appAbs, currentRel);
+ let entries: string[];
+ try {
+ entries = readdirSync(currentAbs);
+ } catch {
+ return;
+ }
+
+ for (const entry of entries) {
+ const entryRel = currentRel ? join(currentRel, entry) : entry;
+ const entryAbs = join(appAbs, entryRel);
+ let stat;
+ try {
+ stat = statSync(entryAbs);
+ } catch {
+ continue;
+ }
+
+ if (stat.isDirectory()) {
+ walkForPages(appAbs, entryRel, pages);
+ continue;
+ }
+
+ if (PAGE_BASENAMES.has(entry)) {
+ pages.push(entryRel.replace(/\\/g, '/'));
+ }
+ }
+}
+
+function collectLayoutChain(appAbs: string, pageRelPath: string): string[] {
+ const layouts: string[] = [];
+ const segments = dirname(pageRelPath).replace(/\\/g, '/').split('/');
+ const chainDirs: string[] = [''];
+
+ for (const segment of segments) {
+ if (!segment || segment === '.') continue;
+ const parent = chainDirs[chainDirs.length - 1];
+ chainDirs.push(parent ? `${parent}/${segment}` : segment);
+ }
+
+ for (const dir of chainDirs) {
+ const dirAbs = dir ? join(appAbs, dir) : appAbs;
+ for (const layoutName of LAYOUT_BASENAMES) {
+ const layoutAbs = join(dirAbs, layoutName);
+ if (existsSync(layoutAbs)) {
+ layouts.push(join(dir, layoutName).replace(/\\/g, '/'));
+ break;
+ }
+ }
+ }
+
+ return layouts;
+}
+
+/**
+ * Discover App Router pages under `appDir` (default `app`).
+ */
+export function discoverNextAppRoutes(root: string, appDir = 'app'): NextAppRoute[] {
+ const appAbs = resolve(root, appDir);
+ if (!existsSync(appAbs)) return [];
+
+ const pageRelPaths: string[] = [];
+ walkForPages(appAbs, '', pageRelPaths);
+
+ return pageRelPaths
+ .map((pageRelPath) => {
+ const pageFile = normalizeRelPath(root, join(appAbs, pageRelPath));
+ const layoutFiles = collectLayoutChain(appAbs, pageRelPath).map((layoutRel) =>
+ normalizeRelPath(root, join(appAbs, layoutRel)),
+ );
+ return {
+ routePath: pageRelPathToRoutePath(pageRelPath),
+ pageFile,
+ layoutFiles,
+ };
+ })
+ .sort((a, b) => a.routePath.localeCompare(b.routePath));
+}
diff --git a/packages/build-runner/src/route-css-read.ts b/packages/build-runner/src/route-css-read.ts
new file mode 100644
index 0000000..2e6b6b2
--- /dev/null
+++ b/packages/build-runner/src/route-css-read.ts
@@ -0,0 +1,66 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { isManifestV2, normalizeRoutePath, type TypestylesExtractManifest } from './manifest';
+
+export function readTypestylesManifest(
+ root: string,
+ manifestFile = 'app/typestyles.manifest.json',
+): TypestylesExtractManifest {
+ const manifestPath = resolve(root, manifestFile);
+ if (!existsSync(manifestPath)) {
+ throw new Error(`[typestyles] Missing manifest at ${manifestPath}.`);
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
+ } catch {
+ throw new Error(`[typestyles] Invalid JSON in ${manifestPath}.`);
+ }
+
+ if (
+ typeof parsed !== 'object' ||
+ parsed === null ||
+ !('version' in parsed) ||
+ !('css' in parsed)
+ ) {
+ throw new Error(`[typestyles] Manifest at ${manifestPath} is missing required fields.`);
+ }
+
+ return parsed as TypestylesExtractManifest;
+}
+
+export function readCssFile(root: string, cssRelPath: string): string {
+ const cssPath = resolve(root, cssRelPath);
+ if (!existsSync(cssPath)) {
+ throw new Error(`[typestyles] Missing CSS file at ${cssPath}.`);
+ }
+ return readFileSync(cssPath, 'utf8');
+}
+
+export interface GetRouteCssOptions {
+ /** Project root used to resolve manifest and CSS paths. */
+ root: string;
+ /** Manifest path relative to `root`. Defaults to `app/typestyles.manifest.json`. */
+ manifestFile?: string;
+}
+
+/**
+ * Read pre-extracted CSS for an App Router route from the build manifest.
+ *
+ * When the manifest is v2 and includes a route entry, returns that route's CSS.
+ * Otherwise falls back to the full-app stylesheet path in the manifest.
+ */
+export function getRouteCss(routePath: string, options: GetRouteCssOptions): string {
+ const manifest = readTypestylesManifest(options.root, options.manifestFile);
+ const normalized = normalizeRoutePath(routePath);
+
+ if (isManifestV2(manifest)) {
+ const routeEntry = manifest.routes[normalized];
+ if (routeEntry) {
+ return readCssFile(options.root, routeEntry.css);
+ }
+ }
+
+ return readCssFile(options.root, manifest.css);
+}
diff --git a/packages/build-runner/src/route-css.test.ts b/packages/build-runner/src/route-css.test.ts
new file mode 100644
index 0000000..8747f32
--- /dev/null
+++ b/packages/build-runner/src/route-css.test.ts
@@ -0,0 +1,59 @@
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { describe, expect, it } from 'vitest';
+import { collectAndWriteRouteCss } from './route-css';
+import { getRouteCss } from './route-css-read';
+import { buildManifestV2 } from './route-css';
+
+describe('collectAndWriteRouteCss', () => {
+ it('writes per-route CSS files', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'typestyles-route-css-'));
+ mkdirSync(join(root, 'styles'), { recursive: true });
+ mkdirSync(join(root, 'app/about'), { recursive: true });
+
+ writeFileSync(join(root, 'styles/shared.ts'), `SHARED_MARKER\n`);
+ writeFileSync(join(root, 'app/layout.tsx'), `export default function L() { return null; }\n`);
+ writeFileSync(
+ join(root, 'app/page.tsx'),
+ `import '../styles/home';\nexport default function Home() { return null; }\n`,
+ );
+ writeFileSync(
+ join(root, 'styles/home.ts'),
+ `import { styles } from 'typestyles';\nstyles.class('home', { color: 'red' });\n`,
+ );
+ writeFileSync(
+ join(root, 'app/about/page.tsx'),
+ `import '../../styles/about';\nexport default function About() { return null; }\n`,
+ );
+ writeFileSync(
+ join(root, 'styles/about.ts'),
+ `import { styles } from 'typestyles';\nstyles.class('about', { color: 'blue' });\n`,
+ );
+
+ const collectCss = async (loaders: Array<() => unknown | Promise>) =>
+ `/* css for ${loaders.length} modules */`;
+
+ try {
+ const { routes } = await collectAndWriteRouteCss({
+ root,
+ sharedModules: ['styles/shared.ts'],
+ collectCss,
+ });
+
+ expect(Object.keys(routes)).toEqual(['/', '/about']);
+ expect(routes['/'].css).toBe('app/_typestyles/routes/index.css');
+ expect(routes['/about'].css).toBe('app/_typestyles/routes/about.css');
+ expect(readFileSync(join(root, routes['/'].css), 'utf8')).toContain('css for');
+
+ const manifest = buildManifestV2('app/typestyles.css', routes);
+ writeFileSync(join(root, 'app/typestyles.manifest.json'), JSON.stringify(manifest));
+ writeFileSync(join(root, 'app/typestyles.css'), '.global { }');
+
+ const homeCss = getRouteCss('/', { root });
+ expect(homeCss).toContain('css for');
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/build-runner/src/route-css.ts b/packages/build-runner/src/route-css.ts
new file mode 100644
index 0000000..bc73e7b
--- /dev/null
+++ b/packages/build-runner/src/route-css.ts
@@ -0,0 +1,88 @@
+import { mkdir, writeFile } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { discoverNextAppRoutes } from './next-routes';
+import { createModuleLoader, traceTypestylesModules } from './trace-imports';
+import type { TypestylesExtractManifestV2, TypestylesRouteCssEntry } from './manifest';
+
+export interface CollectRouteCssOptions {
+ root: string;
+ /** Convention entry modules (relative to root) included in every route bundle. */
+ sharedModules: string[];
+ /** App Router directory relative to root. Default `app`. */
+ appDir?: string;
+ /** Output directory for per-route CSS relative to root. */
+ routeCssOutDir?: string;
+ /**
+ * Collect CSS from traced modules.
+ * Injected by `@typestyles/next/build` so this package stays bundler-agnostic.
+ */
+ collectCss: (loaders: Array<() => unknown | Promise>) => Promise;
+}
+
+export interface CollectRouteCssResult {
+ routes: Record;
+}
+
+function routePathToCssFile(routePath: string): string {
+ if (routePath === '/') return 'index.css';
+ const slug = routePath
+ .slice(1)
+ .replace(/^\[|\]$/g, '')
+ .replace(/[^\w.-]+/g, '-');
+ return `${slug || 'route'}.css`;
+}
+
+function uniqueModules(...groups: string[][]): string[] {
+ return [...new Set(groups.flat())].sort();
+}
+
+/**
+ * Emit per-route CSS files and return manifest route entries.
+ */
+export async function collectAndWriteRouteCss(
+ options: CollectRouteCssOptions,
+): Promise {
+ const {
+ root,
+ sharedModules,
+ appDir = 'app',
+ routeCssOutDir = 'app/_typestyles/routes',
+ collectCss,
+ } = options;
+
+ const routes = discoverNextAppRoutes(root, appDir);
+ if (routes.length === 0) {
+ return { routes: {} };
+ }
+
+ const manifestRoutes: Record = {};
+
+ for (const route of routes) {
+ const entryFiles = uniqueModules(route.layoutFiles, [route.pageFile]);
+ const routeModules = await traceTypestylesModules(root, entryFiles);
+ const modules = uniqueModules(sharedModules, routeModules);
+ const loaders = modules.map((mod) => createModuleLoader(root, mod));
+ const css = await collectCss(loaders);
+
+ const cssFileName = routePathToCssFile(route.routePath);
+ const cssRel = `${routeCssOutDir.replace(/\\/g, '/')}/${cssFileName}`;
+ const cssAbs = resolve(root, cssRel);
+ await mkdir(dirname(cssAbs), { recursive: true });
+ await writeFile(cssAbs, css, 'utf8');
+
+ manifestRoutes[route.routePath] = { css: cssRel };
+ }
+
+ return { routes: manifestRoutes };
+}
+
+export function buildManifestV2(
+ cssOutFile: string,
+ routeEntries: Record,
+): TypestylesExtractManifestV2 {
+ return {
+ version: 2,
+ css: cssOutFile,
+ routes: routeEntries,
+ };
+}
diff --git a/packages/build-runner/src/trace-imports.test.ts b/packages/build-runner/src/trace-imports.test.ts
new file mode 100644
index 0000000..077deaa
--- /dev/null
+++ b/packages/build-runner/src/trace-imports.test.ts
@@ -0,0 +1,89 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { describe, expect, it } from 'vitest';
+import { traceTypestylesModules } from './trace-imports';
+
+describe('traceTypestylesModules', () => {
+ it('returns modules in the import graph that import typestyles', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'typestyles-trace-'));
+ mkdirSync(join(root, 'app'), { recursive: true });
+ mkdirSync(join(root, 'styles'), { recursive: true });
+ writeFileSync(
+ join(root, 'styles/tokens.ts'),
+ `import { tokens } from 'typestyles';\ntokens.create('trace-root', { color: { primary: '#000' } });\n`,
+ );
+ writeFileSync(
+ join(root, 'app/page.tsx'),
+ `import '../styles/tokens';\nexport default function Page() { return null; }\n`,
+ );
+ writeFileSync(
+ join(root, 'tsconfig.json'),
+ JSON.stringify({
+ compilerOptions: {
+ baseUrl: '.',
+ paths: { '@/*': ['./*'] },
+ },
+ }),
+ );
+
+ try {
+ const modules = await traceTypestylesModules(root, ['app/page.tsx']);
+ expect(modules).toContain('styles/tokens.ts');
+ expect(modules).not.toContain('app/page.tsx');
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ it('resolves tsconfig path aliases', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'typestyles-trace-alias-'));
+ mkdirSync(join(root, 'app'), { recursive: true });
+ mkdirSync(join(root, 'styles'), { recursive: true });
+ writeFileSync(
+ join(root, 'styles/site.ts'),
+ `import { styles } from 'typestyles';\nstyles.class('site-page', { padding: '1rem' });\n`,
+ );
+ writeFileSync(
+ join(root, 'app/page.tsx'),
+ `import '@/styles/site';\nexport default function Page() { return null; }\n`,
+ );
+ writeFileSync(
+ join(root, 'tsconfig.json'),
+ JSON.stringify({
+ compilerOptions: {
+ baseUrl: '.',
+ paths: { '@/*': ['./*'] },
+ },
+ }),
+ );
+
+ try {
+ const modules = await traceTypestylesModules(root, ['app/page.tsx']);
+ expect(modules).toContain('styles/site.ts');
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ it('externalizes layout CSS imports with site-root asset URLs', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'typestyles-trace-css-'));
+ mkdirSync(join(root, 'app'), { recursive: true });
+ writeFileSync(
+ join(root, 'app/typestyles.css'),
+ `@font-face { src: url('/fonts/space-grotesk-latin.woff2') format('woff2'); }\n`,
+ );
+ writeFileSync(
+ join(root, 'app/layout.tsx'),
+ `import './typestyles.css';\nexport default function Layout() { return null; }\n`,
+ );
+ writeFileSync(join(root, 'app/page.tsx'), `export default function Page() { return null; }\n`);
+
+ try {
+ const modules = await traceTypestylesModules(root, ['app/layout.tsx', 'app/page.tsx']);
+ expect(modules).toEqual([]);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/build-runner/src/trace-imports.ts b/packages/build-runner/src/trace-imports.ts
new file mode 100644
index 0000000..281d55a
--- /dev/null
+++ b/packages/build-runner/src/trace-imports.ts
@@ -0,0 +1,111 @@
+import { build as esbuildBuild } from 'esbuild';
+import { existsSync, readFileSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { TYPESTYLES_IMPORT_RE } from './namespaces';
+
+const EXTERNAL_PREFIXES = [
+ 'react',
+ 'react-dom',
+ 'react/',
+ 'react-dom/',
+ 'next',
+ 'next/',
+ 'server-only',
+ 'client-only',
+ 'typestyles',
+ 'typestyles/',
+];
+
+function isExternalBareImport(specifier: string): boolean {
+ if (specifier.startsWith('node:')) return true;
+ return EXTERNAL_PREFIXES.some(
+ (prefix) => specifier === prefix || specifier.startsWith(`${prefix}/`),
+ );
+}
+
+function isExternalAssetPath(path: string, importer: string, namespace: string): boolean {
+ if (path.endsWith('.css')) return true;
+ const fromCss = namespace === 'css' || importer.endsWith('.css');
+ if (fromCss && path.startsWith('/') && !path.startsWith('//')) {
+ return true;
+ }
+ return false;
+}
+
+function fileImportsTypestyles(absPath: string): boolean {
+ if (!existsSync(absPath)) return false;
+ try {
+ return TYPESTYLES_IMPORT_RE.test(readFileSync(absPath, 'utf8'));
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Trace local modules reachable from entry files and return those that import `typestyles`.
+ * Paths are relative to `root` with `/` separators.
+ */
+export async function traceTypestylesModules(
+ root: string,
+ entryFiles: string[],
+): Promise {
+ const absEntries = entryFiles.map((file) => resolve(root, file));
+ const missing = absEntries.filter((file) => !existsSync(file));
+ if (missing.length > 0) {
+ throw new Error(
+ `[typestyles] Cannot trace imports; missing entry file(s): ${missing.join(', ')}`,
+ );
+ }
+
+ const tsconfigPath = join(root, 'tsconfig.json');
+ const result = await esbuildBuild({
+ entryPoints: absEntries,
+ bundle: true,
+ write: false,
+ outdir: root,
+ metafile: true,
+ platform: 'neutral',
+ format: 'esm',
+ logLevel: 'silent',
+ absWorkingDir: root,
+ ...(existsSync(tsconfigPath) ? { tsconfig: tsconfigPath } : {}),
+ plugins: [
+ {
+ name: 'typestyles-trace-imports',
+ setup(build) {
+ build.onResolve({ filter: /.*/ }, (args) => {
+ if (isExternalAssetPath(args.path, args.importer, args.namespace)) {
+ return { path: args.path, external: true };
+ }
+ if (args.path.startsWith('.')) {
+ return null;
+ }
+ if (isExternalBareImport(args.path)) {
+ return { path: args.path, external: true };
+ }
+ return null;
+ });
+ },
+ },
+ ],
+ });
+
+ const inputs = result.metafile?.inputs ?? {};
+ const traced = new Set();
+
+ for (const inputPath of Object.keys(inputs)) {
+ const absPath = resolve(root, inputPath);
+ if (!fileImportsTypestyles(absPath)) continue;
+ traced.add(inputPath.replace(/\\/g, '/'));
+ }
+
+ return [...traced].sort();
+}
+
+/** Load a traced module for style extraction. */
+export function createModuleLoader(root: string, modulePath: string): () => Promise {
+ const abs = resolve(root, modulePath);
+ const href = pathToFileURL(abs).href;
+ return () => import(href);
+}
diff --git a/packages/build-runner/src/verify.test.ts b/packages/build-runner/src/verify.test.ts
index e4c9265..a444c23 100644
--- a/packages/build-runner/src/verify.test.ts
+++ b/packages/build-runner/src/verify.test.ts
@@ -113,4 +113,39 @@ describe('verifyTypestylesBuild', () => {
rmSync(root, { recursive: true, force: true });
}
});
+
+ it('validates v2 route CSS files', () => {
+ const root = mkdtempSync(join(tmpdir(), 'typestyles-verify-v2-'));
+ mkdirSync(join(root, 'app/_typestyles/routes'), { recursive: true });
+ const { cssFile } = writeFixture(root, '.button { color: red; }');
+ const manifestFile = 'app/typestyles.manifest.json';
+ writeFileSync(join(root, 'app/_typestyles/routes/index.css'), '.home { color: red; }');
+ writeFileSync(
+ join(root, 'app/typestyles.manifest.json'),
+ `${JSON.stringify(
+ {
+ version: 2,
+ css: cssFile,
+ routes: {
+ '/': { css: 'app/_typestyles/routes/index.css' },
+ },
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+
+ try {
+ const result = verifyTypestylesBuild({
+ root,
+ cssFile,
+ manifestFile,
+ manifestVersion: 2,
+ minRouteEntries: 1,
+ });
+ expect(result.manifestVersion).toBe(2);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
});
diff --git a/packages/build-runner/src/verify.ts b/packages/build-runner/src/verify.ts
index 7833091..2d31e38 100644
--- a/packages/build-runner/src/verify.ts
+++ b/packages/build-runner/src/verify.ts
@@ -1,11 +1,12 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
+import {
+ isManifestV2,
+ type TypestylesExtractManifest,
+ type TypestylesExtractManifestV1,
+} from './manifest';
-/** Manifest shape written by {@link buildTypestylesForNext} and verified here. */
-export interface TypestylesExtractManifestV1 {
- version: 1;
- css: string;
-}
+export type { TypestylesExtractManifestV1 };
export interface VerifyTypestylesBuildOptions {
/** Project root used to resolve `cssFile` and `manifestFile`. */
@@ -25,6 +26,11 @@ export interface VerifyTypestylesBuildOptions {
minBytes?: number;
/** Substrings that must appear in the emitted CSS (app-specific sanity checks). */
requiredCssSubstrings?: readonly string[];
+ /**
+ * When the manifest is v2, require at least this many route entries.
+ * Ignored for v1 manifests.
+ */
+ minRouteEntries?: number;
}
export interface VerifyTypestylesBuildResult {
@@ -48,7 +54,7 @@ function fail(message: string, code: string): never {
throw new VerifyTypestylesBuildError(message, code);
}
-function readManifest(path: string): TypestylesExtractManifestV1 {
+function readManifest(path: string): TypestylesExtractManifest {
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(path, 'utf8'));
@@ -65,7 +71,17 @@ function readManifest(path: string): TypestylesExtractManifestV1 {
fail(`Manifest at ${path} is missing required fields.`, 'manifest-invalid-shape');
}
- return parsed as TypestylesExtractManifestV1;
+ const version = (parsed as { version?: unknown }).version;
+ if (version !== 1 && version !== 2) {
+ fail(`Unsupported manifest version ${String(version)}.`, 'manifest-version-unsupported');
+ }
+
+ const manifest = parsed as TypestylesExtractManifest;
+ if (isManifestV2(manifest) && typeof manifest.routes !== 'object') {
+ fail(`Manifest v2 at ${path} is missing routes.`, 'manifest-invalid-shape');
+ }
+
+ return manifest;
}
/**
@@ -86,6 +102,7 @@ export function verifyTypestylesBuild(
manifestVersion = 1,
minBytes,
requiredCssSubstrings = [],
+ minRouteEntries,
} = options;
const cssPath = resolve(root, cssFile);
@@ -135,6 +152,26 @@ export function verifyTypestylesBuild(
);
}
+ if (isManifestV2(manifest)) {
+ const routeCount = Object.keys(manifest.routes).length;
+ if (minRouteEntries !== undefined && routeCount < minRouteEntries) {
+ fail(
+ `Expected at least ${minRouteEntries} route CSS entries, got ${routeCount}.`,
+ 'manifest-routes-too-few',
+ );
+ }
+
+ for (const [routePath, entry] of Object.entries(manifest.routes)) {
+ const routeCssPath = resolve(root, entry.css);
+ if (!existsSync(routeCssPath)) {
+ fail(`Missing route CSS for ${routePath} at ${routeCssPath}.`, 'route-css-missing');
+ }
+ if (Buffer.byteLength(readFileSync(routeCssPath, 'utf8'), 'utf8') < 1) {
+ fail(`Route CSS for ${routePath} is empty.`, 'route-css-empty');
+ }
+ }
+ }
+
return {
cssPath,
cssBytes,
diff --git a/packages/next/README.md b/packages/next/README.md
index fbfd9e9..52f6d72 100644
--- a/packages/next/README.md
+++ b/packages/next/README.md
@@ -144,7 +144,7 @@ const css = await getTypestylesMetadata();
To ship a static stylesheet and avoid client-side `