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 shapeMode to testEvidence required
Public marketing routePrerenderSEO, stable content, low per-request personalization
Blog or docs routePrerender with paramsKnown slugs at build time and acceptable build cost
Logged-in dashboardClient renderingPrivate data, heavy browser-only UI, SEO not relevant
Checkout or product detailServer renderingLCP and conversion improve without leaking user data
Unknown fallbackServer rendering or explicit 404 policyClear behavior for routes not generated at build time
Route-level render modests
// 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.

Hydration and transfer cachets
// 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.

Deferring a heavy report charthtml
<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.

Enable incremental hydrationts
// main.ts
bootstrapApplication(AppComponent, {
  providers: [
    provideClientHydration(withIncrementalHydration()),
  ],
});
Hydrate the chart when it enters the viewporthtml
@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.

Browser-only measurement after renderts
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.

HypothesisMinimum evidenceDecision
Prerender improves public contentHTML exists at build output, SEO metadata is present, build time is acceptableUse prerender for stable public routes
SSR improves a critical routeLCP improves and hydration logs stay cleanUse server rendering for that route
@defer helps a heavy componentInitial bundle drops and placeholder causes no CLSKeep defer and document the trigger
Incremental hydration helps a routeEarly events replay and non-critical island hydrates laterKeep only for that measured route
Legacy DOM blocks hydrationMismatch reproduced and owner identifiedTemporary ngSkipHydration with removal plan
Rendering baseline commandsbash
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=performance

Reusable artifact

Rendering strategy review checklist

  • Classify each priority route as CSR, prerender, or SSR before touching configuration.
  • When using getPrerenderParams, keep inject() before any await.
  • 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 @defer only 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 ngSkipHydration exception with owner, reason, risk, and removal plan.

Sources checked

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.

Open playbook →

André Ramosavailable for remote roles, UTC−3Get in touch →

Related

Error · 8 min

NG0500 Hydration Node Mismatch: the Real Fix Is Not ngSkipHydration

What NG0500 Hydration Node Mismatch means, the short list of real causes (direct DOM manipulation, invalid HTML the browser normalizes, third-party DOM libraries, HTML altered in transit), why ngSkipHydration is a tourniquet, and the fix for each.

Read article →

Guide · 10 min

The Code That Breaks When Angular Goes Zoneless

A code-heavy review guide for timers, subscriptions, forms, third-party callbacks, and tests before a zoneless Angular spike.

Read article →

Guide · 9 min

HTTP State Without Turning Everything Into Signals

A code-heavy guide to Angular HTTP state boundaries with HttpClient, RxJS, toSignal, and cautious httpResource experiments.

Read article →