- `,
- styles: `
- h3 {
- color: blue;
- }
- `,
-})
-export default class HelloComponent implements OnInit {
- Date = Date;
- props = injectStaticProps();
- outputs = injectStaticOutputs<{ loaded: boolean }>();
- json = computed(() => JSON.stringify(this.props));
-
- ngOnInit() {
- this.outputs.set({ loaded: true });
- }
-}
diff --git a/apps/docs-app/docs/features/server/streaming-ssr.md b/apps/docs-app/docs/features/server/streaming-ssr.md
new file mode 100644
index 000000000..c608da584
--- /dev/null
+++ b/apps/docs-app/docs/features/server/streaming-ssr.md
@@ -0,0 +1,122 @@
+# Streaming SSR
+
+Analog supports progressive streaming server-side rendering, flushing the
+response to the browser as the app renders instead of buffering the whole
+document until it is complete.
+
+The document head is sent immediately, each `@defer (hydrate …)` block is sent
+the moment it resolves on the server, and the authoritative document arrives
+last — so a slow block never holds back the rest of the page.
+
+:::info Experimental
+
+Streaming SSR is experimental and opt-in. It requires **Angular 21 or later**
+and builds on [incremental hydration](https://angular.dev/guide/incremental-hydration).
+The default buffered [Server Side Rendering](/docs/features/server/server-side-rendering)
+path is unchanged.
+
+:::
+
+## Enabling streaming
+
+Enable the `experimental.streaming` option in your Vite config:
+
+```ts
+// vite.config.ts
+import analog from '@analogjs/platform';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [analog({ experimental: { streaming: true } })],
+});
+```
+
+Then use `renderStream` instead of `render` in `main.server.ts`:
+
+```ts
+// src/main.server.ts
+import { renderStream } from '@analogjs/router/server';
+import { config } from './app/app.config.server';
+import { AppComponent } from './app/app.component';
+
+export default renderStream(AppComponent, config);
+```
+
+Streaming builds on incremental hydration, so enable it in your client
+providers. On Angular 21 use `withIncrementalHydration()`; on Angular 22+ it is
+enabled by default with `provideClientHydration()`:
+
+```ts
+// src/app/app.config.ts
+import {
+ provideClientHydration,
+ withIncrementalHydration,
+} from '@angular/platform-browser';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideClientHydration(withIncrementalHydration()),
+ // ...
+ ],
+};
+```
+
+## Streaming deferred blocks
+
+Content that should stream progressively goes in a `@defer` block with a
+`hydrate` trigger. Each block is rendered eagerly on the server, streamed as it
+resolves, and hydrated on the client when its trigger fires:
+
+```markup
+
+}
+```
+
+A block backed by asynchronous data (for example an
+[`httpResource`](https://angular.dev/guide/http/http-resource)) keeps the render
+pending until its data resolves, so the block streams with its final content and
+the page's time-to-first-byte is unaffected.
+
+## Title and meta
+
+Because the document head is flushed before the app renders, a title or meta set
+during render (via the `Title`/`Meta` services or route metadata) is applied to
+the streamed document once the render completes, before hydration runs. Search
+engine crawlers are served a buffered render with a fully resolved head instead
+of the streamed shell.
+
+## Opting a route out of streaming
+
+Disable streaming for specific routes with a `streaming: false` route rule, the
+same way `ssr: false` disables SSR. Matching routes fall back to a buffered
+render (no streaming, but SSR and hydration still work):
+
+```ts
+// vite.config.ts
+import analog from '@analogjs/platform';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [
+ analog({
+ experimental: { streaming: true },
+ nitro: {
+ routeRules: {
+ '/report': { streaming: false },
+ },
+ },
+ }),
+ ],
+});
+```
diff --git a/apps/docs-app/sidebars.js b/apps/docs-app/sidebars.js
index 7e3dd76d8..3aac9b7fb 100644
--- a/apps/docs-app/sidebars.js
+++ b/apps/docs-app/sidebars.js
@@ -118,6 +118,12 @@ const sidebars = {
label: 'Overview',
key: 'server-side-rendering-overview',
},
+ {
+ type: 'doc',
+ id: 'features/server/streaming-ssr',
+ label: 'Streaming SSR',
+ key: 'streaming-ssr',
+ },
],
},
{
diff --git a/apps/streaming-app/index.html b/apps/streaming-app/index.html
new file mode 100644
index 000000000..d33a5eb84
--- /dev/null
+++ b/apps/streaming-app/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ Analog Streaming SSR
+
+
+
+
+
+
+
+
diff --git a/apps/streaming-app/project.json b/apps/streaming-app/project.json
new file mode 100644
index 000000000..550872270
--- /dev/null
+++ b/apps/streaming-app/project.json
@@ -0,0 +1,51 @@
+{
+ "name": "streaming-app",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "application",
+ "sourceRoot": "apps/streaming-app/src",
+ "prefix": "analogjs",
+ "tags": [],
+ "implicitDependencies": ["platform", "router"],
+ "targets": {
+ "build": {
+ "executor": "@nx/vite:build",
+ "outputs": [
+ "{options.outputPath}",
+ "{workspaceRoot}/dist/apps/streaming-app/.nitro",
+ "{workspaceRoot}/dist/apps/streaming-app/ssr",
+ "{workspaceRoot}/dist/apps/streaming-app/analog"
+ ],
+ "options": {
+ "configFile": "apps/streaming-app/vite.config.ts",
+ "outputPath": "dist/apps/streaming-app/client"
+ },
+ "defaultConfiguration": "production",
+ "configurations": {
+ "development": {
+ "mode": "development"
+ },
+ "production": {
+ "sourcemap": false,
+ "mode": "production"
+ }
+ }
+ },
+ "serve": {
+ "executor": "@nx/vite:dev-server",
+ "defaultConfiguration": "development",
+ "options": {
+ "buildTarget": "streaming-app:build",
+ "port": 3200
+ },
+ "configurations": {
+ "development": {
+ "buildTarget": "streaming-app:build:development",
+ "hmr": true
+ },
+ "production": {
+ "buildTarget": "streaming-app:build:production"
+ }
+ }
+ }
+ }
+}
diff --git a/apps/streaming-app/src/app/app.component.ts b/apps/streaming-app/src/app/app.component.ts
new file mode 100644
index 000000000..ee74d2a94
--- /dev/null
+++ b/apps/streaming-app/src/app/app.component.ts
@@ -0,0 +1,10 @@
+import { Component } from '@angular/core';
+import { RouterOutlet } from '@angular/router';
+
+@Component({
+ selector: 'streaming-root',
+ standalone: true,
+ imports: [RouterOutlet],
+ template: ``,
+})
+export class AppComponent {}
diff --git a/apps/streaming-app/src/app/app.config.server.ts b/apps/streaming-app/src/app/app.config.server.ts
new file mode 100644
index 000000000..0da63b09b
--- /dev/null
+++ b/apps/streaming-app/src/app/app.config.server.ts
@@ -0,0 +1,10 @@
+import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
+import { provideServerRendering } from '@angular/platform-server';
+
+import { appConfig } from './app.config';
+
+const serverConfig: ApplicationConfig = {
+ providers: [provideServerRendering()],
+};
+
+export const config = mergeApplicationConfig(appConfig, serverConfig);
diff --git a/apps/streaming-app/src/app/app.config.ts b/apps/streaming-app/src/app/app.config.ts
new file mode 100644
index 000000000..b64ac083d
--- /dev/null
+++ b/apps/streaming-app/src/app/app.config.ts
@@ -0,0 +1,22 @@
+import { provideFileRouter, requestContextInterceptor } from '@analogjs/router';
+import {
+ provideHttpClient,
+ withFetch,
+ withInterceptors,
+} from '@angular/common/http';
+import { ApplicationConfig } from '@angular/core';
+import {
+ provideClientHydration,
+ withIncrementalHydration,
+} from '@angular/platform-browser';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideHttpClient(
+ withFetch(),
+ withInterceptors([requestContextInterceptor]),
+ ),
+ provideClientHydration(withIncrementalHydration()),
+ provideFileRouter(),
+ ],
+};
diff --git a/apps/streaming-app/src/app/blocks/fast-a.component.ts b/apps/streaming-app/src/app/blocks/fast-a.component.ts
new file mode 100644
index 000000000..c826d1589
--- /dev/null
+++ b/apps/streaming-app/src/app/blocks/fast-a.component.ts
@@ -0,0 +1,17 @@
+import { Component, signal } from '@angular/core';
+
+@Component({
+ selector: 'app-fast-a',
+ standalone: true,
+ template: `
+ Block A — deferred (fast, no async) + incrementally hydrated. count =
+ {{ count() }}
+
+ `,
+})
+export class FastA {
+ count = signal(41);
+ inc() {
+ this.count.set(this.count() + 1);
+ }
+}
diff --git a/apps/streaming-app/src/app/blocks/shell-status.component.ts b/apps/streaming-app/src/app/blocks/shell-status.component.ts
new file mode 100644
index 000000000..bd263206e
--- /dev/null
+++ b/apps/streaming-app/src/app/blocks/shell-status.component.ts
@@ -0,0 +1,20 @@
+import { Component, signal } from '@angular/core';
+
+// Eager, non-@defer component: it renders as part of the app body (so it lands
+// in the authoritative tail, not a deferred preview) and — unlike the @defer
+// blocks — hydrates immediately on bootstrap rather than via an incremental
+// hydration trigger. Covers the ordinary, non-deferred hydration path.
+@Component({
+ selector: 'app-shell-status',
+ standalone: true,
+ template: `
+ Eager component — rendered in the shell, hydrated immediately. n = {{ n() }}
+
+
`,
+})
+export class ShellStatus {
+ n = signal(7);
+ inc() {
+ this.n.set(this.n() + 1);
+ }
+}
diff --git a/apps/streaming-app/src/app/blocks/slow-b.component.ts b/apps/streaming-app/src/app/blocks/slow-b.component.ts
new file mode 100644
index 000000000..1e1ddb4fa
--- /dev/null
+++ b/apps/streaming-app/src/app/blocks/slow-b.component.ts
@@ -0,0 +1,25 @@
+import { Component, linkedSignal } from '@angular/core';
+import { httpResource } from '@angular/common/http';
+
+@Component({
+ selector: 'app-slow-b',
+ standalone: true,
+ template: ``,
+})
+export class SlowB {
+ // Real per-request data fetch against a slow API route. During SSR the render
+ // stays unstable until this resolves, so the authoritative document is the
+ // last thing flushed — the head and Block A have already streamed.
+ private data = httpResource<{ label: string }>(() => '/api/slow-b');
+
+ // Seed a writable signal from the resolved data; the click can still override
+ // it locally, and it re-seeds if the source ever changes.
+ label = linkedSignal(() => this.data.value()?.label ?? 'loading');
+
+ toggle() {
+ this.label.set(this.label() === 'summer' ? 'winter' : 'summer');
+ }
+}
diff --git a/apps/streaming-app/src/app/blocks/trigger-c.component.ts b/apps/streaming-app/src/app/blocks/trigger-c.component.ts
new file mode 100644
index 000000000..7e9105ad1
--- /dev/null
+++ b/apps/streaming-app/src/app/blocks/trigger-c.component.ts
@@ -0,0 +1,22 @@
+import { Component, signal } from '@angular/core';
+
+// Deferred block hydrated by a *non-immediate* trigger (`hydrate on
+// interaction`). It renders eagerly on the server like the others, but stays
+// dormant on the client until the first interaction — the click both triggers
+// hydration and is replayed to the handler. Proves the streaming reconcile
+// (the swap to the authoritative body) preserves the dehydrated DOM, jsaction
+// markers, and event contract that triggered hydration depends on.
+@Component({
+ selector: 'app-trigger-c',
+ standalone: true,
+ template: `
+ Block C — deferred + hydrated on interaction. count = {{ count() }}
+
+ `,
+})
+export class TriggerC {
+ count = signal(100);
+ inc() {
+ this.count.set(this.count() + 1);
+ }
+}
diff --git a/apps/streaming-app/src/app/pages/buffered.page.ts b/apps/streaming-app/src/app/pages/buffered.page.ts
new file mode 100644
index 000000000..7b25c5633
--- /dev/null
+++ b/apps/streaming-app/src/app/pages/buffered.page.ts
@@ -0,0 +1,22 @@
+import { Component } from '@angular/core';
+
+import { FastA } from '../blocks/fast-a.component';
+
+// This route opts out of streaming via a `streaming: false` route rule (see
+// vite.config.ts). It still renders and hydrates like any incremental-hydration
+// page, but the response is a single buffered document — no streaming
+// scaffolding, no chunked transfer-encoding.
+@Component({
+ selector: 'buffered-page',
+ standalone: true,
+ imports: [FastA],
+ template: `
+
+ }
+ `,
+})
+export default class BufferedPage {}
diff --git a/apps/streaming-app/src/app/pages/index.page.ts b/apps/streaming-app/src/app/pages/index.page.ts
new file mode 100644
index 000000000..7ce7ed543
--- /dev/null
+++ b/apps/streaming-app/src/app/pages/index.page.ts
@@ -0,0 +1,58 @@
+import { Component, inject } from '@angular/core';
+import { Meta, Title } from '@angular/platform-browser';
+
+import { FastA } from '../blocks/fast-a.component';
+import { SlowB } from '../blocks/slow-b.component';
+import { ShellStatus } from '../blocks/shell-status.component';
+import { TriggerC } from '../blocks/trigger-c.component';
+
+@Component({
+ selector: 'streaming-home',
+ standalone: true,
+ imports: [FastA, SlowB, ShellStatus, TriggerC],
+ template: `
+
Analog Streaming SSR
+
+ The document head streams first, then each @defer block streams as it
+ resolves; this shell and the eager component below arrive with the
+ authoritative tail.
+
+ }
+ `,
+})
+export default class IndexPage {
+ // Set the head dynamically *during* render. The streaming shell head was
+ // already flushed by this point, so this exercises the finalize-time head
+ // reconcile (see __analogReconcileHead): the title/meta below must appear in
+ // the live document even though they were unknown when the head streamed.
+ constructor() {
+ inject(Title).setTitle('Streamed dynamically — Analog SSR');
+ inject(Meta).updateTag({
+ name: 'description',
+ content: 'Head set during render, reconciled after the stream.',
+ });
+ }
+}
diff --git a/apps/streaming-app/src/main.server.ts b/apps/streaming-app/src/main.server.ts
new file mode 100644
index 000000000..bfa9aaf30
--- /dev/null
+++ b/apps/streaming-app/src/main.server.ts
@@ -0,0 +1,8 @@
+import 'zone.js/node';
+import '@angular/platform-server/init';
+import { renderStream } from '@analogjs/router/server';
+
+import { config } from './app/app.config.server';
+import { AppComponent } from './app/app.component';
+
+export default renderStream(AppComponent, config);
diff --git a/apps/streaming-app/src/main.ts b/apps/streaming-app/src/main.ts
new file mode 100644
index 000000000..c4f73244a
--- /dev/null
+++ b/apps/streaming-app/src/main.ts
@@ -0,0 +1,9 @@
+import 'zone.js';
+import { bootstrapApplication } from '@angular/platform-browser';
+
+import { AppComponent } from './app/app.component';
+import { appConfig } from './app/app.config';
+
+bootstrapApplication(AppComponent, appConfig).catch((err) =>
+ console.error(err),
+);
diff --git a/apps/streaming-app/src/server/routes/api/slow-b.get.ts b/apps/streaming-app/src/server/routes/api/slow-b.get.ts
new file mode 100644
index 000000000..f92a2f0ed
--- /dev/null
+++ b/apps/streaming-app/src/server/routes/api/slow-b.get.ts
@@ -0,0 +1,10 @@
+import { eventHandler } from 'h3';
+
+// Simulate a slow data source (~600ms) so Block B's `httpResource` keeps the
+// app unstable until it resolves. This is a genuine per-request delay: the head
+// and Block A stream immediately while the authoritative, data-filled document
+// is flushed only once this resolves.
+export default eventHandler(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 600));
+ return { label: 'summer' };
+});
diff --git a/apps/streaming-app/tools/cwv-bench.mjs b/apps/streaming-app/tools/cwv-bench.mjs
new file mode 100644
index 000000000..7b4a1a5a0
--- /dev/null
+++ b/apps/streaming-app/tools/cwv-bench.mjs
@@ -0,0 +1,151 @@
+/**
+ * Core Web Vitals benchmark: streamed vs buffered render of the SAME page.
+ *
+ * Usage:
+ * npx nx build streaming-app --configuration=production
+ * PORT=3210 HOST=127.0.0.1 node dist/apps/streaming-app/analog/server/index.mjs &
+ * node apps/streaming-app/tools/cwv-bench.mjs # BASE/ITERS overridable via env
+ *
+ * The same route (`/`) is measured both ways, decided by User-Agent: a browser
+ * UA streams, a Googlebot UA takes the buffered fallback. Page, bundle, and the
+ * ~600ms httpResource dependency are identical, so only the render strategy
+ * differs. Throttled to Lighthouse mobile-ish conditions (Slow-4G + 4x CPU) so
+ * the TTFB/FCP deltas are meaningful.
+ *
+ * Playwright is used rather than Lighthouse on purpose: Lighthouse's own
+ * user-agent matches `SSR_BOT_RE`, so it would only ever measure the buffered
+ * path. Requires the repo's `playwright` dev dependency + `npx playwright install`.
+ */
+import pw from 'playwright';
+const { chromium } = pw;
+
+const BASE = process.env.BASE || 'http://127.0.0.1:3210';
+const URL = `${BASE}/`;
+const ITERS = Number(process.env.ITERS || 9);
+
+const UA_BROWSER =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
+const UA_BOT =
+ 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
+
+// Lighthouse "Slow 4G": 150ms RTT, ~1.6 Mbps down / 0.75 up, 4x CPU.
+const NET = {
+ offline: false,
+ latency: 150,
+ downloadThroughput: (1.6 * 1024 * 1024) / 8,
+ uploadThroughput: (0.75 * 1024 * 1024) / 8,
+};
+const CPU_RATE = 4;
+
+const OBSERVERS = `
+window.__cwv = { lcp: 0, lcpEl: '', cls: 0, fcp: 0, events: [] };
+new PerformanceObserver((l) => {
+ for (const e of l.getEntries()) {
+ window.__cwv.lcp = e.startTime;
+ const el = e.element;
+ window.__cwv.lcpEl = el ? (el.tagName.toLowerCase() + (el.className ? '.' + String(el.className).trim().split(/\\s+/)[0] : '')) : '';
+ }
+}).observe({ type: 'largest-contentful-paint', buffered: true });
+new PerformanceObserver((l) => {
+ for (const e of l.getEntries()) if (!e.hadRecentInput) window.__cwv.cls += e.value;
+}).observe({ type: 'layout-shift', buffered: true });
+new PerformanceObserver((l) => {
+ for (const e of l.getEntries()) if (e.name === 'first-contentful-paint') window.__cwv.fcp = e.startTime;
+}).observe({ type: 'paint', buffered: true });
+new PerformanceObserver((l) => {
+ for (const e of l.getEntries()) window.__cwv.events.push({ name: e.name, dur: e.duration });
+}).observe({ type: 'event', buffered: true, durationThreshold: 0 });
+`;
+
+function median(xs) {
+ const s = [...xs].sort((a, b) => a - b);
+ const m = Math.floor(s.length / 2);
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
+}
+
+async function runOnce(browser, ua) {
+ const context = await browser.newContext({
+ userAgent: ua,
+ viewport: { width: 390, height: 844 },
+ });
+ const page = await context.newPage();
+ const cdp = await context.newCDPSession(page);
+ await cdp.send('Network.emulateNetworkConditions', NET);
+ await cdp.send('Emulation.setCPUThrottlingRate', { rate: CPU_RATE });
+ await page.addInitScript(OBSERVERS);
+
+ await page.goto(URL, { waitUntil: 'load', timeout: 60000 });
+ // Let the ~600ms data + tail + hydration settle, and give LCP time to finalize.
+ await page.waitForTimeout(2500);
+
+ // INP proxy: click the interaction-hydrated block, take the slowest pointer/
+ // click event-timing entry it produces.
+ const before = await page.evaluate(() => window.__cwv.events.length);
+ const btn = page.locator('.btn-c');
+ if (await btn.count()) {
+ await btn.first().click();
+ await page.waitForTimeout(600);
+ }
+ const inp = await page.evaluate((n) => {
+ const evs = window.__cwv.events
+ .slice(n)
+ .filter((e) => ['pointerdown', 'pointerup', 'click'].includes(e.name));
+ return evs.length ? Math.max(...evs.map((e) => e.dur)) : null;
+ }, before);
+
+ const nav = await page.evaluate(() => {
+ const n = performance.getEntriesByType('navigation')[0] || {};
+ return { ttfb: n.responseStart || 0, load: n.loadEventEnd || 0 };
+ });
+ const cwv = await page.evaluate(() => window.__cwv);
+
+ await context.close();
+ return {
+ ttfb: nav.ttfb,
+ fcp: cwv.fcp,
+ lcp: cwv.lcp,
+ lcpEl: cwv.lcpEl,
+ cls: cwv.cls,
+ load: nav.load,
+ inp,
+ };
+}
+
+async function bench(browser, label, ua) {
+ const rows = [];
+ for (let i = 0; i < ITERS; i++) rows.push(await runOnce(browser, ua));
+ const pick = (k) =>
+ rows.map((r) => r[k]).filter((v) => v != null && !Number.isNaN(v));
+ return {
+ label,
+ ttfb: median(pick('ttfb')),
+ fcp: median(pick('fcp')),
+ lcp: median(pick('lcp')),
+ lcpEl:
+ rows
+ .map((r) => r.lcpEl)
+ .filter(Boolean)
+ .pop() || '(none)',
+ cls: median(pick('cls')),
+ load: median(pick('load')),
+ inp: pick('inp').length ? median(pick('inp')) : null,
+ };
+}
+
+const browser = await chromium.launch({ headless: true });
+const streamed = await bench(browser, 'streamed', UA_BROWSER);
+const buffered = await bench(browser, 'buffered', UA_BOT);
+await browser.close();
+
+const ms = (v) => (v == null ? ' n/a' : `${Math.round(v)}ms`.padStart(6));
+const fmt = (r) =>
+ `${r.label.padEnd(9)} TTFB=${ms(r.ttfb)} FCP=${ms(r.fcp)} LCP=${ms(r.lcp)} CLS=${r.cls
+ .toFixed(3)
+ .padStart(6)} load=${ms(r.load)} INP≈${ms(r.inp)} LCPel=${r.lcpEl}`;
+
+console.log(
+ `\n== CWV: streamed vs buffered, same page, Slow-4G + 4x CPU, median of ${ITERS} ==`,
+);
+console.log(fmt(streamed));
+console.log(fmt(buffered));
+console.log('\nJSON ' + JSON.stringify({ streamed, buffered }));
diff --git a/apps/streaming-app/tsconfig.app.json b/apps/streaming-app/tsconfig.app.json
new file mode 100644
index 000000000..ccb631681
--- /dev/null
+++ b/apps/streaming-app/tsconfig.app.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "types": []
+ },
+ "files": ["src/main.ts"],
+ "include": ["src/**/*.ts"]
+}
diff --git a/apps/streaming-app/tsconfig.editor.json b/apps/streaming-app/tsconfig.editor.json
new file mode 100644
index 000000000..b81b3b862
--- /dev/null
+++ b/apps/streaming-app/tsconfig.editor.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["**/*.ts"]
+}
diff --git a/apps/streaming-app/tsconfig.json b/apps/streaming-app/tsconfig.json
new file mode 100644
index 000000000..0a89050bd
--- /dev/null
+++ b/apps/streaming-app/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "types": ["vite/client"]
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.editor.json"
+ }
+ ]
+}
diff --git a/apps/streaming-app/vite.config.ts b/apps/streaming-app/vite.config.ts
new file mode 100644
index 000000000..46de8e3cf
--- /dev/null
+++ b/apps/streaming-app/vite.config.ts
@@ -0,0 +1,38 @@
+///
+
+import analog from '@analogjs/platform';
+import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
+import { defineConfig } from 'vite';
+
+export default defineConfig(() => {
+ return {
+ root: __dirname,
+ publicDir: 'src/assets',
+ optimizeDeps: {
+ include: ['@angular/common'],
+ },
+ build: {
+ outDir: '../../dist/apps/streaming-app/client',
+ reportCompressedSize: true,
+ target: ['es2020'],
+ },
+ plugins: [
+ analog({
+ experimental: {
+ streaming: true,
+ },
+ // Render at request time (no prerender) so streaming is exercised over HTTP.
+ prerender: {
+ routes: [],
+ },
+ // Opt a route out of streaming — it falls back to a buffered render.
+ nitro: {
+ routeRules: {
+ '/buffered': { streaming: false },
+ },
+ },
+ }),
+ nxViteTsPaths(),
+ ],
+ };
+});
diff --git a/packages/platform/src/lib/options.ts b/packages/platform/src/lib/options.ts
index 44d5c28b1..4753db9a5 100644
--- a/packages/platform/src/lib/options.ts
+++ b/packages/platform/src/lib/options.ts
@@ -12,10 +12,16 @@ import { ContentPluginOptions } from './content-plugin.js';
declare module 'nitropack' {
interface NitroRouteConfig {
ssr?: boolean;
+ /**
+ * Disable progressive streaming SSR for matching routes (falls back to a
+ * buffered render). Only meaningful when `experimental.streaming` is on.
+ */
+ streaming?: boolean;
}
interface NitroRouteRules {
ssr?: boolean;
+ streaming?: boolean;
}
}
@@ -144,6 +150,19 @@ export interface Options {
* the LOCALE injection token.
*/
i18n?: I18nOptions;
+ /**
+ * Opt-in experimental features that are not yet stable.
+ */
+ experimental?: {
+ /**
+ * Opt into progressive streaming SSR. When enabled, the SSR build patches
+ * `@angular/core` with a per-`@defer` block resolution hook so
+ * `@analogjs/router`'s `renderStream` can flush the document head first and
+ * each `@defer (hydrate …)` block as it resolves on the server. Has no
+ * effect unless the server entry uses `renderStream`.
+ */
+ streaming?: boolean;
+ };
}
export { PrerenderContentDir, PrerenderContentFile };
diff --git a/packages/platform/src/lib/platform-plugin.spec.ts b/packages/platform/src/lib/platform-plugin.spec.ts
index 0da585525..a925397b4 100644
--- a/packages/platform/src/lib/platform-plugin.spec.ts
+++ b/packages/platform/src/lib/platform-plugin.spec.ts
@@ -48,4 +48,24 @@ describe('platformPlugin', () => {
expect(viteNitroPluginSpy).toHaveBeenCalledWith({ ssr: false }, undefined);
expect(ssrBuildPluginSpy).not.toHaveBeenCalled();
});
+
+ it('emits the x-analog-no-streaming header for routes with streaming: false', async () => {
+ const { viteNitroPluginSpy, platformPlugin } = await setup();
+ platformPlugin({
+ nitro: {
+ routeRules: {
+ '/buffered': { streaming: false },
+ '/streamed': {},
+ },
+ },
+ });
+
+ const [, nitroOptions] = viteNitroPluginSpy.mock.calls[0];
+ expect(
+ nitroOptions.routeRules['/buffered'].headers['x-analog-no-streaming'],
+ ).toBe('true');
+ expect(
+ nitroOptions.routeRules['/streamed'].headers['x-analog-no-streaming'],
+ ).toBeUndefined();
+ });
});
diff --git a/packages/platform/src/lib/platform-plugin.ts b/packages/platform/src/lib/platform-plugin.ts
index 996f22aee..9edf88d64 100644
--- a/packages/platform/src/lib/platform-plugin.ts
+++ b/packages/platform/src/lib/platform-plugin.ts
@@ -1,4 +1,6 @@
import { Plugin } from 'vite';
+import { createRequire } from 'node:module';
+import { join } from 'node:path';
import viteNitroPlugin from '@analogjs/vite-plugin-nitro';
import angular from '@analogjs/vite-plugin-angular';
@@ -8,6 +10,11 @@ import {
ssrBuildPlugin,
i18nDefRegistryPlugin,
} from './ssr/ssr-build-plugin.js';
+import {
+ deferStreamingPlugin,
+ streamingSupportedOnAngular,
+ MIN_STREAMING_ANGULAR_MAJOR,
+} from './ssr/defer-streaming-plugin.js';
import { contentPlugin } from './content-plugin.js';
import { clearClientPageEndpointsPlugin } from './clear-client-page-endpoint.js';
import { ssrXhrBuildPlugin } from './ssr/ssr-xhr-plugin.js';
@@ -16,6 +23,25 @@ import { injectHTMLPlugin } from './ssr/inject-html-plugin.js';
import { serverModePlugin } from '../server-mode-plugin.js';
import { i18nExtractPlugin } from './i18n-extract-plugin.js';
+/**
+ * The installed `@angular/core` major version, resolved from the workspace root
+ * (where the app's Angular is installed). Returns `null` when it cannot be
+ * detected, in which case streaming is not blocked and the build-time
+ * anchor-drift detection is relied on instead.
+ */
+function getAngularCoreMajor(workspaceRoot: string): number | null {
+ try {
+ const req = createRequire(join(workspaceRoot, 'noop.js'));
+ const { version } = req('@angular/core/package.json') as {
+ version: string;
+ };
+ const major = Number.parseInt(version.split('.')[0], 10);
+ return Number.isNaN(major) ? null : major;
+ } catch {
+ return null;
+ }
+}
+
export function platformPlugin(opts: Options = {}): Plugin[] {
const isTest = process.env['NODE_ENV'] === 'test' || !!process.env['VITEST'];
const { ...platformOptions } = {
@@ -23,6 +49,28 @@ export function platformPlugin(opts: Options = {}): Plugin[] {
...opts,
};
+ // Gate experimental streaming SSR on an Angular version whose compiled
+ // @angular/core matches the patch anchors (see MIN_STREAMING_ANGULAR_MAJOR).
+ // Reflect the gated value back onto platformOptions so the nitro renderer
+ // selection and the deferStreamingPlugin registration below stay consistent —
+ // never select the streaming renderer without applying the patch.
+ if (platformOptions.ssr && platformOptions.experimental?.streaming) {
+ const major = getAngularCoreMajor(
+ platformOptions.workspaceRoot ?? process.cwd(),
+ );
+ if (!streamingSupportedOnAngular(major)) {
+ console.warn(
+ `[@analogjs/platform] experimental.streaming requires Angular ` +
+ `${MIN_STREAMING_ANGULAR_MAJOR}+ (detected v${major}); streaming is ` +
+ `disabled and rendering falls back to buffered SSR.`,
+ );
+ platformOptions.experimental = {
+ ...platformOptions.experimental,
+ streaming: false,
+ };
+ }
+ }
+
let nitroOptions = platformOptions?.nitro;
if (nitroOptions?.routeRules) {
@@ -38,6 +86,8 @@ export function platformPlugin(opts: Options = {}): Plugin[] {
...config[curr].headers,
'x-analog-no-ssr':
config[curr]?.ssr === false ? 'true' : undefined,
+ 'x-analog-no-streaming':
+ config[curr]?.streaming === false ? 'true' : undefined,
} as any,
},
};
@@ -50,6 +100,9 @@ export function platformPlugin(opts: Options = {}): Plugin[] {
return [
...viteNitroPlugin(platformOptions, nitroOptions),
...(platformOptions.ssr ? [ssrBuildPlugin(), ...injectHTMLPlugin()] : []),
+ ...(platformOptions.ssr && platformOptions.experimental?.streaming
+ ? [deferStreamingPlugin()]
+ : []),
...(!isTest ? depsPlugin(platformOptions) : []),
...routerPlugin(platformOptions),
...contentPlugin(platformOptions?.content, platformOptions),
diff --git a/packages/platform/src/lib/ssr/defer-streaming-plugin.spec.ts b/packages/platform/src/lib/ssr/defer-streaming-plugin.spec.ts
new file mode 100644
index 000000000..53231d932
--- /dev/null
+++ b/packages/platform/src/lib/ssr/defer-streaming-plugin.spec.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it } from 'vitest';
+import {
+ injectDeferStreamingHook,
+ inspectAngularCoreModule,
+ streamingSupportedOnAngular,
+ MIN_STREAMING_ANGULAR_MAJOR,
+} from './defer-streaming-plugin.js';
+
+describe('streamingSupportedOnAngular', () => {
+ it('supports the floor version and above', () => {
+ expect(streamingSupportedOnAngular(MIN_STREAMING_ANGULAR_MAJOR)).toBe(true);
+ expect(streamingSupportedOnAngular(MIN_STREAMING_ANGULAR_MAJOR + 1)).toBe(
+ true,
+ );
+ });
+
+ it('rejects versions below the floor (v20 inlines the profiler anchor)', () => {
+ expect(streamingSupportedOnAngular(MIN_STREAMING_ANGULAR_MAJOR - 1)).toBe(
+ false,
+ );
+ expect(streamingSupportedOnAngular(19)).toBe(false);
+ });
+
+ it('does not block when the version is undetectable', () => {
+ expect(streamingSupportedOnAngular(null)).toBe(true);
+ });
+});
+
+describe('injectDeferStreamingHook', () => {
+ // A minimal stand-in for the two anchors the real @angular/core defer module
+ // carries: the applyDeferBlockState function and its DeferBlockStateEnd
+ // profiler call, plus the collectNativeNodesInLContainer function.
+ const bundle = [
+ 'function applyDeferBlockState(newState, lDetails, lContainer, tNode, hostLView) {',
+ ' renderDeferBlockState(newState, tNode, lContainer);',
+ ' profiler(ProfilerEvent.DeferBlockStateEnd);',
+ '}',
+ 'function collectNativeNodesInLContainer(lContainer, result) {}',
+ ].join('\n');
+
+ it('injects the resolution hook before the DeferBlockStateEnd anchor', () => {
+ const out = injectDeferStreamingHook(bundle);
+ expect(out).not.toBeNull();
+ expect(out).toContain('globalThis.__analogSsrDeferCapture');
+ // capture must precede the anchor it is threaded in front of
+ const capture = out!.indexOf('globalThis.__analogSsrDeferCapture({');
+ const anchor = out!.indexOf('profiler(ProfilerEvent.DeferBlockStateEnd);');
+ expect(capture).toBeGreaterThan(-1);
+ expect(capture).toBeLessThan(anchor);
+ });
+
+ it('gates the capture on server mode and Complete state', () => {
+ const out = injectDeferStreamingHook(bundle)!;
+ expect(out).toContain('newState === DeferBlockState.Complete');
+ expect(out).toContain('ngServerMode');
+ });
+
+ it('wraps the whole guard in try/catch so drifted internals no-op', () => {
+ const out = injectDeferStreamingHook(bundle)!;
+ expect(out).toMatch(
+ /try\s*\{\s*if \(newState === DeferBlockState\.Complete/,
+ );
+ });
+
+ it('exposes collectNativeNodesInLContainer on __analogSsrInternals', () => {
+ const out = injectDeferStreamingHook(bundle)!;
+ expect(out).toContain(
+ 'globalThis.__analogSsrInternals = Object.assign(globalThis.__analogSsrInternals || {}, { collectNativeNodesInLContainer })',
+ );
+ });
+
+ it('is a no-op for unrelated modules', () => {
+ expect(injectDeferStreamingHook('export const x = 1;')).toBeNull();
+ expect(
+ injectDeferStreamingHook('function applyDeferBlockState() {}'),
+ ).toBeNull();
+ });
+
+ it('preserves the original bundle content', () => {
+ const out = injectDeferStreamingHook(bundle)!;
+ expect(out).toContain('function applyDeferBlockState(');
+ expect(out).toContain('profiler(ProfilerEvent.DeferBlockStateEnd);');
+ expect(out.length).toBeGreaterThan(bundle.length);
+ });
+});
+
+describe('inspectAngularCoreModule', () => {
+ const bundle = [
+ 'function applyDeferBlockState(newState, lDetails, lContainer, tNode, hostLView) {',
+ ' profiler(ProfilerEvent.DeferBlockStateEnd);',
+ '}',
+ 'function collectNativeNodesInLContainer(lContainer, result) {}',
+ ].join('\n');
+
+ it('reports patchable when all anchors are present', () => {
+ expect(inspectAngularCoreModule(bundle)).toEqual({ kind: 'patchable' });
+ });
+
+ it('reports not-target for unrelated core modules', () => {
+ expect(inspectAngularCoreModule('export const x = 1;')).toEqual({
+ kind: 'not-target',
+ });
+ });
+
+ it('reports drift when the defer module lost the profiler anchor', () => {
+ const drifted = bundle.replace(
+ 'profiler(ProfilerEvent.DeferBlockStateEnd);',
+ 'profiler(ProfilerEvent.DeferBlockRenamed);',
+ );
+ const info = inspectAngularCoreModule(drifted);
+ expect(info.kind).toBe('drifted');
+ expect(info.kind === 'drifted' && info.reason).toContain(
+ 'DeferBlockStateEnd',
+ );
+ });
+
+ it('reports drift when the subtree collector is gone', () => {
+ const drifted = bundle.replace(
+ 'function collectNativeNodesInLContainer(lContainer, result) {}',
+ '',
+ );
+ const info = inspectAngularCoreModule(drifted);
+ expect(info.kind).toBe('drifted');
+ expect(info.kind === 'drifted' && info.reason).toContain(
+ 'collectNativeNodesInLContainer',
+ );
+ });
+});
diff --git a/packages/platform/src/lib/ssr/defer-streaming-plugin.ts b/packages/platform/src/lib/ssr/defer-streaming-plugin.ts
new file mode 100644
index 000000000..3ccf6f291
--- /dev/null
+++ b/packages/platform/src/lib/ssr/defer-streaming-plugin.ts
@@ -0,0 +1,153 @@
+import type { Plugin } from 'vite';
+
+/**
+ * Minimum Angular major whose compiled `@angular/core` FESM matches the
+ * streaming patch anchors. Incremental hydration is a stable public API from
+ * v20 (`withIncrementalHydration`, `@publicApi 20.0`), but v20's FESM inlines
+ * the injection anchor's `DeferBlockStateEnd` profiler event to its numeric
+ * ordinal, whereas v21+ keeps the symbolic `ProfilerEvent.DeferBlockStateEnd`
+ * form the anchor matches on. Keying on the literal ordinal would be fragile
+ * (enum values shift between versions), so v21 is the floor.
+ */
+export const MIN_STREAMING_ANGULAR_MAJOR = 21;
+
+/**
+ * Whether the streaming SSR patch can be applied to the installed Angular.
+ * A `null` major (version undetectable) returns `true`: don't block on a failed
+ * detection — the plugin's anchor-drift detection catches a real mismatch at
+ * build time and falls back to buffered rendering.
+ */
+export function streamingSupportedOnAngular(major: number | null): boolean {
+ return major === null || major >= MIN_STREAMING_ANGULAR_MAJOR;
+}
+
+/**
+ * Pure transform that injects the streaming-SSR per-block resolution hook into
+ * `@angular/core`'s compiled output. It is the same class of transform as
+ * `i18nDefRegistryPlugin` in ./ssr-build-plugin — a string patch of Angular's
+ * bundle applied only to SSR builds. Exported separately so it can be unit
+ * tested against a bundle string.
+ *
+ * Two edits, both keyed on single-occurrence anchors in the module that holds
+ * the defer runtime:
+ * 1. Inside `applyDeferBlockState`, fire `globalThis.__analogSsrDeferCapture`
+ * when a `@defer` block reaches `Complete` on the server, passing the
+ * block's live `lContainer` — this is the per-block resolution signal a
+ * streaming renderer consumes.
+ * 2. Expose `collectNativeNodesInLContainer` via `globalThis.__analogSsrInternals`
+ * so the renderer can serialize a block's subtree.
+ *
+ * Returns `null` when the module is not the one carrying the defer runtime, so
+ * it is a no-op on every other Angular module.
+ */
+export function injectDeferStreamingHook(code: string): string | null {
+ const END_ANCHOR = 'profiler(ProfilerEvent.DeferBlockStateEnd);';
+ if (
+ !code.includes('function applyDeferBlockState(') ||
+ !code.includes(END_ANCHOR)
+ ) {
+ return null;
+ }
+
+ // Wrap the entire guard — not just the call — so a drifted Angular runtime
+ // that still matches the anchors but renamed these locals fails as a silent
+ // no-op instead of throwing a ReferenceError inside applyDeferBlockState.
+ const capture =
+ `try { if (newState === DeferBlockState.Complete && ` +
+ `typeof ngServerMode !== 'undefined' && ngServerMode && ` +
+ `typeof globalThis.__analogSsrDeferCapture === 'function') { ` +
+ `globalThis.__analogSsrDeferCapture({ ssrUniqueId: lDetails[SSR_UNIQUE_ID], lContainer, hostLView }); } } catch (e) {}\n `;
+
+ let out = code.replace(END_ANCHOR, capture + END_ANCHOR);
+
+ if (code.includes('function collectNativeNodesInLContainer(')) {
+ out +=
+ '\nglobalThis.__analogSsrInternals = Object.assign(globalThis.__analogSsrInternals || {}, { collectNativeNodesInLContainer });\n';
+ }
+
+ return out;
+}
+
+/**
+ * Classify an `@angular/core` module for the streaming patch. The patch anchors
+ * on internal symbol names, so when Angular changes those the transform would
+ * otherwise become a silent no-op and streaming would degrade to buffered with
+ * no signal. This distinguishes "not the target module" (skip quietly) from
+ * "this IS the `@defer` runtime module but the anchors drifted" (worth warning).
+ */
+export function inspectAngularCoreModule(
+ code: string,
+):
+ | { kind: 'not-target' }
+ | { kind: 'patchable' }
+ | { kind: 'drifted'; reason: string } {
+ if (!code.includes('function applyDeferBlockState(')) {
+ return { kind: 'not-target' };
+ }
+ const missing: string[] = [];
+ if (!code.includes('profiler(ProfilerEvent.DeferBlockStateEnd);')) {
+ missing.push('DeferBlockStateEnd profiler anchor');
+ }
+ if (!code.includes('function collectNativeNodesInLContainer(')) {
+ missing.push('collectNativeNodesInLContainer');
+ }
+ return missing.length === 0
+ ? { kind: 'patchable' }
+ : { kind: 'drifted', reason: `missing ${missing.join(', ')}` };
+}
+
+/**
+ * Vite plugin that applies {@link injectDeferStreamingHook} to `@angular/core`
+ * during SSR builds, so `@analogjs/router`'s `renderStream` can flush `@defer`
+ * blocks as they resolve on the server — without hand-patching node_modules.
+ *
+ * Only active for SSR. Mirrors the enforce/filter/ssr-gate shape of
+ * `i18nDefRegistryPlugin`. If the `@defer` runtime module is found but its
+ * anchors have drifted (Angular changed internals), or if it is never
+ * encountered at all, the plugin warns rather than silently producing a build
+ * that falls back to buffered rendering.
+ */
+export function deferStreamingPlugin(): Plugin {
+ let applied = false;
+ let warnedDrift = false;
+ return {
+ name: 'analogjs-defer-streaming',
+ enforce: 'post',
+ transform: {
+ filter: {
+ id: /\/@angular\/core\//,
+ },
+ handler(code, _id, options) {
+ if (!options?.ssr) return;
+ const info = inspectAngularCoreModule(code);
+ if (info.kind === 'not-target') return;
+ if (info.kind === 'drifted') {
+ if (!warnedDrift) {
+ warnedDrift = true;
+ this.warn(
+ `experimental streaming SSR: found @angular/core's @defer runtime ` +
+ `but could not apply the resolution hook (${info.reason}). The ` +
+ `installed Angular version likely changed internals the patch ` +
+ `depends on; streaming will fall back to buffered rendering.`,
+ );
+ }
+ return;
+ }
+ const out = injectDeferStreamingHook(code);
+ if (!out) return;
+ applied = true;
+ return { code: out };
+ },
+ },
+ buildEnd() {
+ if (!applied && !warnedDrift) {
+ this.warn(
+ `experimental streaming SSR is enabled but @angular/core's @defer ` +
+ `runtime module was never encountered during the SSR build, so the ` +
+ `resolution hook was not injected. Streaming will fall back to ` +
+ `buffered rendering.`,
+ );
+ }
+ },
+ };
+}
diff --git a/packages/router/RFC-streaming-ssr.md b/packages/router/RFC-streaming-ssr.md
new file mode 100644
index 000000000..e60ad1853
--- /dev/null
+++ b/packages/router/RFC-streaming-ssr.md
@@ -0,0 +1,416 @@
+# RFC: Progressive Streaming SSR (`renderStream`)
+
+**Status:** Prototype (feat/streaming-ssr branch)
+**Author:** Brandon Roberts
+**Date:** 2026-07-06
+**Packages:** @analogjs/router (`@analogjs/router/server`), @analogjs/platform, @analogjs/vite-plugin-nitro
+
+---
+
+## Summary
+
+A streaming SSR renderer for Analog that flushes bytes to the client **during**
+the render instead of after it. `renderStream(App, config)` is an alternative to
+`render(App, config)` in `main.server.ts` that returns a
+`ReadableStream`:
+
+1. the document head + a small client runtime flush immediately, before the app
+ finishes rendering;
+2. each `@defer (hydrate …)` block flushes the moment it resolves on the server
+ — out of document order — so a slow block never holds back an earlier one;
+3. once the app is stable, the authoritative, fully hydration-annotated document
+ flushes as the tail, and the client runtime swaps it in before Angular's
+ incremental hydration runs.
+
+The Angular capability it needs — a per-`@defer`-block resolution signal — is
+delivered as a small **additive** string patch to `@angular/core`, applied only
+to SSR builds via a Vite plugin (`deferStreamingPlugin`) and gated behind an
+opt-in `experimental.streaming` flag. No hand-patching of `node_modules`.
+
+## Motivation
+
+The standard `render()` path calls `renderApplication` from
+`@angular/platform-server`, which is **fully buffered**: it renders the entire
+app to a Domino DOM, waits for `ApplicationRef.whenStable()` (which waits for
+_every_ `@defer (hydrate …)` block to resolve), annotates the whole document for
+hydration, and only then serializes and returns a single string.
+
+Consequences:
+
+1. **Time-to-first-byte is bounded by the slowest `@defer` block.** Nothing —
+ not even ``/`` or linked CSS/JS — reaches the browser until the
+ entire render finishes. A page with one slow hydrate-on-defer block delays
+ the whole response by that block's latency.
+2. **No progressive paint.** With incremental hydration, `@defer (hydrate …)`
+ blocks _are_ rendered eagerly on the server, so their content already exists
+ in the DOM well before `whenStable` — but the buffered path holds all of it
+ until the last block resolves.
+
+Streaming decouples TTFB from render completion and lets each block paint as it
+resolves, which is the point of server rendering heavy/expensive subtrees behind
+`@defer`.
+
+## Design
+
+Four pieces. How they interact over the lifetime of one request:
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant B as Browser
+ participant R as "renderStream (server)"
+ participant A as "@angular/core (SSR, patched)"
+
+ B->>R: GET /
+ Note over R: crawlers (bot user-agent) take the buffered render() path with a fully-resolved head instead of streaming
+ Note over R: platformServer + bootstrapApplication drives the platform directly
+ R-->>B: chunk 1 — document head + reconcile runtime + empty stream region
+ Note over B: browser starts fetching CSS/JS at once — TTFB no longer waits on the slowest block
+
+ loop each @defer (hydrate) block, as it resolves — out of order
+ A->>R: __analogSsrDeferCapture(lContainer)
+ Note over R: serialize the block subtree one macrotask later, after change detection fills interpolations
+ R-->>B: data-analog-defer="sN" template + __analogPaint("sN")
+ Note over B: paints the block into the live region (progressive, resolution order)
+ end
+
+ Note over R: await whenStable() — all blocks resolved
+ Note over R: ɵrenderInternal → authoritative document (whole-document hydration annotation)
+ R-->>B: data-analog-head + data-analog-authoritative templates + __analogReconcileHead() + __analogFinalize()
+ Note over B: apply the app-set title/meta to the live head, then swap body to the authoritative DOM
+ Note over B: Angular incremental hydration boots against the finalized DOM
+```
+
+The head flushes before the app has rendered, each `@defer` block flushes the
+moment it resolves (out of order), and the authoritative document — the only
+thing hydration runs against — is the tail. The rest of this section covers each
+piece in turn.
+
+### 1. `renderStream` (`@analogjs/router/server`)
+
+Drives the platform directly (`platformServer` + `bootstrapApplication` +
+`ɵrenderInternal`) rather than calling `renderApplication`, so it can interleave
+flushes with rendering. The `start()` of the returned `ReadableStream`:
+
+- flushes `document` head + the reconcile runtime + an empty streaming region;
+- installs `globalThis.__analogSsrDeferCapture`; on each resolved block it serializes
+ the block's live Domino subtree (a macrotask later, once change detection has
+ filled interpolations) and flushes it as a `` + paint script;
+- after `whenStable()`, calls `ɵrenderInternal(platformRef, appRef)` to produce
+ the authoritative, hydration-annotated document (byte-identical to a buffered
+ render) and flushes both its `` (in a ``) and
+ its body as the tail;
+- falls back to a single buffered chunk for server-component requests, for
+ **crawlers** (bot user-agents, so they index a fully-resolved head — see
+ _Head & title_ below), or when the streaming primitive is absent, so output
+ matches the classic path.
+
+### 2. Client reconcile runtime (`defer-reconcile-runtime.ts`)
+
+A tiny inlined script exposing `window.__analogPaint(id)` (paints a streamed
+block into the live region as it arrives — progressive, out of order),
+`window.__analogReconcileHead()` (applies the authoritative ``'s
+title/meta/link to the live document — idempotent, so unchanged shell tags are
+left alone), and `window.__analogFinalize()` (swaps the whole body to the
+authoritative document before hydration boots, so the reconciled DOM matches a
+buffered render byte-for-byte).
+
+### 3. `deferStreamingPlugin` — the additive Angular seam (`@analogjs/platform`)
+
+A Vite plugin (same shape and ssr-gate as the existing `i18nDefRegistryPlugin`)
+that string-patches `@angular/core` during SSR builds. It is **purely additive**
+— two edits keyed on single-occurrence anchors:
+
+1. inside `applyDeferBlockState`, fire `globalThis.__analogSsrDeferCapture` when a
+ block reaches `Complete` on the server, passing its live `lContainer`;
+2. expose `collectNativeNodesInLContainer` on `globalThis.__analogSsrInternals` so
+ the renderer can serialize a block's subtree.
+
+The transform is exported as a pure function (`injectDeferStreamingHook`) and
+unit-tested against a bundle string. It is registered only when
+`ssr && experimental.streaming`, so default builds are untouched.
+
+**Angular version floor.** Streaming is gated on **Angular ≥ 21**. Incremental
+hydration is a stable public API from v20 (`withIncrementalHydration`,
+`@publicApi 20.0`), but v20's compiled FESM inlines the `DeferBlockStateEnd`
+profiler event to its numeric ordinal, while v21+ keeps the symbolic
+`ProfilerEvent.DeferBlockStateEnd` the anchor matches on — verified by checking
+the published `@angular/core` FESM for v20/v21/v22. The gate lives in
+`@analogjs/platform`: below the floor it warns and disables streaming (falling
+back to buffered), and the value is reflected onto the options so the nitro
+renderer selection and this plugin never disagree.
+
+### 4. `ssrStreamRenderer` (`@analogjs/vite-plugin-nitro`)
+
+An h3 event handler that returns the `ReadableStream` with chunked transfer
+encoding, preserving the `x-analog-no-ssr` bypass. A `streaming: false` route
+rule sets an `x-analog-no-streaming` response header (mirroring how `ssr: false`
+becomes `x-analog-no-ssr`); `renderStream` then emits the buffered `render()`
+output and the handler returns it as a normal, non-chunked document — so a route
+can opt out of streaming while keeping SSR. The buffered `ssrRenderer` is
+unchanged.
+
+### Runtime & concurrency
+
+The per-`@defer` resolution signal is a process-global entry point (the patched
+`@angular/core` can only call one `globalThis` function), so `renderStream`
+installs a **stable dispatcher once** and routes each resolved block to the
+render that owns it using `AsyncLocalStorage` (`node:async_hooks`). Concurrent
+renders in one process are therefore isolated — a block resolving in render A is
+never enqueued into render B's stream.
+
+Runtime portability is delegated to **Nitro**: Analog does not shim runtimes.
+`node:async_hooks` is provided across Nitro's deployment presets (native Node,
+and non-Node targets via `nodejs_compat` / unenv), so `renderStream` depends on
+it directly rather than carrying its own edge fallbacks.
+
+### Resilience to Angular drift
+
+Because the patch anchors on internal symbol names, `deferStreamingPlugin`
+classifies each `@angular/core` module: it warns if it finds the `@defer`
+runtime but the anchors have drifted (Angular changed internals), and at
+`buildEnd` if that module was never encountered — instead of silently producing
+a build that falls back to buffered. `renderStream` likewise warns in dev when
+the streaming primitive is absent at request time.
+
+### Honest boundary
+
+Angular's hydration annotation is **whole-document** — the root component's `ngh`
+index references every `@defer` container — so the authoritative hydration
+payload can only be finalized once all blocks have resolved. Therefore
+**rendering streams progressively, but hydration begins when the tail arrives.**
+The blocks are effectively sent twice (progressive preview during render +
+authoritative copy in the tail); this is the byte cost measured below, and the
+target of the "Future work" section.
+
+### Head & title
+
+Because the shell `` is flushed **before** the app renders, a title or
+meta set _during_ render (`Title`/`Meta` services, route meta) is not yet known
+when the head goes out. Two mechanisms keep this correct, mirroring how Nuxt
+handles the identical problem in its streaming renderer:
+
+1. **Finalize-time reconcile (interactive clients).** The authoritative ``
+ is streamed in the tail (``) and
+ `__analogReconcileHead()` applies its title/meta/link to the live document
+ before hydration boots. This is the same "inject the resolved head late"
+ strategy Nuxt uses — Nuxt flushes a head _shell_ then streams
+ `renderSSRHeadSuspenseChunk` ``,
+ );
+ resolve();
+ }, 0);
+ }),
+ );
+ };
+
+ // Run the whole render inside the async-local context so every
+ // change-detection tick and @defer resolution it schedules routes back
+ // to THIS render's handler.
+ await captureStore.run(onBlockResolved, async () => {
+ const platformRef = platformServer([
+ { provide: INITIAL_CONFIG, useValue: { document, url } },
+ provideServerContext(serverContext),
+ platformProviders,
+ ]);
+
+ // 1. Flush the head + reconcile runtime immediately (before the app is
+ // rendered), then open the live streaming region.
+ enqueue(
+ document.slice(0, afterBodyOpen(document)) +
+ `` +
+ ``,
+ );
+
+ let appRef: ApplicationRef | undefined;
+ let errored = false;
+ try {
+ // 2. Bootstrap + render. Blocks resolve out of order during this
+ // phase and flush via the capture handler above.
+ appRef = await bootstrap({ platformRef } as BootstrapContext);
+ await appRef.whenStable();
+ await Promise.all(pendingFlushes);
+ // Stop capturing before serializing the tail so late resolutions
+ // triggered by the hydration pass are not streamed as extra blocks.
+ capturing = false;
+
+ // 3. Flush the authoritative, fully hydration-annotated document as
+ // the tail. Carried in s (their inert `ng-state`
+ // script survives). The app's resolved ships alongside so
+ // a dynamically-set title/meta — set during render, after the
+ // shell head was already flushed — is reconciled onto the live
+ // document before the runtime swaps in the body and hydration
+ // boots.
+ const authoritative = await renderInternal(platformRef, appRef);
+ enqueue(
+ `${headInner(authoritative)}` +
+ `${bodyInner(authoritative)}` +
+ `` +
+ ``,
+ );
+ } catch (err) {
+ // The head + runtime were already flushed, so the status/headers are
+ // committed; error the stream (a no-op silent close would hand the
+ // client a truncated, non-hydratable 200) and log with block context.
+ errored = true;
+ console.error(
+ `[@analogjs/router] renderStream failed for ${url} after ` +
+ `${blockIndex} block(s); response truncated.`,
+ err,
+ );
+ controller.error(err);
+ } finally {
+ await asyncDestroyPlatform(platformRef);
+ if (!errored) controller.close();
+ }
+ });
+ },
+ });
+ };
+}
diff --git a/packages/router/server/src/render.ts b/packages/router/server/src/render.ts
index c3d60025d..f49b4d1e0 100644
--- a/packages/router/server/src/render.ts
+++ b/packages/router/server/src/render.ts
@@ -12,36 +12,12 @@ import { renderApplication } from '@angular/platform-server';
import type { ServerContext } from '@analogjs/router/tokens';
import { provideServerContext } from './provide-server-context';
-import {
- serverComponentRequest,
- renderServerComponent,
-} from './server-component-render';
+import { resetComponentDefTViews } from './utils/reset-component-def-tviews';
if (import.meta.env.PROD) {
enableProdMode();
}
-/**
- * Nulls `def.tView` on every component definition that Angular has
- * compiled in this process. Angular caches the result of `consts()` on
- * `def.tView` — that factory is where `$localize` tagged templates are
- * evaluated — so without this reset the first rendered locale would be
- * frozen into the cache for the process lifetime.
- *
- * The set on `globalThis.__ngComponentDefs` is populated by a Vite
- * transform in `@analogjs/platform` that patches `@angular/core`'s
- * `getComponentId()` to mirror every compiled component definition to
- * a global Set, bypassing the `ngServerMode` guard that normally
- * prevents registration on the server.
- */
-function resetComponentDefTViews(): void {
- const defs = (globalThis as any).__ngComponentDefs as Set | undefined;
- if (!defs) return;
- for (const def of defs) {
- def.tView = null;
- }
-}
-
/**
* Returns a function that accepts the navigation URL,
* the root HTML, and server context.
@@ -65,10 +41,6 @@ export function render(
document: string,
serverContext: ServerContext,
) {
- if (serverComponentRequest(serverContext)) {
- return await renderServerComponent(url, serverContext);
- }
-
resetComponentDefTViews();
const html = await renderApplication(bootstrap, {
diff --git a/packages/router/server/src/server-component-render.ts b/packages/router/server/src/server-component-render.ts
deleted file mode 100644
index 36a59d0c3..000000000
--- a/packages/router/server/src/server-component-render.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import { ApplicationConfig, Type } from '@angular/core';
-import {
- bootstrapApplication,
- type BootstrapContext,
-} from '@angular/platform-browser';
-import {
- reflectComponentType,
- ɵConsole as Console,
- APP_ID,
-} from '@angular/core';
-import {
- provideServerRendering,
- renderApplication,
- ɵSERVER_CONTEXT as SERVER_CONTEXT,
-} from '@angular/platform-server';
-import { ServerContext } from '@analogjs/router/tokens';
-import { createEvent, readBody, getHeader } from 'h3';
-
-import { provideStaticProps } from './tokens';
-
-type ComponentLoader = () => Promise>;
-
-export function serverComponentRequest(serverContext: ServerContext) {
- const serverComponentId = getHeader(
- createEvent(serverContext.req, serverContext.res),
- 'X-Analog-Component',
- );
-
- if (
- !serverComponentId &&
- serverContext.req.url &&
- serverContext.req.url.startsWith('/_analog/components')
- ) {
- const componentId = serverContext.req.url.split('/')?.[3];
-
- return componentId;
- }
-
- return serverComponentId;
-}
-
-const components = import.meta.glob([
- '/src/server/components/**/*.{ts,analog,ag}',
-]);
-
-export async function renderServerComponent(
- url: string,
- serverContext: ServerContext,
- config?: ApplicationConfig,
-) {
- const componentReqId = serverComponentRequest(serverContext) as string;
- const { componentLoader, componentId } = getComponentLoader(componentReqId);
-
- if (!componentLoader) {
- return new Response(`Server Component Not Found ${componentId}`, {
- status: 404,
- });
- }
-
- const component = ((await componentLoader()) as any)[
- 'default'
- ] as Type;
-
- if (!component) {
- return new Response(`No default export for ${componentId}`, {
- status: 422,
- });
- }
-
- const mirror = reflectComponentType(component);
- const selector = mirror?.selector.split(',')?.[0] || 'server-component';
- const event = createEvent(serverContext.req, serverContext.res);
- const body = (await readBody(event)) || {};
- const appId = `analog-server-${selector.toLowerCase()}-${new Date().getTime()}`;
-
- const bootstrap = (context?: BootstrapContext) =>
- bootstrapApplication(
- component,
- {
- providers: [
- provideServerRendering(),
- provideStaticProps(body),
- { provide: SERVER_CONTEXT, useValue: 'analog-server-component' },
- {
- provide: APP_ID,
- useFactory() {
- return appId;
- },
- },
- ...(config?.providers || []),
- ],
- },
- context,
- );
-
- const html = await renderApplication(bootstrap, {
- url,
- document: `<${selector}>${selector}>`,
- platformProviders: [
- {
- provide: Console,
- useFactory() {
- return {
- warn: () => {},
- log: () => {},
- };
- },
- },
- ],
- });
-
- const outputs = retrieveTransferredState(html, appId);
- const responseData: { html: string; outputs: Record } = {
- html,
- outputs,
- };
-
- return new Response(JSON.stringify(responseData), {
- headers: {
- 'X-Analog-Component': 'true',
- },
- });
-}
-
-function getComponentLoader(componentReqId: string): {
- componentLoader: ComponentLoader | undefined;
- componentId: string;
-} {
- let _componentId = `/src/server/components/${componentReqId.toLowerCase()}`;
- let componentLoader: ComponentLoader | undefined = undefined;
- let componentId = _componentId;
-
- if (components[`${_componentId}.ts`]) {
- componentId = `${_componentId}.ts`;
- componentLoader = components[componentId] as ComponentLoader;
- }
-
- return { componentLoader, componentId };
-}
-
-function retrieveTransferredState(
- html: string,
- appId: string,
-): Record {
- const regex = new RegExp(
- `