Angular上級 #6 SSR — Angular Universal とハイドレーション

読了 11分

これまでの Angular 講座で私たちが作ってきたアプリはすべて CSR(Client-Side Rendering) 方式でした。基礎 #2 ng new でプロジェクトを作るときに Server-Side Rendering (SSR)? という質問に No と答えたことを覚えていらっしゃるでしょう。その選択の結果物が、まさに私たちがこれまで扱ってきた一般的な SPA — 空の HTML の殻を先に受け取り、JavaScript バンドルがダウンロードされてようやく画面が描画されるあの方式です。

ほとんどの社内システム・ダッシュボードならこの方式でも十分です。しかし 公開ウェブサイト なら話が変わります。検索エンジンが本文をインデックスしなければならず、初回画面が速く表示されなければユーザーが離れ、ソーシャル共有のプレビューにタイトルや画像が正しく入っていなければなりません。こうした要求を解いてくれるのが SSR(Server-Side Rendering) です。

今回はモダン Angular(Angular 17+)の統合 SSR モデルを整理します。古い名前の Angular Universal から ハイドレーションTransferState、そして Pre-rendering(SSG) まで一度に押さえます。

CSR / SSR / SSG — 3 つのレンダリングモデル #

まず用語をはっきりさせておきます。同じ Angular アプリでも、画面が作られる時点と場所によって 3 つに分かれます。

  • CSR (Client-Side Rendering) — ブラウザが空の HTML を受け取り、JS バンドルをダウンロードして実行した後にようやく画面が描画されます。私たちがこれまでやってきた方式です。
  • SSR (Server-Side Rendering) — リクエストごとにサーバーでアプリを実行し、完成した HTML を作って送ります。ユーザーは初回画面を即座に見て、JS はその上にインタラクションを上乗せします。
  • SSG (Static Site Generation, Pre-rendering) — ビルドタイムに予めすべてのページを HTML として抽出しておきます。リクエストが来たらサーバーがその静的ファイルをそのまま返すだけで済みます。
3 つのモデルの違い
CSR : リクエスト → 空の HTML + JS → ブラウザで描画
SSR : リクエスト → サーバーが HTML を生成 → 送信 → ブラウザでハイドレーション
SSG : (ビルド時点で HTML 生成) → リクエスト → 静的 HTML を即座に応答

重要なのは 3 つのうち 1 つだけを選ばなければならないわけではないという点です。モダン Angular は 3 つを同じビルドの中でルート単位で混ぜて使えるよう 統合してあります。ブログ本文ページは SSG で、ユーザーダッシュボードは SSR で、インタラクション中心の管理画面は CSR で — 1 つのアプリの中で自然に共存します。

Angular Universal の位置 #

Angular で SSR を検索すると Angular Universal という名前がよく出てきます。これは古い名前です。2017 年頃から SSR を担当していた別のパッケージ(@nguniversal/*)でしたが、Angular 17 から SSR がフレームワーク本体に統合 されながら名前とパッケージ構造が整理されました。

Angular Universal の変遷
~Angular 16 : @nguniversal/express-engine など別パッケージ
Angular 17+ : @angular/ssr 1 つのパッケージに統合 (ng new --ssr)

新しいプロジェクトでは @angular/ssr だけ知っていれば十分です。ただし会社のコードベースで @nguniversal/... に出会うことがまだ多いので、「古い名前なんだな」程度には覚えておくとよいです。内部の動作は同じ系譜です。

SSR の有効化 #

新規プロジェクトなら --ssr フラグ 1 つで終わりです。

新規プロジェクトに SSR を入れる
ng new my-app --ssr

ng new 中の SSR の質問に No と答えて既に作っていたプロジェクトなら、後で ng add で追加できます。

既存プロジェクトに SSR を追加
ng add @angular/ssr

このコマンドを実行すると次のような変化が生じます。

SSR 有効化後に追加/変更されるもの
src/
├── main.server.ts         ← サーバー用エントリポイント (追加)
├── app/
│   ├── app.config.ts          ← クライアント設定 (修正)
│   └── app.config.server.ts   ← サーバー設定 (追加)
└── server.ts              ← Express サーバー (追加)
angular.json              ← server ビルドターゲット追加
package.json              ← serve:ssr:my-app スクリプト追加

server.ts がポイントです。Express ベースの小さな Node サーバーで、入ってくるリクエストを受けて Angular アプリをサーバーでレンダリングし、HTML として応答します。このファイルは直接修正してミドルウェアを追加したり、別のルートを差し込んだりできる普通の Express アプリです。

provideServerRendering / provideClientHydration #

Standalone コンポーネント時代の Angular は、設定も関数呼び出しの集まりです。SSR を点けるとクライアントとサーバーの設定ファイルが対で作られます。

src/app/app.config.ts (クライアント)
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideClientHydration(),
    provideHttpClient(withFetch()),
  ],
};
src/app/app.config.server.ts (サーバー)
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);

各関数がする仕事は名前にそのまま現れています。

  • provideServerRendering() — アプリをサーバー(Node)でレンダリングできるよう、必要なトークンを登録します。
  • provideClientHydration() — ブラウザがサーバーから受け取った DOM の上にハイドレーションを実行するよう点けます。これが抜けると SSR が点いていてもクライアントが DOM を丸ごと再描画します — 最もよくある SSR 設定ミスです。
  • provideHttpClient(withFetch()) — サーバーでは XMLHttpRequest を使えないので fetch バックエンドを使います。SSR 環境では事実上必須です。

ハイドレーションとは #

たった今登場した ハイドレーション(Hydration) という言葉をちょっと押さえておきます。例えるとこうです。

サーバーが 乾いた HTML を描いて送ってくれると、クライアントがその上に JS の 水を注いで 生きているコンポーネントにします。

サーバーで描いた静的な DOM をクライアントがそのまま受け継ぎ、その DOM にイベントハンドラを付けてシグナル・バインディングをつなぐ プロセスがハイドレーションです。

Angular 16 以前は SSR が点いていてもクライアントが到着すると DOM 全体を丸ごと再描画していました(destructive hydration)。 画面が一度ちらつき、ユーザーから見ると「なぜ再描画されるんだろう?」という違和感がありました。

Angular 16 で Non-destructive hydration が正式に安定化されたことで、この問題はなくなりました。クライアントはサーバーが描いた DOM を 触らずに、その上にイベントリスナーをそっと乗せるだけです。ちらつきがなく、初回ペイント以降のインタラクション可能時点(TTI)も速くなります。

provideClientHydration() を点けるだけでこのモードが有効になります。

ヒント
ハイドレーションが点いた状態では サーバーで描いた DOM とクライアントが描く DOM が正確に同じでなければなりません。サーバーでは Date.now() のように毎回値が変わる式が入ると、クライアントで再計算するときに結果が変わってハイドレーションミスマッチが発生する可能性があります。こうした動的な値はクライアント専用コンポーネントに分離するか、時刻表示は [ngClass] や directive でクライアントでのみ適用する形で解く必要があります。

TransferState — 同じデータを 2 回受け取らない #

SSR を点けると微妙な問題が 1 つ見えてきます。サーバーが HttpClient で API を呼んでデータを受け取って HTML を描いたのに、クライアントがハイドレーションするときにその API をもう一度呼ぶ 現象です。

サーバーが既に受け取ったレスポンスを HTML と一緒にクライアントに渡し、クライアントは最初はその値をそのまま使えば 1 回で十分です。これをすっきり解いてくれるのが Angular の TransferState API です。

最も簡単な方法は withFetch() と一緒に provideClientHydration(withHttpTransferCacheOptions(...)) を使うことです。このオプションを点けると SSR 中に発生した GET リクエストの結果を HTML にシリアライズして一緒に下ろし、クライアントはそのキャッシュを見て再リクエストを省きます。

src/app/app.config.ts
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withFetch()),
    provideClientHydration(
      withHttpTransferCacheOptions({
        includePostRequests: false,
        filter: (req) => !req.url.includes('/admin'),
      }),
    ),
  ],
};

直接キーと値を渡したいときは TransferState トークンを注入して明示的に扱うこともできます。

src/app/services/posts.service.ts
import { inject, Injectable, makeStateKey, TransferState } from '@angular/core';
import { HttpClient } from '@angular/common/http';

const POSTS_KEY = makeStateKey<Post[]>('posts');

@Injectable({ providedIn: 'root' })
export class PostsService {
  private http = inject(HttpClient);
  private state = inject(TransferState);

  async loadPosts(): Promise<Post[]> {
    // クライアントの初回実行時、サーバーが入れておいた値があれば再利用
    const cached = this.state.get(POSTS_KEY, null);
    if (cached) {
      this.state.remove(POSTS_KEY);
      return cached;
    }
    const posts = await firstValueFrom(this.http.get<Post[]>('/api/posts'));
    this.state.set(POSTS_KEY, posts); // サーバー側で入れると HTML に一緒にシリアライズされる
    return posts;
  }
}

詳細な実装は異なりますが、両方とも 「サーバーが受け取ったデータを HTML の隣に挟んで送り、クライアントが再受信しないようにする」 という同じ目標を持ちます。

サーバーでできないこと #

SSR を点ける瞬間から私たちのコンポーネントは 2 回実行されます。一度は Node サーバーで、一度はブラウザで。Node には windowdocumentlocalStorage もありません。何気なく次のようなコードをコンポーネントの最上位や ngOnInit に置くとサーバーレンダリングがその場で破裂します。

間違った例 — サーバーで破裂するコード
ngOnInit() {
  const theme = localStorage.getItem('theme'); // ReferenceError: localStorage is not defined
  document.body.classList.add(theme ?? 'light');
}

解決策は プラットフォームガード です。Angular は現在実行中のプラットフォームを教えてくれるトークンを提供します。

src/app/theme.component.ts
import { Component, inject, OnInit, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Component({
  selector: 'app-theme',
  standalone: true,
  template: `<button (click)="toggle()">テーマ切替</button>`,
})
export class ThemeComponent implements OnInit {
  private platformId = inject(PLATFORM_ID);

  ngOnInit() {
    if (isPlatformBrowser(this.platformId)) {
      const theme = localStorage.getItem('theme');
      document.body.classList.add(theme ?? 'light');
    }
  }

  toggle() {
    if (!isPlatformBrowser(this.platformId)) return;
    document.body.classList.toggle('dark');
  }
}

isPlatformBrowser で包めばサーバーではそのブロックを飛ばします。setTimeoutIntersectionObserver、チャートライブラリ、地図ライブラリ — ブラウザに依存するすべてのコードはこのガードの中に入れなければならないと考えればよいです。

注記
サードパーティライブラリが import 時点で直ちに window を触ることもあります。こうしたライブラリは import('chart.js') のように 動的 import で包み、isPlatformBrowsertrue のときだけ呼ぶ形で隔離する必要があります。静的 import に window 参照が含まれているとサーバービルド自体が失敗することがあります。

Pre-rendering / SSG #

リクエストごとにサーバーが同じ HTML を作って応答するのが無駄に感じられるときがあります。ブログ記事や文書ページのように 内容がほとんど変わらないページ がそうです。こうしたページは ビルド時点で予め HTML として抽出しておく 方が圧倒的に効率的です。

@angular/ssr はこの Pre-rendering(SSG)を 1 級市民として扱います。app.routes.ts にルートを定義し、各ルートのレンダリング戦略を一緒に指定します。

src/app/app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender }, // ホームはビルドタイムに HTML 抽出
  { path: 'about', renderMode: RenderMode.Prerender },
  {
    path: 'posts/:slug',
    renderMode: RenderMode.Prerender,
    getPrerenderParams: async () => {
      const posts = await loadAllPosts();
      return posts.map((p) => ({ slug: p.slug }));
    },
  },
  { path: 'dashboard/**', renderMode: RenderMode.Server }, // ダッシュボードはリクエストごとに SSR
  { path: 'admin/**', renderMode: RenderMode.Client }, // 管理画面は CSR
];

RenderMode は 3 つです。

  • Prerender — ビルド時に HTML 生成 (SSG)
  • Server — リクエストごとにサーバーがレンダー (SSR)
  • Client — クライアントでのみレンダー (CSR)

このようにルート単位で混ぜて使えるのがモダン Angular SSR の大きな魅力です。同じビルド、同じコードベースの中でページの性格に合わせて戦略を選んで使えばよいです。

ビルド
ng build

ビルドが終われば dist/my-app/browser/ にクライアント資産と prerender された静的 HTML が、dist/my-app/server/ に SSR 用 Node バンドルが作られます。

デプロイ #

ng build の結果物をどこに上げるかは、どのルートが Server モードかによって分かれます。

すべて Prerender ならdist/my-app/browser/ だけあればよいです。普通の静的ホスティングで十分なので VercelNetlifyCloudflare Pages、果ては S3 + CloudFront のような普通の静的ホスティングどこに上げても動作します。

Server モードのルートがあるなら — サーバーで Angular を実行できるランタイムが必要です。選択肢は大きく 2 つに分かれます。

  • Node サーバー (Express)ng build が作り出した server.mjs をそのまま立ち上げます。AWS EC2、GCP、Render、Railway、Fly.io、そして Docker イメージにしてどこにでも。最も伝統的で、どんなミドルウェアでも自由に挟むことができます。
  • サーバーレス / エッジランタイム — Vercel、Netlify Functions、Cloudflare Workers のようにリクエスト単位で関数を実行する環境。コールドスタートが短く、グローバルエッジで応答できる利点があります。Angular CLI で ng add @angular/ssr --server-routing と一緒にビルダーを選んでデプロイアダプタを付ける方式が標準化されつつあります。
ローカルで SSR サーバーを実行
npm run serve:ssr:my-app

デプロイ前に常にこのコマンドで ローカル SSR の動作を一度確認 する習慣をつけてください。ng serve(開発モード)は SSR が常に点いた状態ではないので、サーバー専用コードの問題を見落として通り過ぎる可能性があります。

まとめ #

今回はモダン Angular の SSR を最初から最後まで一気に流しました。

  • CSR / SSR / SSG — Angular 17+ で 1 つのビルドの中でルート単位で混ぜて使える統合モデル
  • Angular Universal は古い名前。今は @angular/ssr に整理された
  • provideClientHydration()non-destructive hydration でちらつきのない初回ペイント
  • TransferState / withHttpTransferCacheOptions でサーバー・クライアントの重複呼び出しを除去
  • isPlatformBrowserwindowdocument のようなブラウザ専用 API を保護
  • RenderMode でルートごとに Prerender / Server / Client を選んで使う

SSR を点ける作業は難しくありませんが、「このコードはサーバーでも回り、クライアントでも回る」という思考の転換が必要です。基礎 #2No と進んだあの質問に、今は自信を持って Yes と答えられるようになっているでしょう。

次回の「Angular上級 #7 パフォーマンスチューニング」では、OnPush 変更検知、トラックバック、lazy loading、バンドル分析まで — 実際のプロダクション Angular アプリが速く動作するように整える技法を順を追って見ていきます。

X