Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
719595e
feat(router): add renderToString() with string-based SSR renderer
brandonroberts Apr 7, 2026
26b05dd
fix(router): add location and children to DOM shim, add benchmarks
brandonroberts Apr 7, 2026
a01f460
refactor(router): extract resetComponentDefTViews to shared util
brandonroberts Apr 30, 2026
c3a4c64
fix(router): scope renderToString providers to app injector
brandonroberts Apr 30, 2026
07dcd71
fix(router): correct text escaping and serialization in SSR string re…
brandonroberts Apr 30, 2026
ac0dd94
test(router): add specs for SSR dom-shim and string renderer
brandonroberts Apr 30, 2026
b51a30d
perf(router): prototype lazy-string element layout for SSR renderer
brandonroberts Apr 30, 2026
da3eb3d
perf(router): tighten V2 serialize and dedicate class/style slots
brandonroberts Apr 30, 2026
653a8b7
perf(router): prototype renderToStringFast with cached platform
brandonroberts Apr 30, 2026
051cfb3
test(router): document renderToStringFast parity gaps and add parity-…
brandonroberts Apr 30, 2026
f44b161
fix(router): wire hydration, server-context, and TransferState into r…
brandonroberts Apr 30, 2026
79afc99
fix(router): emit empty SSR attrs as `name=""` to match Domino
brandonroberts Apr 30, 2026
ffb6ed1
fix(router): lowercase HTML attribute names in SSR string renderer
brandonroberts Apr 30, 2026
75a4fd0
feat(router): wire view encapsulation into SSR string renderer
brandonroberts Apr 30, 2026
3428f91
refactor(router): consolidate SSR string renderers into single render…
brandonroberts Jul 6, 2026
b12a18e
fix(router): attach benchmark tree to renderer root so serialize benc…
brandonroberts Jul 6, 2026
a7c51b7
fix(router): expose DOM node surface on string renderer tokens for hy…
brandonroberts Jul 6, 2026
bd7d539
chore: make analog-app SSR scripts exit cleanly and surface render er…
brandonroberts Jul 6, 2026
e2c0264
docs: add RFC for the fast SSR string renderer
brandonroberts Jul 6, 2026
bb40ea9
refactor(router): remove unwired ServerSharedStylesHost
brandonroberts Jul 6, 2026
21cac4b
feat(router): add experimental renderStream progressive streaming SSR…
brandonroberts Jul 6, 2026
e24bf31
feat(vite-plugin-nitro): add ssrStreamRenderer h3 handler template
brandonroberts Jul 6, 2026
e565a5c
feat(platform): add deferStreamingPlugin to patch @angular/core for s…
brandonroberts Jul 6, 2026
2c4b15a
feat(platform): add experimental.streaming option to enable defer str…
brandonroberts Jul 6, 2026
5cdfdd0
docs(router): add RFC for progressive streaming SSR
brandonroberts Jul 6, 2026
9743fa0
fix(router): route renderStream @defer capture per-render for concurr…
brandonroberts Jul 6, 2026
2f3e860
feat(platform): warn when the defer streaming patch cannot be applied
brandonroberts Jul 6, 2026
9f04223
docs(router): note Nitro-delegated runtime portability + hardening in…
brandonroberts Jul 6, 2026
bb4062f
fix(router): dedupe @defer stream captures per render
brandonroberts Jul 6, 2026
bd0c8e2
feat(vite-plugin-nitro): select streaming renderer via experimental.s…
brandonroberts Jul 6, 2026
64ba79a
test: add end-to-end progressive streaming SSR app
brandonroberts Jul 6, 2026
44686ac
feat(router): reconcile dynamic head and buffer for crawlers in rende…
brandonroberts Jul 6, 2026
c7af0f7
docs(router): add streaming SSR sequence diagram and head/title handling
brandonroberts Jul 6, 2026
82c6b31
feat(platform): gate experimental streaming SSR on Angular v21+
brandonroberts Jul 6, 2026
c4dee14
docs(router): note the Angular v21+ floor for streaming SSR
brandonroberts Jul 6, 2026
0a43cba
refactor(router): remove the experimental fast SSR string renderer
brandonroberts Jul 6, 2026
46144dc
feat: disable streaming per route via a streaming:false route rule
brandonroberts Jul 6, 2026
96f2625
test: add a streaming-disabled route to the streaming-app
brandonroberts Jul 6, 2026
76c4226
docs(router): document the per-route streaming opt-out
brandonroberts Jul 6, 2026
b0aef81
docs: add Streaming SSR page
brandonroberts Jul 6, 2026
bfd3c1a
refactor(router): remove the experimental server components feature
brandonroberts Jul 6, 2026
965b763
docs: polish the Streaming SSR page
brandonroberts Jul 6, 2026
7e70a46
test(router): unit-test streaming helpers and the client reconcile ru…
brandonroberts Jul 6, 2026
51d053d
docs(router): reconcile the streaming SSR RFC validation section
brandonroberts Jul 6, 2026
2094382
test(router): add reproducible Core Web Vitals benchmark for streamin…
brandonroberts Jul 7, 2026
bb132b0
docs(router): record Core Web Vitals results and the early-shell foll…
brandonroberts Jul 7, 2026
7553e4e
fix(router): surface streaming render errors and reset tViews on ever…
brandonroberts Jul 7, 2026
5cb3993
fix(platform): harden the streaming patch guard and version detection
brandonroberts Jul 7, 2026
16dcaa7
fix(vite-plugin-nitro): stop forcing Transfer-Encoding on streamed re…
brandonroberts Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 0 additions & 30 deletions apps/analog-app/src/app/pages/client/(client).page.ts

This file was deleted.

11 changes: 0 additions & 11 deletions apps/analog-app/src/app/pages/server/(server).page.ts

This file was deleted.

34 changes: 0 additions & 34 deletions apps/analog-app/src/server/components/hello.ts

This file was deleted.

122 changes: 122 additions & 0 deletions apps/docs-app/docs/features/server/streaming-ssr.md
Original file line number Diff line number Diff line change
@@ -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
<h1>Dashboard</h1>

@defer (hydrate on immediate) {
<app-activity-feed />
} @placeholder {
<p>Loading activity…</p>
}

@defer (hydrate on viewport) {
<app-recommendations />
} @placeholder {
<p>Loading recommendations…</p>
}
```

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 },
},
},
}),
],
});
```
6 changes: 6 additions & 0 deletions apps/docs-app/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
},
{
Expand Down
13 changes: 13 additions & 0 deletions apps/streaming-app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Analog Streaming SSR</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<streaming-root></streaming-root>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
51 changes: 51 additions & 0 deletions apps/streaming-app/project.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
}
10 changes: 10 additions & 0 deletions apps/streaming-app/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
selector: 'streaming-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet></router-outlet>`,
})
export class AppComponent {}
10 changes: 10 additions & 0 deletions apps/streaming-app/src/app/app.config.server.ts
Original file line number Diff line number Diff line change
@@ -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);
22 changes: 22 additions & 0 deletions apps/streaming-app/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -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(),
],
};
17 changes: 17 additions & 0 deletions apps/streaming-app/src/app/blocks/fast-a.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Component, signal } from '@angular/core';

@Component({
selector: 'app-fast-a',
standalone: true,
template: `<section class="block-a">
Block A — deferred (fast, no async) + incrementally hydrated. count =
{{ count() }}
<button class="btn-a" (click)="inc()">A+</button>
</section>`,
})
export class FastA {
count = signal(41);
inc() {
this.count.set(this.count() + 1);
}
}
20 changes: 20 additions & 0 deletions apps/streaming-app/src/app/blocks/shell-status.component.ts
Original file line number Diff line number Diff line change
@@ -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: `<p class="block-eager">
Eager component — rendered in the shell, hydrated immediately. n = {{ n() }}
<button class="btn-eager" (click)="inc()">E+</button>
</p>`,
})
export class ShellStatus {
n = signal(7);
inc() {
this.n.set(this.n() + 1);
}
}
25 changes: 25 additions & 0 deletions apps/streaming-app/src/app/blocks/slow-b.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Component, linkedSignal } from '@angular/core';
import { httpResource } from '@angular/common/http';

@Component({
selector: 'app-slow-b',
standalone: true,
template: `<aside class="block-b">
Block B — deferred + incrementally hydrated. label = {{ label() }}
<button class="btn-b" (click)="toggle()">B!</button>
</aside>`,
})
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');
}
}
Loading
Loading