Blazor WASMとHugoを連携させたブログサイトを構築してみた

本記事では、フロントエンドにBlazor WASM、コンテンツ管理にHugoを採用したブログサイトの構築方法及び、GitHub ActionsによるCI/CDパイプラインの構築について簡潔に説明します。

はじめに

Blazorを用いたWeb開発において、多くの記事を扱うブログのようなサイト構築を解説した例は多くありません。 本記事ではその課題に対し、Markdownで記述した記事をHugoで静的サイト生成(SSG)し、Blazorの動的なUIと連携させるハイブリッドなアプローチを解説します。

システムアーキテクチャ

本記事で構築するシステムの全体像は以下の通りです。

  • ソースコード管理: GitHub
  • フロントエンド: Blazor WASM(.NET)
  • コンテンツ管理(SSG): Hugo
  • CI/CD: GitHub Actions
  • ホスティング: Azure Static Web Apps
  • DNS管理: Azure DNS

system-architecture-image

システムのデプロイフローは以下の通りです。

workflow-architecture-image

設計

本ブログでは、Blazorプロジェクトをビルドする際、HugoによるSSGを実行し、その生成物の記事データを静的ファイルとしてwwwrootに配置し、JSON APIのエンドポイント経由でアプリケーションがHttpClient.GetStringAsync()を使用してフェッチする構成を採用しました。

全体のディレクトリ構造

全体のディレクトリ構造は以下の通りであり、Blazorプロジェクト配下にHugoのソースを、Blazorプロジェクト外に生成元の記事コンテンツを配置しています。

HugoSiteフォルダでは、テンプレートシステムを採用し、Hugo側で生成に最適なテンプレートを自動選択し生成します。 また、content/articlesフォルダでは、リーフバンドルを採用し、Hugoソースにおける記事リソースのアクセスを容易にしています。

JunueBlog/
├── JunueBlog.sln                    # ソリューションファイル
├── Client/                          # Blazor WASM プロジェクト
│   ├── Client.csproj
│   ├── Program.cs
│   ├── App.razor
│   │
# ... Blazorソース(略)
│   │
│   ├── HugoSite/                    # Hugo設定・テンプレート
│   │   ├── hugo.toml                # Hugo基本設定(共通設定)
│   │   └── layouts/                 # HTMLテンプレート群
│   │       ├── _markup/             # Markdownレンダリング設定
│   │       ├── _partials/           # 再利用可能なテンプレート部品
│   │       ├── baseof.html          # ベースHTMLテンプレート
│   │       ├── index.articles-index.json  # JSON API生成テンプレート
│   │       └── page.html            # 個別記事ページテンプレート
│   │ 
│   └── wwwroot/                     # 静的ファイル(Hugo出力先)
│       ├── index.html
│       ├── articles-index.json      # 記事一覧API
│       ├── sitemap.xml
│       ├── staticwebapp.config.json # Azure SWA設定
│       ├── css/
│       ├── js/
│       └── public/                  # 公開フォルダ
│           └── articles/            # HTML化された記事
│
└── content/                         # 生成元記事コンテンツ
    └── articles/                    # articlesセクション
        └── 2025/
            └── 2025-09-06-blazor-azure-static-blog/
                ├── index.md
                ├── system-architecture.png
                └── workflow-architecture.png

ビルドプロセスとHugoの連携

Blazorプロジェクトのビルドプロセス内でHugoによる静的サイト生成プロセスを追加しました。 これにより、ビルド時に自動でSSGを実行し、HTMLファイルの記事コンテンツを配置することができます。

なお今回はJSON API用のJsonファイルとsitemapをwwwroot直下、記事コンテンツはwwwroot/public/articlesフォルダ内に分けて配置するため、objフォルダにHugoの生成物を出力してから、wwwrootフォルダにステージングする手法を採用しています。

Hugoと連携したビルドプロセス (Client.csproj):

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">

  <PropertyGroup>
  ...(略)
    <!-- Hugo統合設定 -->
    <!-- RunHugo: Hugo自動実行の制御(既定: true、手動でfalseに設定可能) -->
    <RunHugo>true</RunHugo>
    <HugoExe>hugo</HugoExe>
    <!-- Hugoソースのルートディレクトリ -->
    <HugoSiteDir>$(MSBuildProjectDirectory)\HugoSite</HugoSiteDir>
    <!-- Hugoの最終出力先 -->
    <HugoOutDir>$(MSBuildProjectDirectory)\wwwroot</HugoOutDir>
    <!-- Hugoのステージング出力先 -->
    <HugoStageDir>$(MSBuildProjectDirectory)\obj\hugo</HugoStageDir>
  </PropertyGroup>

  ...(略)

  <!-- Hugo実行ターゲット -->
  <Target Name="RunHugo" BeforeTargets="Build" Condition="'$(RunHugo)'=='true'">
    <Message Text="Running Hugo to generate posts and data JSON..." Importance="High" />
    <!-- GitHub Actions の Oryx コンテナ内で Hugo が Git 情報を読む際の"detected dubious ownership" 回避のため safe.directory を登録 -->
    <Exec Command="git config --global --add safe.directory /github/workspace" IgnoreExitCode="true" Condition="'$(GITHUB_ACTIONS)'=='true'" />
    <!-- Hugo (extended) の存在確認 -->
    <Exec Command="&quot;$(HugoExe)&quot; version" IgnoreExitCode="true">
      <Output TaskParameter="ExitCode" PropertyName="HugoVersionExitCode" />
    </Exec>
    <Error Condition="'$(HugoVersionExitCode)'!='0'" Text="Hugo (extended) が見つかりません。winget install Hugo.Hugo.Extended 後に VS を再起動してください。" />

    <!-- ステージングディレクトリをクリーン -->
    <RemoveDir Directories="$(HugoStageDir)" />
    <MakeDir Directories="$(HugoStageDir)" />

    <!-- Hugo実行 ステージング出力(wwwroot直下はクリーンしない) -->
    <Exec WorkingDirectory="$(HugoSiteDir)" Command="&quot;$(HugoExe)&quot; --config "hugo.toml" --destination &quot;$(HugoStageDir)&quot;" />

    <!-- wwwroot/articlesをクリーンコピー -->
    <RemoveDir Directories="$(HugoOutDir)\public\articles" />
    <MakeDir Directories="$(HugoOutDir)\public\articles" />

    <ItemGroup>
      <HugoArticles Include="$(HugoStageDir)\articles\**\*.*" />
    </ItemGroup>

    <!-- articles フォルダの全ファイルをコピー -->
    <Copy SourceFiles="@(HugoArticles)" DestinationFiles="@(HugoArticles->'$(HugoOutDir)\public\articles\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />

    <!-- articles-index.json をコピー(必須ファイル) -->
    <Error Condition="!Exists('$(HugoStageDir)\articles-index.json')" Text="articles-index.json が生成されていません: $(HugoStageDir)\articles-index.json" />
    <Copy SourceFiles="$(HugoStageDir)\articles-index.json" DestinationFiles="$(HugoOutDir)\articles-index.json" SkipUnchangedFiles="true" />
  </Target>

</Project>

実装

Hugoの実装

Hugo設定ファイル

hugo.tomlは、Hugoサイトの基本設定やビルド動作を定義するメイン設定ファイルです。このファイルでサイトのメタデータ、出力形式、パーマリンク構造、コンテンツの処理方法などを制御できます。

今回は、JSON API用のJsonファイルを生成するために、HugoのCustom Output Formatsを活用します。

Hugo設定ファイル (hugo.toml):

baseURL = "https://osg.junue.net/"
languageCode = "ja-jp"
defaultContentLanguage = "ja-jp"
title = "JunueBlog"
timeZone = "Asia/Tokyo"

# 記事コンテンツの格納場所
contentDir = "../../content"

# 不要なページ種類の生成を無効化(homeは除外)
disableKinds = ["RSS", "taxonomy", "term", "section"]

# /articles/slug/ 構造のパーマリンク設定
[permalinks]
  articles = "/articles/:slug/"

[taxonomies]
  tag = "tags"
  
# JSON出力用の設定
[outputs]
  page = ["HTML"] 
  home = ["articles-index"]

[outputFormats]
  [outputFormats.articles-index]
    baseName = "articles-index"
    mediaType = "application/json"
    isPlainText = true

記事メタデータの管理

スラグやタグといった記事のメタデータ管理には、MarkdownのYAMLフロントマターを使用します。 ここで指定したメタデータをJSON APIで活用します。

記事Markdownファイル (index.md):

---
slug: "blazor-hugo-azure-blog"
title: "Blazor WASMとHugoを連携させたブログサイトを構築してみた。"
description: "Blazor WebAssemblyとHugoを組み合わせたハイブリッドなブログサイトの構築方法を解説。MSBuildでのHugo連携、JSON API生成、Azure Static Web AppsでのCI/CDパイプライン構築まで説明します。"
tags: ["Blazor", "Hugo" ,"Azure"]
date: "2025-09-30"
updated: "2025-10-01"  # オプション
---

以下本文

各フィールドの役割:

  • slug: URL生成とファイル識別のための一意キー
  • title: 記事タイトル(SEOとナビゲーション用)
  • description: メタディスクリプション(SEOと記事一覧表示用)
  • tags: カテゴライゼーションとフィルタリング用
  • date: 公開日
  • updated: 更新日(オプション、差分管理用)

JSON APIの生成

JSON APIはBlazorアプリケーションでのデータフェッチングを効率化するため、各記事コンテンツのメタデータをまとめたサマリーファイルです。 hugo.tomlで生成の指定を行ったので、articles-indexのテンプレートファイルであるindex.articles-index.jsonを作成します。 このテンプレートでは、記事セクション内のすべてのindex.mdのYAMLフロントマターを読み取り、記事のメタデータを取得します。

記事インデックスAPIテンプレート (index.articles-index.json):

{{- /* articles セクションの index.md ファイルのみを対象 */ -}}
{{- $articlesPages := where .Site.RegularPages "Section" "articles" -}}
{{- $indexPages := where $articlesPages ".File.TranslationBaseName" "index" -}}

{{- /* 日付でソート(降順)*/ -}}
{{- $sortedArticles := sort $indexPages "Date" "desc" -}}
{{- $finalArticles := slice -}}

{{- range $article := $sortedArticles -}}    
  {{- $article := dict 
    "url" (printf "public/articles/%s/index.html" $article.Params.slug)
    "slug" $article.Params.slug
    "title" $article.Params.title
    "description" $article.Params.description
    "tags" (default slice $article.Params.tags)
    "date" ($article.Date.Format "2006-01-02")
  -}}
  
  {{- /* 更新日があれば追加 */ -}}
  {{- with $article.Params.updated -}}
    {{- $article = merge $article (dict "updated" .) -}}
  {{- end -}}
  
  {{- $finalArticles = $finalArticles | append $article -}}
{{- end -}}

{{- dict "articles" $finalArticles | jsonify (dict "indent" "  ") -}}

生成される記事インデックスAPI (articles-index.json):

{
  "articles": [
    {
      "url":"public/articles/blazor-hugo-azure-blog/index.html",
      "slug": "blazor-hugo-azure-blog",
      "title": "Blazor WASMとHugoを連携させたブログサイトを構築してみた。",
      "description": "Blazor WebAssemblyとHugoを組み合わせたハイブリッドなブログサイトの構築方法を解説。MSBuildでのHugo連携、JSON API生成、Azure Static Web AppsでのCI/CDパイプライン構築まで説明します。",
      "tags": ["Blazor", "Hugo" ,"Azure"],
      "date": "2025-09-30",
      "updated": "2025-10-01"
    },
    ...
  ]
}

記事コンテンツの生成

記事Markdownファイルの本文から、HTMLファイルを生成させるために、baseof.htmlpage.htmlの二つのテンプレートファイルを使用します。

ベーステンプレートはサイト全体で共通する HTML 構造を定義します。head や header/footer、共通スクリプトやメタ情報をまとめ、block “main” の位置を差し替えポイントとして個別テンプレート(define)から内容を注入できるようにします。

ベーステンプレート(baseof.html):

<!DOCTYPE html>
<html lang="{{ .Site.Language.Lang | default "ja-jp" }}">
<head>
    {{ partial "head.html" . }}
</head>
<body>
    <main>
        {{- block "main" . }}
          {{ .Content }}
        {{- end }}
    </main>
</body>
</html>

ページテンプレートは個々のコンテンツ(ページ)をレンダリングするための部分で、baseof.html の block “main” を置き換える define を持ちます。通常はタイトルや本文、ページ固有の構造や部分テンプレート呼び出しをここで定義します。 .Contentプロパティで本文を格納します。

ページテンプレート(page.html):

{{- define "main" }}  
  {{ .Content }}
{{- end }}

Blazorの実装

記事サマリー情報の取得

Hugoで生成されたarticles-index.jsonを取得するためにまず、JSON API用のDTOレコードを用意します。

記事インデックスDTOレコード(ArticlesIndexDto.cs):

public record ArticlesIndexDto
(
    [property: JsonPropertyName("articles")]
    List<ArticlesIndexDto.ArticleItem> Articles
)
{
    public record ArticleItem
    (
        string Url,
        string Slug,
        string Title,
        string Description,
        List<string>? Tags,
        string Date,
        string? Updated
    );
}

HttpClient.GetAsync()を使用し、articles-index.jsonを取得する。articles-index.jsonはwwwroot直下に配置されているため、ファイル名のみでOK。 取得したストリームをArticlesIndexDtoにデシリアライズすることによって、articles-index.jsonの記事サマリ情報を取得することができます。 今後はslugを中心に記事データを表示するため、取得した記事サマリ情報をキャッシュし、slugから関連するほかの情報を抽出できるようにしましょう。

  string jsonFileName = "articles-index.json";
  var response = await _httpClient.GetAsync($"{jsonFileName}");
  response.EnsureSuccessStatusCode();

  var stream = await response.Content.ReadAsStreamAsync();
  var content = await JsonSerializer.DeserializeAsync<ArticlesIndexDto>(stream, _serializeOptions);

  // 取得した記事サマリ情報をキャッシュ
  _cache = contet;

取得した記事サマリーを以下のように記事一覧として表示し、NavLinkを指定してあげることによって記事ページの遷移を促すことができます。

<ul class="list-group">
    @foreach (var article in articles)
    {
        <li class="list-group-item" @key="article.Slug">
            <h5 class="mb-1">
                <!-- SPA 内で詳細ページに遷移するため /articles/{slug}/ を使用 -->
                <NavLink href="@($"/articles/{article.Slug}/")" Match="NavLinkMatch.Prefix">@article.Title</NavLink>
            </h5>
        </li>
      }
</ul>

記事の取得

次に表示する記事用のページを作成します。 記事一覧からのナビゲーションで遷移するために@page/articles/{Slug}/と指定し、[Parameter]Slugを追加してください。 これにより、記事一覧であるSlugを選択した際、記事用ページのパラメーターに選択したSlugが入ります。

記事が初期化される際のOnInitializedAsync()イベントから、Slugから記事サマリ情報を取得し、記事サマリ情報のUrlプロパティに対して、GetStringAsync()でhtml情報を取得します。 HeadとBodyを分けたいので、HtmlAgilityPackを使用して、それぞれの情報を抽出し、プロパティに格納します。

これにより、Hugoで生成した記事コンテンツをBlazorで表示することができます。

@page "/articles/{Slug}/"

<PageTitle>
    @(Title ?? "記事詳細")
</PageTitle>


<div class="article-content" id="article-content-@Slug">
    <!-- 記事本文のHTMLコンテンツを表示 -->
    <div class="articlepage-body">
        @if (Body != null)
        {
            @((MarkupString)Body)
        }
        else
        {
            <p>記事の内容が見つかりません。</p>
        }
    </div>
</div>

<HeadContent>
    @if (Head != null)
    {
        @((MarkupString)Head)
    }
</HeadContent>

@code {
    /// <summary>
    /// ルートパラメーターから取得される記事のスラッグ
    /// </summary>
    [Parameter] public string? Slug { get; set; }

    /// <summary>
    /// 記事(HTML)のBody
    /// </summary>
    private string Title { get; set; }

    /// <summary>
    /// 記事(HTML)のBody
    /// </summary>
    private string Body { get; set; }

    /// <summary>
    /// 記事(HTML)のHead
    /// </summary>
    private string Head { get; set; }

    protected override async Task OnInitializedAsync()
    {
        await LoadArticleAsync();
    }

    private async Task LoadArticleAsync()
    {
        try
        {
            // slugからキャッシュから対応する記事サマリ情報を取得するメソッド
            articleSummary = GetCacheArticleSummary(Slug);

            // 記事サマリ情報のURLから、index.htmlを取得
            var html = await httpClient.GetStringAsync(articleSummary.Url);

            //HtmlAgilityPackを使用して、index.htmlのheadとbodyを抽出
            var doc = new HtmlAgilityPack.HtmlDocument();
            doc.LoadHtml(html);
            var headNode = doc.DocumentNode.SelectSingleNode("//head");
            var bodyNode = doc.DocumentNode.SelectSingleNode("//body");

            // 抽出した情報をプロパティに格納
            Head = headNode?.InnerHtml;
            Body = bodyNode?.InnerHtml;

            Title = articleSummary.Title;
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, "Error loading article: {Slug}", Slug);
            SetErrorState($"記事の読み込みに失敗しました: {ex.Message}");
        }
    }
}

CI/CDの実装

Azure SWAとGitHubを連携している場合、workflowsを設定することによりCI/CDが可能となる。 ここで注意しないといけないのは、workflow上で動作するHugoのバージョンが最新版であることです。

Azure/static-web-apps-deploy@v1でHugoを実行してくれるが、実行されるHugoのバージョンがかなり古いもののため、比較的新しい記述だとエラーが発生します。 そのため、buildジョブで.NETHugoの適正なバージョンを取得し、それらを用いてビルドを行う必要があります。生成物はアーティファクトとして保存し、deployジョブにてダウンロードすることで、生成物を受け渡すことが可能です。なおdeployジョブでは、ビルドを行わないため、skip_app_build: trueと設定します。

name: Azure Static Web Apps CI/CD

on:
  push:
    branches:
      - main
  pull_request:
    types: [opened, synchronize, reopened, closed]
    branches:
      - main

jobs:
  build:
    if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
    runs-on: ubuntu-latest
    name: Build
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: true
          lfs: false
          fetch-depth: 0
      
      - name: Mark workspace as safe directory for Git
        run: git config --global --add safe.directory /github/workspace
      
      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: 'latest'
          extended: true
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'
      
      - name: Build Application
        run: |
          cd Client
          dotnet publish -c Release -p:RunHugo=true
      
      - name: Upload Build Artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-artifacts
          path: Client/bin/Release/net9.0/publish/wwwroot/

  deploy:
    needs: build
    if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
    runs-on: ubuntu-latest
    name: Deploy
    steps:
      - name: Download Build Artifacts
        uses: actions/download-artifact@v4
        with:
          name: build-artifacts
          path: Client/wwwroot
      
      - name: Deploy to Azure Static Web Apps
        uses: Azure/static-web-apps-deploy@v1
        with:
          azure_static_web_apps_api_token: ${{ secrets.TOKEN }}
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          action: "upload"
          app_location: "Client/wwwroot"
          skip_app_build: true

  close_pull_request:
    if: github.event_name == 'pull_request' && github.event.action == 'closed'
    runs-on: ubuntu-latest
    name: Close Pull Request
    steps:
      - name: Close Pull Request
        uses: Azure/static-web-apps-deploy@v1
        with:
          azure_static_web_apps_api_token: ${{ secrets.TOKEN }}
          action: "close"
          app_location: "."

おわりに

以上で、Blazor WASMとHugoを連携させたブログの構築方法についての解説を終了します。 書いてて思ったのですが、ニッチすぎて需要あるんですかね。。。?

まぁともあれ、これから定期的に記事の更新を行うので、もしよかったら定期的に見に来てくれたら幸いです!!