Modern Angular
SSR, Hydration, and @defer Without Guesswork
Rendering strategy belongs in route config, measurements, and DOM constraints, not in a blanket SSR decision. Hydration shipped stable in Angular v17 (November 2023), @defer in v18 (May 2024), and incremental hydration in v20 (May 2025). The stack is production-ready; the design questions are what differ.
Make rendering a route decision
Start one level lower than "should the app use SSR?" Ask which routes need which rendering mode. A pricing page, a blog article, a logged-in dashboard, and a checkout flow do not have the same constraints.
Angular's server route configuration gives the team a place to record that decision in code. That matters because rendering strategy should survive code review, not disappear into meeting notes.
For prerendered parameterized routes, keep inject() calls before any await inside getPrerenderParams. That keeps the example aligned with Angular's synchronous injection-context rule.
| Route shape | Mode to test | Evidence required |
|---|---|---|
| Public marketing route | Prerender | SEO, stable content, low per-request personalization |
| Blog or docs route | Prerender with params | Known slugs at build time and acceptable build cost |
| Logged-in dashboard | Client rendering | Private data, heavy browser-only UI, SEO not relevant |
| Checkout or product detail | Server rendering | LCP and conversion improve without leaking user data |
| Unknown fallback | Server rendering or explicit 404 policy | Clear behavior for routes not generated at build time |
// app.routes.server.ts
import { inject } from '@angular/core';
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '',
renderMode: RenderMode.Prerender,
},
{
path: 'pricing',
renderMode: RenderMode.Prerender,
},
{
path: 'blog/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const posts = inject(PostCatalog);
const slugs = await posts.publicSlugs();
return slugs.map((slug) => ({ slug }));
},
},
{
path: 'app/**',
renderMode: RenderMode.Client,
},
{
path: 'checkout',
renderMode: RenderMode.Server,
},
{
path: '**',
renderMode: RenderMode.Server,
},
];Hydration is a contract, not a switch
Hydration only works when the server DOM and client DOM agree. Direct DOM manipulation, invalid HTML, CDN rewrites, and browser-only assumptions are not small details. They are the reasons a server-rendered page fails when the client tries to claim the existing HTML.
Enable hydration with event replay for public routes where early interaction matters, and make transfer cache policy explicit. User-specific endpoints should not accidentally become reusable initial HTML data.
In custom SSR setups, the hydration provider also belongs in the server bootstrap configuration. Otherwise the client and server are not agreeing on the same rendering contract.
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import {
provideClientHydration,
withEventReplay,
withHttpTransferCacheOptions,
} from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
withEventReplay(),
withHttpTransferCacheOptions({
filter: (req) => !req.url.includes('/api/profile'),
includeRequestsWithAuthHeaders: false,
})
),
],
};`@defer` should move real JavaScript out of the initial path
@defer is not a decorative loading state. It earns its place when a standalone dependency is expensive enough that delaying it improves the initial path or the interaction path.
Review the diff by asking three questions: which component leaves the initial bundle, what placeholder prevents layout shift, and what happens if the deferred code fails to load?
The placeholder, loading, and error blocks are eagerly loaded, so keep them cheap. And in SSR or SSG, a normal @defer block renders its placeholder on the server; if the main content should render on the server, that moves the conversation to incremental hydration.
<section
class="report-route"
aria-live="polite"
aria-atomic="true"
>
<app-report-summary [summary]="summary()" />
@defer (on viewport; prefetch on idle) {
<app-revenue-chart [series]="series()" />
} @placeholder (minimum 600ms) {
<app-chart-skeleton aria-label="Chart loading" />
} @loading (after 200ms; minimum 600ms) {
<app-chart-skeleton aria-label="Chart loading" />
} @error {
<app-inline-error message="Could not load the chart" />
}
</section>Incremental hydration is for islands with a reason
Incremental hydration keeps server-rendered content visible while delaying hydration for selected sections. That makes sense for expensive sections that do not need to be interactive immediately.
Do not make it the first SSR task in a mature app. First prove normal hydration is clean, then test one route where an expensive island has a visible payoff. Angular enables event replay automatically when incremental hydration is enabled, so the team should also test early clicks and keyboard interaction.
// main.ts
bootstrapApplication(AppComponent, {
providers: [
provideClientHydration(withIncrementalHydration()),
],
});@defer (hydrate on viewport) {
<app-revenue-chart [series]="series()" />
} @placeholder {
<app-chart-skeleton aria-label="Chart loading" />
}Move browser-only DOM work out of render
SSR exposes code that assumes window, document, layout measurement, or a chart library exists during render. Avoid turning that into scattered isPlatformBrowser branches in templates.
For component DOM work that only makes sense in the browser, use hooks such as afterNextRender. The rendered HTML remains consistent, and the browser-only side effect runs after Angular has rendered in the browser.
export class ChartHostComponent {
private readonly chartHost = viewChild.required<ElementRef>('chartHost');
constructor() {
afterNextRender(() => {
const width = this.chartHost().nativeElement.clientWidth;
this.resizeChart(width);
});
}
private resizeChart(width: number) {
// browser-only chart API
}
}The spike should produce numbers and exceptions
Rendering changes can sound bigger than their evidence. Keep the spike narrow: measure the current state, change one route or one heavy component, and record what improved, what broke, and what had to be skipped.
ngSkipHydration can be a tactical exception for a legacy component that mutates DOM, but it should come with a component name, reason, owner, and removal plan. If the exception list grows, hydration is telling you something about the codebase.
| Hypothesis | Minimum evidence | Decision |
|---|---|---|
| Prerender improves public content | HTML exists at build output, SEO metadata is present, build time is acceptable | Use prerender for stable public routes |
| SSR improves a critical route | LCP improves and hydration logs stay clean | Use server rendering for that route |
@defer helps a heavy component | Initial bundle drops and placeholder causes no CLS | Keep defer and document the trigger |
| Incremental hydration helps a route | Early events replay and non-critical island hydrates later | Keep only for that measured route |
| Legacy DOM blocks hydration | Mismatch reproduced and owner identified | Temporary ngSkipHydration with removal plan |
rm -rf dist .angular/cache
time npx ng build --configuration production --stats-json
npx ng test --no-watch
npx lighthouse https://staging.example.com/critical-route --only-categories=performanceReusable artifact
Rendering strategy review checklist
- Classify each priority route as CSR, prerender, or SSR before touching configuration.
- When using
getPrerenderParams, keepinject()before anyawait. - Enable hydration only with a DOM audit: valid HTML, no server/client mismatch, no uncontrolled direct DOM mutation.
- Filter transfer cache for user-specific or sensitive endpoints.
- Use
@deferonly when a standalone heavy dependency leaves the initial path, the placeholder stays cheap, and CLS remains stable. - Treat incremental hydration as a measured route-level experiment, not a default for the entire app.
- Document every
ngSkipHydrationexception with owner, reason, risk, and removal plan.
Sources checked
- https://angular.dev/guide/ssr
- https://angular.dev/guide/hydration
- https://angular.dev/guide/incremental-hydration
- https://angular.dev/api/platform-browser/withEventReplay
- https://angular.dev/guide/templates/defer
- https://github.com/angular/angular/discussions/57664
- https://blog.angular.dev/introducing-angular-v17-4d7033312e4b
- https://blog.angular.dev/angular-v18-is-now-available-e79d5ac0affe
- https://blog.angular.dev/announcing-angular-v20-b5c9c06cf301
Modern Angular Playbook
This article is one play.
The Modern Angular Playbook collects the diagnostic, the adoption matrix, eleven plays, and the 30-day plan. Free, in English and Portuguese.
You get both PDFs by email, through the Dojo IA list.
André Ramosavailable for remote roles, UTC−3Get in touch →