👓

AngularでJamstackなブログサイトをつくる

2024/12/06に公開

これはAngularアドベントカレンダー 6日目の記事です。
https://qiita.com/advent-calendar/2024/angular

はじめに

Angularがv19アップデートでJamstackなサイトを構築しやすくなったので、試しにブログサイトをつくってみました。ヘッドレスCMSにmicroCMS、ホスティングサービスにVercelを使いました。

完成したもの

https://ng19-ssr-hybrid.vercel.app/blog
記事の一覧ページと詳細ページのみ。今回styleは設定しません。

microCMSの準備

事前にAPIを構築して、いくつか記事を追加しておきます。
また、エンドポイントURLとX-MICROCMS-API-KEYをメモっておきます。

実装

バージョン
% ng version                                                                                                                                                       (git)-[main]

     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / △ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |
 /_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|
                |___/
    

Angular CLI: 19.0.2
Node: 20.17.0
Package Manager: npm 10.8.2
OS: darwin arm64

Angular: 19.0.0
... animations, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, platform-server
... router

Package                         Version
---------------------------------------------------------
@angular-devkit/architect       0.1900.1
@angular-devkit/build-angular   19.0.2
@angular-devkit/core            19.0.1
@angular-devkit/schematics      19.0.1
@angular/cli                    19.0.2
@angular/ssr                    19.0.2
@schematics/angular             19.0.1
rxjs                            7.8.1
typescript                      5.6.3
zone.js                         0.15.0

プロジェクト作成

v19で追加されたハイブリッドレンダリングを有効化して、新しいプロジェクトを作成します。

ng new ng19-ssr-hybrid --ssr --server-routing

サービスの実装

BlogServiceを実装します。microCMSのAPIを呼び出します。

  • fetchBlogs:記事一覧を取得する
  • fetchBlog:記事詳細を取得する
  • fetchBlogIds:記事ID一覧を取得する

interfaceは適当に必要なものだけを。

blog.service.ts
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { map, Observable } from 'rxjs';

export interface Blog {
  id: string;
  title: string;
  content: string;
}

export interface MicroCMSGetBlogsResponse {
  contents: Blog[];
}

@Injectable({
  providedIn: 'root',
})
export class BlogService {
  private readonly httpClient = inject(HttpClient);
  private readonly microCMSEndpoint = 'https://**********.microcms.io/api/v1'; // microCMSのエンドポイント
  private readonly microCMSApiKey = '**********'; // microCMSのAPI-KEY

  private readonly headers = new HttpHeaders({
    'X-MICROCMS-API-KEY': this.microCMSApiKey,
  });

  fetchBlogs(): Observable<MicroCMSGetBlogsResponse> {
    return this.httpClient.get<MicroCMSGetBlogsResponse>(
      `${this.microCMSEndpoint}/blogs`,
      {
        headers: this.headers,
      }
    );
  }

  fetchBlog(id: string): Observable<Blog> {
    return this.httpClient.get<Blog>(`${this.microCMSEndpoint}/blogs/${id}`, {
      headers: this.headers,
    });
  }

  fetchBlogIds(): Observable<string[]> {
    return this.fetchBlogs().pipe(
      map((response) => response.contents.map((blog) => blog.id))
    );
  }
}

記事一覧の実装

BlogPageComponentを実装します。
v19で追加されたresource APIを使うと、ローディングやエラーをシンプルに記述できます。

blog-page.component.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { BlogService } from '../../services/blog.service';

@Component({
  selector: 'app-blog-page',
  imports: [RouterLink],
  templateUrl: './blog-page.component.html',
  styleUrl: './blog-page.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BlogPageComponent {
  private readonly blogService = inject(BlogService);

  blogsResource = rxResource({
    loader: () => this.blogService.fetchBlogs(),
  });
}
blog-page.component.html
<h2>BLOG</h2>
@if(blogsResource.value(); as blogs) {
  @for(blog of blogs.contents; track blog.id) {
    <div>
      <a [routerLink]="['/blog', blog.id]">{{ blog.title }}</a>
    </div>
  }
} @else if (blogsResource.error()) {
  <div>Load failed</div>
} @else {
  <div>Loading...</div>
}

記事詳細の実装

PostPageComponentを実装します。
APIレスポンスをそのままinnerHTMLにバインドすると警告が出るので、DomSanitizer.bypassSecurityTrustHtmlでサニタイズします。

post-page.component.ts
import {
  ChangeDetectionStrategy,
  Component,
  inject,
  input,
} from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { DomSanitizer } from '@angular/platform-browser';
import { map } from 'rxjs';
import { BlogService } from '../../services/blog.service';

@Component({
  selector: 'app-post-page',
  templateUrl: './post-page.component.html',
  styleUrl: './post-page.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PostPageComponent {
  private readonly blogService = inject(BlogService);
  private readonly sanitizer = inject(DomSanitizer);

  id = input.required<string>();

  postResource = rxResource({
    request: () => this.id(),
    loader: ({ request: id }) =>
      this.blogService.fetchBlog(id).pipe(
        map((blog) => ({
          ...blog,
          content: this.sanitizer.bypassSecurityTrustHtml(blog.content),
        }))
      ),
  });
}
post-page.component.html
@if(postResource.value(); as post) {
  <h3>{{ post.title }}</h3>
  <article [innerHTML]="post.content"></article>
} @else if (postResource.error()) {
  <div>Load failed</div>
} @else {
  <div>Loading...</div>
}

ルーティングの設定

app.routes.tsを実装します。

app.routes.ts
import { Routes } from '@angular/router';
import { BlogPageComponent } from './pages/blog-page/blog-page.component';
import { PostPageComponent } from './pages/post-page/post-page.component';

export const routes: Routes = [
  {
    path: '',
    pathMatch: 'full',
    redirectTo: '/blog',
  },
  {
    path: 'blog',
    children: [
      {
        path: '',
        component: BlogPageComponent,
      },
      {
        path: ':id',
        component: PostPageComponent,
      },
    ],
  },
];

サーバールーティングの実装

ハイブリッドレンダリングで追加されたapp.routes.server.tsを実装します。

app.routes.tsに設定された各Routeに対するRenderModeを設定するファイルです。今回はJamstackなので全てRenderMode.Prerenderを設定します。

さらに、記事詳細画面(blog/:id)にgetPrerenderPrams関数を設定します。この関数で記事ID一覧を返すことによって、各記事詳細画面をプリレンダリングすることができます。

app.routes.server.ts
import { inject } from '@angular/core';
import { RenderMode, ServerRoute } from '@angular/ssr';
import { firstValueFrom } from 'rxjs';
import { BlogService } from './services/blog.service';

export const serverRoutes: ServerRoute[] = [
  {
    path: 'blog',
    renderMode: RenderMode.Prerender,
  },
  {
    path: 'blog/:id',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      const blogService = inject(BlogService);
      const ids = await firstValueFrom(blogService.fetchBlogIds());
      return ids.map((id) => ({ id }));
    },
  },
  {
    path: '**',
    renderMode: RenderMode.Prerender,
  },
];

ビルド

ng buildすると各記事IDごとにindex.htmlが生成されていることが確認できます。

% tree dist/ng19-ssr-hybrid/browser                                          
dist/ng19-ssr-hybrid/browser
├── blog
│   ├── 4q-rm66b576v
│   │   └── index.html
│   ├── index.html
│   ├── kukv_0rvfqf
│   │   └── index.html
│   └── zbptd_3j6i
│       └── index.html
├── favicon.ico
├── index.csr.html
├── main-4YZ5U62D.js
├── polyfills-SC4UBBZS.js
└── styles-5INURTSO.css

5 directories, 9 files

Vercelにデプロイする

ホスティングするだけならGitHub PagesFirebase Hostingで十分ですが、今後SSRを追加する可能性を考慮してVercelにデプロイします。

Add New Projectから実装したAngularのGitHubリポジトリを選択して、Framework PresetAngularを選択するだけです。v17のときに必要だったvercel.jsonは不要です。

まとめ

これまではScullyAnalogなどのMeta Frameworkがないと難しかったSSR/SSGやJamstackが、Angular単体で構築できる時代がやってきました。

今回の構成(Angular+microCMS+Vercel)は無料なので、ぜひ自分のブログサイトをつくってみてはいかがでしょうか?

参考

https://angular.jp/guide/hybrid-rendering

Discussion