# Server-side rendering

> ngwr renders on the server. It is standalone, zoneless and signals-only, no component touches the DOM in a constructor, and every one of this site's own routes is prerendered under `outputMode: 'static'` with the build failing on a prerender error — so SSR is a gate here, not an aspiration. What this page answers is the part an app still owns: what hydration needs from your markup, which APIs cannot answer truthfully without a browser, and which two calls are NOT no-ops on the server.

Source: https://ngwr.dev/guides/ssr  
Kind: Core

## Hydration and event replay

Both are supported and neither needs anything from ngwr. The library contains no `ngSkipHydration` — not one component opts out — so `provideClientHydration()` reuses the prerendered DOM rather than throwing it away, and `withEventReplay()` replays the clicks that landed before the bundle booted.

```angular-ts
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';

bootstrapApplication(AppComponent, {
  providers: [
    provideZonelessChangeDetection(),
    provideClientHydration(withEventReplay()),
    provideWrOverlay(),
    provideWrTheme(),
  ],
});
```

Hydration's one requirement is that the server and the client agree on the DOM, and every hydration error in an ngwr app so far has come from the app's own markup rather than from a component. The two shapes below are the ones worth knowing before you turn it on.

## Write the `\<tbody>` yourself

Rows emitted straight into a `<table>` serialize exactly as written, and then the browser's HTML parser inserts an implicit `<tbody>` around them — so the live DOM no longer matches the server's. Hydration walks off the end of the table and throws: `NG0501` under `ng serve`, and a `nextSibling` TypeError in the optimized build, where it also costs the page its title.

```angular-html
<!-- WRONG under hydration: the HTML parser inserts an implicit <tbody>
     around these rows, so the live DOM stops matching what the server
     wrote and hydration walks off the end of the table (NG0501). -->
<table>
  @for (row of rows(); track row.id) {
    <tr>…</tr>
  }
</table>

<!-- RIGHT: write the section element yourself. -->
<table>
  <tbody>
    @for (row of rows(); track row.id) {
      <tr>…</tr>
    }
  </tbody>
</table>
```

This is not an ngwr rule — `<wr-table>` writes its own sections and is unaffected. It is here because it is the failure this repository actually hit, on a docs page, with a table of four rows.

## Reflow in CSS, not in a template branch

`WrMedia` has no viewport to measure on the server, so a template that branches on it prerenders the narrow arm every time and swaps the subtree out after hydration — a visible relayout, and a hydration mismatch for anything structural. The `responsive` modifiers reflow through a container query instead: one DOM on both sides, resolved by the browser after the markup has already matched.

```angular-html
<!-- Server-safe: one DOM in both places. The reflow is a CONTAINER QUERY in
     CSS, resolved by the browser after the markup has already matched. -->
<wr-table responsive [columns]="cols" [items]="rows" />
<wr-pagination responsive [(page)]="page" [total]="total()" />

<!-- Server-hostile: WrMedia answers false on the server, so the prerendered
     HTML is always the narrow branch and the wide one appears only after
     hydration has swapped the subtree out. -->
@if (media.matches('md')()) {
  <aside class="filters">…</aside>
}
```

## What the server can answer

Every browser-shaped API in the library has an explicit server branch, and this is what each one returns. Two of them do not have one, and that is the row to read twice.

| API | On the server | In the browser |
| --- | --- | --- |
| `WrMedia.matches(q)` | always false — there is no matchMedia to ask | the real match, and it keeps updating |
| `WrMedia.current()` | 'xs' — the walk from xxl down finds no match and falls through | the real breakpoint |
| `WrPlatform.isBrowser / isServer` | false / true | true / false |
| `WrPlatform.userAgent` | null | navigator.userAgent |
| `WrPlatform.prefersDark()` | false | the OS preference |
| `WrTheme.resolved()` | 'light' under the default config — no localStorage, no prefers-color-scheme | the persisted or preferred theme |
| `WrStorage.get(k, fallback)` | the fallback — the engine token resolves to an in-memory map per request | localStorage |
| `WrHaptics.supported` | false | whether navigator.vibrate exists |
| `WrTour.start(steps)` | no-op, by an explicit guard | starts the tour |
| `WrToast.show(…)` | NOT a no-op — it attaches a real overlay into the server DOM | shows the toast |
| `WrDialog.open(…)` | NOT a no-op — attaches, but skips role, aria-modal, the ✕ and the focus trap | opens a decorated, focus-trapped dialog |

## Overlays are not no-ops on the server

`WrTour.start()` returns early when there is no browser, and it is the only one of the three that does. `WrToast.show()` and `WrDialog.open()` attach a real overlay whichever platform they are called on — the toast markup is serialized into the response, and a dialog is attached without the decoration that makes it a dialog: no `role`, no `aria-modal`, no ✕, no focus trap, no focus to restore. On a cached or prerendered page that overlay ships to every visitor.

```angular-ts
import { afterNextRender, inject, Injector } from '@angular/core';
import { WrToast } from 'ngwr/toast';

private readonly toast = inject(WrToast);
private readonly injector = inject(Injector);

// Neither WrToast nor WrDialog guards the platform for you: called during a
// server render they attach an overlay that is serialized into the response
// and shipped to every visitor of a cached page. Open them from a
// browser-only hook.
constructor() {
  afterNextRender(() => this.toast.show({ message: 'Welcome back' }), { injector: this.injector });
}
```

The same rule covers anything opened from a lifecycle hook that runs on both platforms. `afterNextRender` is the narrowest guard — [WrPlatform](https://ngwr.dev/reference/services/platform)'s `isBrowser` is the general one.

## A virtualized table on the server

`<wr-table virtualScroll>` renders a fixed first window rather than the whole list, and the window is arithmetic you can predict: the server has no measured viewport and no scroll offset, so it starts at row 0 and renders `min(rows, ceil(viewportHeight / rowHeight) + 1 + overscan)` of them.

Both inputs fall back when they cannot be read: a `viewportHeight` given as a CSS string (rather than a number) counts as `480`, and an unset `rowHeight` counts as `40`, since the real row height is measured in the browser. `overscan` defaults to `6`. So `[viewportHeight]="400" [rowHeight]="40"` ships 17 body rows and a bottom spacer of `(rows − 17) × 40` pixels; the browser then re-measures and the spacer moves by whatever the real row height turned out to be. Pin `rowHeight` to the measured value if you need the two to agree exactly.

## The theme flash

A server has no `localStorage` and no `prefers-color-scheme`, so every prerendered page ships the same theme attribute — `light` under the default config — and a dark-mode visitor sees a white page until the bundle boots. No provider can close that window: by the time Angular runs, the browser has painted.

```angular-ts
import { wrThemePrePaintScript } from 'ngwr/theme';

// Emit into <head>, above every stylesheet.
const html = template.replace('<!--theme-->', `<script>${wrThemePrePaintScript()}</script>`);
```

`wrThemePrePaintScript()` emits the blocking reader for `<head>`, spelling the persisted mode exactly the way `WrTheme` and `WrStorage` agree to write it. The options, the full output and the CSP note live on the [WrTheme page](https://ngwr.dev/reference/services/theme) — this section exists so you find it from here.

## See also

- [WrTheme](https://ngwr.dev/reference/services/theme) — The pre-paint script that closes the light-theme flash, with its options.
- [WrMedia](https://ngwr.dev/reference/services/media) — Breakpoint signals — the API this page says answers `false` on the server.
- [WrPlatform](https://ngwr.dev/reference/services/platform) — `isBrowser` / `isServer` / `userAgent` — the guard the rest of this page relies on.
- [Mobile & responsive](https://ngwr.dev/guides/mobile) — The container-query modifiers that reflow without asking the viewport.
