【Angularの新しいSSR環境】「Angular Universal」から「@angular/ssr」へのマイグレーションガイド


※ 当ページには【広告/PR】を含む場合があります。
2024/08/16
Angular Universalのサーバー(AWS Lambda)側で独自フォント(TTF/WOFF/WOFF2)がデコードできないときの対処法
CodeGenieApp/serverless-express(Express Adapter for AWS)のv4への更新方法
蛸壺の技術ブログ|「Angular Universal」から「@angular/ssr」へのマイグレーション



当ブログは、これまで継続的に
「Angular Universal」 (以降、Universal)によってCDNサーバーからプリレンダリングした記事を配給する、いわゆるSSG(Static Site Generation)で構築しております。
個人的にウェブサイトを運営していく上で、この「Universal」がもっとも重要な技術の一つであったわけですが、Angular v17からは正式にAngularの標準ライブラリへと昇格し、
「@angular/ssr」 (以降、Angular/SSR)という名前に変わりました。
このことでAngular v17以降からは、Universalをそのまま使い続けることができなくなり、Angular/SSRへの完全移行が必要になります。
ここでは、Universal時代のSSG/SSRプロジェクトのレガシーコードをAngular/SSRへ引き上げたときの作業を中心に紹介していきます。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2025年最新】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集

UniversalをAngular/SSRへ巻き上げる



少しだけ歴史を辿ると、そもそもAngular v17より前のバーションでは、UniversalはSSR/SSGへの対応はサイドメニュー的な扱いで、長らく有志のプログラマーの方々がほそぼそと開発していた時代が続きました。
どちらかというとサードパーティ提供のアドオンをインストールするような感覚で、環境をセットアップするだけでも非常に手間を取られ、クライアントサイドとサーバーサイドのそれぞれのコードを使い分け、それぞれにプログラムを別にビルドしないなどなど、何とも理解が難しいライブラリでした。
Angular v17以降、大きく進化したのが正式に加えられたビルダー・
「@angular-devkit/build-angular:application」 の存在であり、これがなんと ブラウザターゲット+サーバーターゲット の両方を同時にビルドしてくれるようになりました。
つまり、一からSSR/SSG対応のAngularアプリを開始しようとする場合、

            $ ng new --ssr
$ npm run build && npm run serve:ssr:[アプリケーション名]

        

だけで良くなってしまいました!
これがどれだけ楽チンかというと、かつてのUniversal時代ではとても分かりにくかった、ブラウザーパートとサーバーパートを住み分けした後に、分割ビルドを...

            #クライアントサイドのビルド
$ ng build --configuration production

#次段でサーバーサイドのビルド(SSRのみ/プレレンダリングは別)
$ ng run [アプリケーション名]:server:production

        

などとする必要があるなど、ビルドするだけでもプロジェクトの構成に込み入った調整が必要になっていました。
ともかく、AngularプロジェクトでもSSR/SSG対応の敷居がかなり低くなったことは喜ばしいことです。


v16からv17への移行作業



さて、本題の過去のUniversal時代のSSR/SSGレガシープロジェクトをここ最近のAngular/SSRに引き上げることを目指します。
NPMパッケージの名前を
@nguniversal@angular/ssr に変えただけは動きません。

@angular/ssr にする前に、ビルド環境をドラスティックに変えないとモダンなSSR対応にできないのです。
実際のところ、もう
ng new --ssr からプロジェクト初めて、コードを移植したほうが早いのでは...?と思えるくらいこれが面倒になります。
さほど大きいプロジェクトでなければ、最新のAngular v18で
ng new --ssr から始めたほうが圧倒的に楽です。

参考|Server-side rendering

手動でマイグレーションするポイントは大きく分けて以下の2つです。

            1. ビルドシステムの一新:
  Webpack(@angular-devkit/build-angular:browser)から
  esbuild(@angular-devkit/build-angular:application)へ移行

2. コンポーネントのスタンドアローン対応:
  全コンポーネントをNgModuleの削除などstandaloneへ移行

        

項目1は言わずもがな、ビルダーを変えないとAngular/SSRが使えないためです。
項目2もSSG/SSRをさせたいならば不可欠な変更で、以降で説明する"新方式"の
server.ts が暗黙的にstandaloneを利用させるコードになっているためです。
まずはこの辺の変更を踏まえて、以降のパートで作業の詳細を紹介していきます。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2025年最新】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集

ビルドシステムを「@angular-devkit/build-angular:application」に移行する



v17からv18への移行はほぼ修正なしのシームレスで完了するため、過去のプロジェクトをいかに正常にv17まで引き上げられるかが争点になります。
まずやるべきは
package.json を手動で片っ端からangular17( ~17.0.0 )まで引き上げられるかを試します。

@angular 系のライブラリのnpmバージョン、あとは typescriptrxjszone.js をv17相当のpeerdependenciesに対応させておきます。

            {
  //...中略
  "dependencies": {
    "@angular/animations": "~17.0.0",
    "@angular/common": "~17.0.0",
    "@angular/compiler": "~17.0.0",
    "@angular/core": "~17.0.0",
    "@angular/forms": "~17.0.0",
    "@angular/platform-browser": "~17.0.0",
    "@angular/platform-browser-dynamic": "~17.0.0",
    "@angular/platform-server": "~17.0.0",
    "@angular/router": "~17.0.0",
    "rxjs": "^7.4.0",
    "zone.js": "~0.14.0",
    //...
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "~17.0.0",
    "@angular/cli": "~17.0.0",
    "@angular/compiler-cli": "~17.0.0",
    "typescript": "~5.2.0",
    //...
  }
}

        

ほどよく
package.json を手動アップグレードしたら、

            $ rm -rf yarn.lock node_modules
$ yarn install

        

で過去のプロジェクトのライブラリの掃除&パッケージを更新しておきます。


angular.jsonを手動でリニューアル



UniversalからAngular/SSRへの移行でもっとも肝になる作業が、
angular.json の設定変更です。
v17以降のangular-cliで新規プロジェクトを作成したらこういう修正作業も要らないのですが、既存の
angular.json を手動で設定を変更する場合を想定します。
必要最低限の修正点としてプロジェクトの
architect/buildarchitect/serve の設定を主にいじります。

            //...
"projects": {
  "プロジェクト名": {
    "projectType": "application",
    "schematics": {
      "@schematics/angular:component": {
        "style": "scss"
      }
    },
    "root": "",
    "sourceRoot": "src",
    "prefix": "app",
    "architect": {
      "build": {
        //👇ビルダーをbrowserからapplicationへ変える
        "builder": "@angular-devkit/build-angular:application",
        "options": {
          //👇Universal以前はdist/プロジェクト名/browserだったものから変える
          "outputPath": "dist/プロジェクト名",
          "index": "src/index.html",
          "browser": "src/main.ts",
          //👇polyfills.tsファイルの指定ではなく、個別ファイル指定となった
          "polyfills": ["zone.js"],
          "tsConfig": "tsconfig.app.json",
          "inlineStyleLanguage": "scss",
          "assets": [
            "src/favicon.ico",
            "src/assets",
          ],
          "styles": [
            "src/styles.scss"
          ],
          "scripts": [],
          //👇以下のserver/preprender/ssrの項目が新設
          "server": "src/main.server.ts",
          "prerender": {
              "discoverRoutes": true,
          },
          "ssr": {
            "entry": "server.ts"
          }
        },
        //👇configurationsからbuildOptimizer/vendorChunk/commonChunkが不要になった
        "configurations": {
          "production": {
            "optimization": true,
            "outputHashing": "none",
            "sourceMap": false,
            "namedChunks": true,
            "extractLicenses": false,
            "budgets": [
              {
                "type": "initial",
                "maximumWarning": "2mb",
                "maximumError": "5mb"
              },
              {
                "type": "anyComponentStyle",
                "maximumWarning": "15kb",
                "maximumError": "18kb"
              }
            ]
          },
          "development": {
              "optimization": false,
              "extractLicenses": false,
              "sourceMap": true,
              "namedChunks": true
          }
        }
      },
      "serve": {
        "builder": "@angular-devkit/build-angular:dev-server",
        "configurations": {
          "production": {
            //👇browserTargetからbuildTargetの名前に変更
            "buildTarget": "プロジェクト名:build:production"
          },
          "development": {
              //👇browserTargetからbuildTargetの名前に変更
              "buildTarget": "プロジェクト名:build:development"
          },
          "defaultConfiguration": "development"
        }
      },
//...
}

        

これでWebpackからesbuildに対応させることができました。
ちょっとだけ修正点を振り返ると、Universal時代に独立してあった
architect/server(@angular-devkit/build-angular:server) が不要になっています。
またUniversal付属だった
architect/serve-ssr(@nguniversal/builders:ssr-dev-server) も廃止され、全ては @angular-devkit/build-angular:application に統合されています。
ビルダーが廃統合された関係で、build設定項目から
polyfills.ts などが要らなくなったり、 server などの項目が新設されたり結構変更が多いです。
Typescriptのビルド設定も少し変更します。

tsconfig.app.jsonfiles をAngular/SSR用にリソースに変えておきます。

            {
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "outDir": "./out-tsc/app",
        "types": ["node"],
        "lib": [
          "esnext",
          "dom"
        ]
    },
    "files": [
        "src/main.ts",
        //👇サーバーサイドのアプリケーションもここに追加
        "src/main.server.ts",
        "server.ts"
    ],
    "include": [
        "src/**/*.d.ts"
    ]
}

        

で、Universal時代にあった
tsconfig.server.json はもはや不要です。
ビルドシステムの変更への対応はこのへんでOKかと思います。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2025年最新】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集

プロジェクトコードをスタンドアローン化する



ある意味、手動でやると心が折れそうになるのが、
全ての旧NgModuleコンポーネントをStandaloneコンポーネントへ変える 作業です。
最近のAngularでは既に
*.module.ts を使ってライブラリを管理する必要がなく、スタンドアローンが前提になっています。
実際には、SSRやプリレンダリングをやる必要がないのならそのままNgModuleを使い続けることも可能ですが、SSR/SSGにするならば
*.module.ts などがあるとスッキリとは移行できません。
まずは既存のAngularアプリケーションを「standalone」にマイグレーションしてみます。
そこで使えるのが、Angular謹製の「マイグレーションツール」になります。
早速これを使ってサクッとstandalone化対応してみます。

            $ ng generate @angular/core:standalone

? Choose the type of migration: (Use arrow keys)

❯ Convert all components, directives and pipes to standalone
  Remove unnecessary NgModule classes
  Bootstrap the application using standalone APIs

        

スタンドアローンへのマイグレーションツールは三本建てのメニューが一つになった対話式のプログラムであり、使用者は段階的にプロジェクトのコードを修正することができます。
まず、1つ目の
「Convert all components, directives and pipes to standalone」 は、コンポーネント/ディレクティブ/パイプをstandaloneに変換するツールです。

            $ ng generate @angular/core:standalone

? Choose the type of migration: Convert all components, directives and pipes to standalone

? Which path in your project should be migrated? ./

    🎉 Automated migration step has finished! 🎉
    IMPORTANT! Please verify manually that your application builds and behaves as expected.
    See https://angular.dev/reference/migrations/standalone for more information.
UPDATE src/app/misc/components/thankyou/thankyou.component.ts (604 bytes)
UPDATE src/app/blog/blog-page/blog-page.component.ts (13522 bytes)
#...中略
UPDATE src/app/blog/blog.module.ts (2574 bytes)
UPDATE src/app/top-page/top-page.module.ts (2831 bytes)
UPDATE src/app/misc/notfound.module.ts (586 bytes)

        

コマンド実行時にマイグレーション対象のパスが指定できます。
今回は特に部分指定はせずデフォルト(
./ )でプロジェクト全体を移行させます。
処理が終わると、各コンポーネントに
standalone: true とそれぞれ必要な imports が挿入されていることが分かります。
2つ目の
「Remove unnecessary NgModule classes」 は不要になった NgModuleを削除するツールです。

            $ ng generate @angular/core:standalone

? Choose the type of migration: Remove unnecessary NgModule classes

? Which path in your project should be migrated? ./
    🎉 Automated migration step has finished! 🎉
    IMPORTANT! Please verify manually that your application builds and behaves as expected.
    See https://angular.dev/reference/migrations/standalone for more information.
Nothing to be done.

        

不要なものがない場合には何も起きません。
3つ目の
「Bootstrap the application using standalone APIs」 はstandalone対応の bootstrap へ移行するツールです。

            $ ng generate @angular/core:standalone

? Choose the type of migration: Bootstrap the application using standalone APIs

? Which path in your project should be migrated? ./
    🎉 Automated migration step has finished! 🎉
    IMPORTANT! Please verify manually that your application builds and behaves as expected.
    See https://angular.dev/reference/migrations/standalone for more information.

DELETE src/app/app.module.ts
UPDATE src/main.ts (3273 bytes)

        

このツールの効能はちょっとしたもので、
app.component.tsmain.ts が変更され、不要になった app.module.ts は削除されます。
なお、このマイグレーションツールはスタンドアローン化への必要最低限の修正しかしてくれませんので、過去のレガシーから完全に移行させるにはもうちょっと手動で修正が必要です。
削除や変更の対象外となった不要なファイル等は手動で処分する必要があります。
例えば、Universal時代にサーバー用の設定で使っていた
app.server.module.ts も不要になります。
あとはなぜかapp.component.tsには
standaloneimports が自動でつかないため手動にて追加しないといけないなど、少々残念なところもあります。
ともかくこのヘルパーツールで、大方のスタンドアローン化が自動で完了するため、作業の時短としては効果的です。

main.tsからmoduleの一切を削る



かつてのスタンドアローン前の時代には、
main.ts でプロジェクト内のngModuleは合流する最終地点でした。
Universal時代の典型的な
main.ts は以下のような形をしていたと思います。

            import { CommonModule } from '@angular/common';
import { enableProdMode, importProvidersFrom } from '@angular/core';
import { BrowserModule, bootstrapApplication } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';
import { withInterceptorsFromDi, provideHttpClient } from '@angular/common/http';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppComponent } from './app/app.component';
import { HogeModule } from './app/hoge.module';
import { PiyoModule } from './app/piyo.module';
import { AppRoutingModule } from './app/app-routing.module';

document.addEventListener('DOMContentLoaded', () => {
    bootstrapApplication(AppComponent, {
        providers: [
            importProvidersFrom(
                BrowserModule.withServerTransition({ appId: 'プロジェクト名' }), AppRoutingModule,
                CommonModule,
                FormsModule,
                ReactiveFormsModule,
                HogeModule,
                PiyoModule
            ),
            provideHttpClient(withInterceptorsFromDi()),
            provideAnimations()
        ]
    }).catch(err => console.error(err));
});

        

過去、「Angularは難しい」と言われ続けた当時の仕様を懐かしむことのできる割と難解なコードになっています。
これがスタンドアローン化対応後のv17になると、
main.ts もだいぶ様変わりしています。

            import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig)
    .catch((err) => console.error(err));

        

...どうでしょう、あまりにスッキリしすぎて逆に怖いくらいです。
これがスタンドアローンの力、恐ろしいほど簡素化しています。

app.component.ts



プロジェクトの
app.component.ts のレガシーコードも修正します。
必要最低限のコードは以下の通りです。

            import { Component } from '@angular/core';
//👇スタンドアローン化したルーターアウトレットを使う準備
import { RouterModule, RouterOutlet } from '@angular/router';

@Component({
    selector: 'app-root',
    template: `<router-outlet></router-outlet>`,
    //👇追加
    standalone: true,
    imports: [RouterOutlet, RouterModule]
})
export class AppComponent { }

        

これで最終的に
<app-root> にレンダリング結果が投影されることになります。

app-routing.module.ts --> app.routes.ts



かつて
「app-routing.module.ts」 のような名前でルートもNgModule化して利用する場合がありました。
スタンドアローン後ではルート定義だけでOKになり、名前も
「app.routes.ts」 というものに置き換わっています。
簡単な例でいうと以下のような感じです。

            import { Routes } from '@angular/router';
import { HomeComponent } from './components/home.component';
import { PageNotFoundComponent } from './components/page-not-found.component';
import { ThankyouComponent } from './components/thankyou.component';

export const routes: Routes = [
    { path: 'home', component: HomeComponent },
    { path: 'error', component: PageNotFoundComponent },
    { path: 'confirmation', component: ThankyouComponent },
    { path: '', redirectTo: '/home', pathMatch: 'full' },
    { path: '**', redirectTo: '/error', pathMatch: 'full' }
];

        

app.config.tsとapp.config.server.ts

「app.config.ts」「app.config.server.ts」 もv17以降に自動生成される正式なファイルとして取り扱いされるようになった新しいコードです。

app.config.ts は過去の app.module.ts の内容を、 app.config.server.tsapp.server.module.ts をそれぞれ引き継いています。
スタンドアローン化によってNgModuleの役目を終えたあとの産物で、移行後は必須のファイルになります。
コード内容としては、ほぼ定形構文なので、2つとも手動で以下のファイルを生成しておきましょう。

            import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(routes),
        provideClientHydration(),
        provideAnimations(),
        provideHttpClient(),
    ]
};

        

そして、

            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);

        

以上でザックリとですが旧式のAngularプロジェクトを手動でスタンドアローン化対応する作業は終わりです。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2025年最新】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集

UniversalをAngular/SSRへ移行する



先程まででプロジェクトのスタンドアローン化が完了したので、ここからいよいよUniversalをAngular/SSRへ切り替える作業をやっていきます。
なお、興味本位で、Angular v17の環境で、Universalを使ってみると、以下のような警告が出てきます。

            warning " > @nguniversal/common@16.1.3" has incorrect peer dependency "@angular/common@^16.0.0 || ^16.1.0-next.0".
warning " > @nguniversal/common@16.1.3" has incorrect peer dependency "@angular/core@^16.0.0 || ^16.1.0-next.0".
warning " > @nguniversal/express-engine@16.1.3" has incorrect peer dependency "@angular/common@^16.0.0 || ^16.1.0-next.0".
warning " > @nguniversal/express-engine@16.1.3" has incorrect peer dependency "@angular/core@^16.0.0 || ^16.1.0-next.0".
warning " > @nguniversal/builders@16.1.3" has incorrect peer dependency "@angular-devkit/build-angular@^16.0.0 || ^16.1.0-next.0".

        

何度も繰り返しますが、Angular v17以降では、Universalを使うことができないことが分かります。
で、ひとまずUniversalのパッケージを全て削除します。

            $ yarn remove @nguniversal/common @nguniversal/express-engine @nguniversal/builders

        

そして、Angular/SSR(v17)を手動でインストールしましょう。

            $ yarn add -D @angular/ssr@^17.0.0

        

Angularプロジェクト自体のフォルダ構造の構成はUniversal時代とおおよそ変わりませんが、サーバー関連のファイル構造が大きく異なります。

            my-app
|-- server.ts #サーバーアプリケーション
└── src
    |-- app
    |   └── app.config.server.ts #サーバーアプリケーション設定
    └── main.server.ts #サーバーのブートストラップ

        

先程も述べたように、Universalの時にはappフォルダ内に置いて
app.module.ts + app.server.module.ts だったものが、 app.config.ts + app.config.server.ts に代わっているのが、Angular/SSRからの大きな特徴です。
特に、クライアントサイドで処理させたい実装は
app.config.ts に記述し、サーバーサイドで処理させたい実装は app.config.server.ts に記述するなど、SSG/SSRの中核になる重要なファイルとなります。

app.config.tsのHttpClientモジュール対応



既に前述した
app.config.ts のコードには実は少し手を加えてあります。

ng new --ssr したときに自動で生成される app.config.ts は以下のようなものです。

            import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(routes),
        provideClientHydration(),
        provideAnimations()
    ]
};

        

このまま使うと、HttpClientが使えないため、以下のようなエラーが起こります。

            ERROR Error [NullInjectorError]: R3InjectorError(_ToppageModule)[_SubpageResolver -> _ProvisioningArticleService -> _HttpClient -> _HttpClient -> _HttpClient]: 
  NullInjectorError: No provider for _HttpClient!
    at NullInjector.get (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:5627:27)
    at R3Injector.get (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:6070:33)
    at R3Injector.get (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:6070:33)
    at R3Injector.get (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:6070:33)
    at injectInjectorOnly (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:912:40)
    at Module.ɵɵinject (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:918:42)
    at Object.ProvisioningArticleService_Factory (/usr/src/app/workspace/src/app/service/provisioning-article.service.ts:16:40)
    at eval (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:6192:43)
    at runInInjectorProfilerContext (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:868:9)
    at R3Injector.hydrate (/usr/src/app/workspace/node_modules/@angular/core/fesm2022/core.mjs:6191:17) {
  ngTempTokenPath: null,
  ngTokenPath: [
    '_SubpageResolver',
    '_ProvisioningArticleService',
    '_HttpClient',
    '_HttpClient',
    '_HttpClient'
  ]
}

        

スタンドアローン化より前であれば
HttpClientHttpClientModule の一部でしたが、現在は provideClientHydration というサービスから提供するものになっています。

            import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';

//👇追加
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(routes),
        provideClientHydration(),
        provideAnimations(),
        provideHttpClient(),
    ]
};

        

main.server.ts



サーバーのbootstrapを担当する
main.server.ts も随分と様変わりしました。
かつてのUniversal時代の実装は以下のようになっていました。

            import '@angular/platform-server/init';
import { enableProdMode } from '@angular/core';
import { environment } from './environments/environment';

if (environment.production) {
    enableProdMode();
    global['requestAnimationFrame'] = (callback: any) => {
        let lastTime = 0;
        const currTime = new Date().getTime();
        const timeToCall = Math.max(0, 16 - (currTime - lastTime));
        const id: any = setTimeout(() => {
            callback(currTime + timeToCall);
        }, timeToCall);
        lastTime = currTime + timeToCall;
        return id;
    };
    global['cancelAnimationFrame'] = (id) => {
        clearTimeout(id);
    };
} else {
    console.log('[Server/ExpressNodejs] main.server.ts is enabled Development mode.');
}

export { AppServerModule } from './app/app.server.module';
export { renderModule } from '@angular/platform-server';

        

このようにngModuleをエクスポートする形式でしたが、Angular/SSRのブートストラップでは、以下のようなスタンドアローン方式が推奨されています。

            import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';

const bootstrap = () => bootstrapApplication(AppComponent, config);

export default bootstrap;

        

server.ts



Universal時代からもそうでしたが、Angular/SSRでも
server.ts がサーバーで動くExpressアプリの本体になります。
Universal時代と比べてレンダリングエンジンを中心にガラリと仕様が違います。
ただし、Expressサイドのコードは変更なくほぼ流用できます。

            import {APP_BASE_HREF} from '@angular/common';
import {CommonEngine} from '@angular/ssr';
import express from 'express';
import {fileURLToPath} from 'node:url';
import {dirname, join, resolve} from 'node:path';
import bootstrap from './src/main.server';

export function app(): express.Express {
    const server = express();
    const serverDistFolder = dirname(fileURLToPath(import.meta.url));
    const browserDistFolder = resolve(serverDistFolder, '../browser');
    const indexHtml = join(serverDistFolder, 'index.server.html');
    const commonEngine = new CommonEngine();
    server.set('view engine', 'html');
    server.set('views', browserDistFolder);
  
    server.get('/api/**', (req, res) => {
        res.status(404).send('data requests are not yet supported');
    });

    server.get(
        '*.*',
        express.static(browserDistFolder, {
        maxAge: '1y',
        }),
    );

    server.get('*', (req, res, next) => {
        const {protocol, originalUrl, baseUrl, headers} = req;
        commonEngine
        .render({
            bootstrap,
            documentFilePath: indexHtml,
            url: `${protocol}://${headers.host}${originalUrl}`,
            publicPath: browserDistFolder,
            providers: [{provide: APP_BASE_HREF, useValue: req.baseUrl}],
        })
        .then((html) => res.send(html))
        .catch((err) => next(err));
    });
    return server;
}

export * from './src/main.server';

        

ここでも
ng new --ssr したあとで自動生成される標準の server.ts からちょっと変えています。
標準ではローカルでExpressサーバー起動して動作確認したい際に使う
run メソッドも組み込まれています。
これは考え方の個人差もありますが、著者の使いがっての好みで、以下のような
local.ts という名前で、ビルド済みのコードからExpressアプリを引っ張ってきて、好きなタイミングで ts-node 起動する方式にします。

            import { app } from './dist/プロジェクト名/server/server.mjs';

function run(): void {
    const port = process.env.PORT || process.env.NG_DEV_PORT;

    // Start up the Node server
    const server = app();
    server.listen(port, () => {
      console.log(`Node Express server listening on http://localhost:${port}`);
    });
}

run();

        

ビルドして動作確認



以上のプロジェクト修正を経て、最後にビルドできるかを試します。

            $ ng build --configuration production

        

正常にビルドが完了すると、
dist フォルダにアプリの完成品が出力されていることが分かります。

            dist/
└── [プロジェクト名]
    ├── browser
    ├── prerendered-routes.json
    └── server
          ├── #...中略
          ├── main.server.mjs
          └── server.mjs

        

ローカルサーバーでテスト起動させる場合、ESMインポートを含んだコードの
ts-node 直接起動が落ちてしまうため、 tsx を使って、

            $ node --import tsx ./local.ts

        

とやるとローカルサーバーが起動します。

参考|tsxについて
合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2025年最新】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集

その他細かい修正



ニッチな問題ですが、個人的な移行作業でちょっと引っ掛かった問題をメモしておきます。


Sassでのnode_modulesからのインポート



Angular v16以前では問題なかったのですが、esbuild対応にして、Sassのimport構文がコンパイルエラーを吐くようになってしまいました。

            @import '~katex/dist/katex.min.css';

        

少しルールで変わった?ようですので、angular.jsonの
build タグにある stylePreprocessorOptions の項目を設け、以下のように外部リソースのパスを使えるように指定します。

            ///,
  "styles": [
    "src/styles.scss"
  ],
  "stylePreprocessorOptions": {
    "includePaths": [
      "../node_modules/katex/dist"
    ]
  },
///

        

これでAngular v17以降のプリプロセッサでもnode_modulesのパスが解決できるようになります。

            // @import '~katex/dist/katex.min.css';
//👇node_modulesまでの相対パスに変える
@import '../node_modules/katex/dist/katex.min.css';

        

合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2025年最新】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集

まとめ



以上、なかなかボリューム感のある内容でしたが、コツコツと必要なファイル修正してもらうと過去に作ったUniversalプロジェクトでも問題なくAngular/SSRへ移行できることが分かります。
最近ではJS系フレームワークでもSSR対応の進んだNext.jsなどのほうが認知度が高いのは仕方ないですが、だいぶ使い勝手の向上したAngular/SSRで返り咲きのチャンスがあるか...と少し期待しながら温かい目で見守っていこうかと思います。
記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。

合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート・2025年最新】Angular(JSフレームワーク)をこれから学びたい人のためのオススメ書籍&教材特集