fastapi:~/app$ cat fastapi-htmx.md

FastAPI + HTMX:ビルド不要のフルスタック

# FastAPI + HTMX:ビルド不要のフルスタック

words: 1198 read_time: 41m updated: 2026-04-19 14:52
$ less fastapi-htmx.md

TL;DR: FastAPI + HTMX + Alpine.js + Jinja2 + プレーンなCSSの組み合わせで、ビルドツールゼロ、node_modules/ゼロ、Lighthouseスコア満点のプロダクションWebアプリケーションを構築できます。本ガイドでは、アーキテクチャからデプロイまでシステム全体を網羅しています。プロダクション環境で稼働するblakecrosley.comを実例として、100以上のブログ記事、インタラクティブなJavaScriptコンポーネント、複数の包括的なガイド、9言語の翻訳を——バンドラー、コンパイラー、トランスパイラーを一切使わずに——配信しています。1

現代のWeb開発スタックでは、React、webpack、TypeScript、そしてビルドパイプラインが必要だと考えられています。しかし、コンテンツ主導のサイト、社内ツール、CRUDアプリケーション、ポートフォリオサイト、ドキュメントプラットフォームなど、多くのカテゴリのアプリケーションにとって、その前提は誤りです。本ガイドで紹介するスタックは、フロントエンドのビルドツールチェーンを完全に排除しながら、Lighthouseで100/100/100/100のスコアを達成するサイトを実現します。2

これは主張ではなく、計測結果です。ここで解説するアーキテクチャは本番環境で稼働し、10言語にわたる実際のユーザーにサービスを提供しており、数値はすべて検証可能です。


重要ポイント

  • サーバーレンダリングされたHTMLにより、3つの問題カテゴリが丸ごと解消されます。 クライアント側の状態管理、JSONのシリアライゼーション境界、ハイドレーションの不一致です。HTMXはサーバーレスポンスを最終出力とするため、クライアント側のレンダリングステップが不要になります。
  • ビルドツールがゼロなら、ビルド失敗もゼロです。 npm installのピア依存関係の競合も、触っていないファイルでのTypeScriptコンパイラエラーも、インポートしたことのない推移的依存関係のDependabot PRもありません。デプロイパイプラインはgit pushだけです。
  • Alpine.jsは、HTMXでは扱えないクライアント専用の状態を担当します。 ドロップダウン、モーダル、モバイルナビゲーションの切り替えなど、ブラウザ内だけで完結するUI状態はAlpine.jsの領域です。境界は明確で、サーバーが必要な状態にはHTMXを、そうでなければAlpine.jsを使います。
  • カスタムプロパティを活用したプレーンなCSSが、SassやTailwindを置き換えます。 CSSカスタムプロパティはカスケード・継承し、実行時にメディアクエリに応答します。一方、プリプロセッサ変数はコンパイル時に静的な値へ変換され、消えてしまいます。ブラウザはカスタムプロパティを直接読み取るため、コンパイルステップは不要です。
  • このアプローチには明確な適用範囲があります。 コンポーネントインターフェースを共有する大規模チーム、複雑なクライアント側状態を持つSaaS製品、npmエコシステムのライブラリに依存するアプリケーションには向いていません。セクション15の意思決定フレームワークで、その境界を正確に判断できます。
  • blakecrosley.comがその証明です。 本ガイドのコアパターン(HTMX、Alpine.js、Jinja2、プレーンCSS)は、blakecrosley.comの本番環境で稼働しています。BootstrapとSQLAlchemyのセクションでは、このスタックの標準的なパターンを解説していますが、本サイトでは使用していません。すべての主張にはファイルパス、設定ブロック、またはLighthouse監査の根拠があり、PageSpeed Insightsでご自身で検証できます。2

このガイドの使い方

本ガイドは包括的なリファレンスです。ご自身の経験レベルに合った箇所から始めてください。

経験レベル ここから始める その後に探索
Python開発者でHTMXは初めて ノービルド・テーゼアーキテクチャ概要HTMXディープダイブ Alpine.jsパターンセキュリティ
React/Vue開発者で代替手段を検討中 ノービルド・テーゼ意思決定フレームワーク アーキテクチャ概要パフォーマンス
FastAPI開発者でインタラクティブ性を追加したい HTMXディープダイブAlpine.jsパターン i18nとローカライゼーションデプロイ
フルスタック開発者でゼロから構築する アーキテクチャ概要から順に読み進める 日常的にクイックリファレンスカードを活用

Ctrl+F / Cmd+Fで特定のパターンや属性を検索できます。末尾のクイックリファレンスカードは、一覧性の高いサマリーとなっています。


ノービルド・テーゼ

このテーゼの主張は限定的かつ具体的です。コンテンツ駆動型サイトをソロ開発者や少人数チームで運営する場合、ビルドツールは存在しない問題を解決する一方で、新たな問題を生み出します。

以下はblakecrosley.comの実測値です。

指標 blakecrosley.com(ノービルド) 一般的なNext.jsプロジェクト3
依存関係 Pythonパッケージ15個 npm パッケージ311個以上
ビルド設定ファイル 0 5〜8(next.config、tsconfig、postcss、tailwindなど)
node_modules/サイズ 存在しない ベースライン187MB、追加で250〜400MB
インストール時間 pip install:8秒 npm install:30〜90秒
ビルドステップ なし next build:15〜60秒
デプロイパイプライン git push → 約40秒で公開 インストール → ビルド → デプロイ:2〜5分
Lighthouseパフォーマンス 100 明示的な最適化なしで70〜904

15個のPythonパッケージにはFastAPI、Jinja2、Pydantic、uvicorn、nh3、その他10個が含まれます。ビルドツールは1つもなく、コンパイラもバンドラーもありません。5

何を手放すのか

誠実さのために、実際のコストを挙げておきましょう。

TypeScriptがありません。 すべての.jsファイルはバニラJavaScriptです。型エラーはコンパイラではなく、テストとコード解析で検出します。ソロ開発者には問題ありませんが、10人のチームでコンポーネントインターフェースを共有する場合には向いていません。

ホットモジュールリプレースメントがありません。 CSSの変更にはブラウザの手動リフレッシュが必要です。HTMXのhx-boostによりナビゲーションは十分高速なのでフルリフレッシュも許容範囲ですが、ビジュアルの微調整を繰り返す場面ではHMRの方が効率的です。

ツリーシェイキングがありません。 記述したJavaScriptはすべてブラウザに送信されます。この制約が規律を生み出し、大きなユーティリティモジュールではなく、小さく焦点の絞られたファイルを書くようになります。

npmコンポーネントライブラリが使えません。 Radix、shadcn/ui、Headless UIは使用できません。すべてのインタラクティブ要素は手作りか、Bootstrap 5の組み込みコンポーネントで実装します。

npmからのデザインシステムトークンがありません。 デザインシステムはCSSカスタムプロパティとして定義されており、別プロジェクトにパッケージとしてインポートすることはできません。

これらのトレードオフは、1〜3人の開発者によるコンテンツ駆動型サイトでは許容範囲です。15人のエンジニアリングチームで開発するSaaS製品では受け入れられないでしょう。セクション15で意思決定フレームワークを提供しています。

何を得るのか

ビルド失敗がゼロになります。 ピア依存関係の競合でnpm installが失敗することも、触っていないファイルのTypeScriptエラーでnext buildが失敗することもありません。6

View Sourceでデバッグできます。 ブラウザで実行されるJavaScriptは、自分が書いたJavaScriptそのものです。ソースマップは不要です。

ローカル起動が一瞬です。 uvicorn app.main:app --reloadは2秒以内に起動します。

具体的なリクエストウォーターフォール。 初回訪問で読み込まれるのは、HTMLドキュメント1つ(gzip圧縮後約15KB)、CSSファイル1つ(約8KB)、HTMX(約14KB、キャッシュ済み)、Alpine.js(約14KB、キャッシュ済み)、ページのインタラクティブJS(約4〜8KB)です。初回訪問の合計は45〜60KBとなります。1

将来に強いフロントエンド。 クライアント側のコードはHTML、CSS、JavaScriptというWeb標準技術のみを使用しており、これらは30年にわたり後方互換性を維持してきました。7 Webpack 4→5の移行も、Create React Appの非推奨化も、Next.js App Routerへの移行も不要です。

スタック比較

ノービルドスタックと一般的な代替手段を、測定可能な観点で比較します。

観点 FastAPI+HTMX(本ガイド) Next.js(React) Astro 11ty
ブラウザに送信されるJS 32〜46KB(HTMX+Alpine) 85〜250KB以上(Reactランタイム) デフォルト0KB、オプトインアイランド デフォルト0KB
ビルドステップ なし 必須(webpack/turbopack) 必須(Vite) 必須(カスタム)
設定ファイル 0 5〜8(next.config、tsconfigなど) 1〜3(astro.config、tsconfig) 1〜2(.eleventy.js)
デプロイパイプライン git push(40秒) インストール+ビルド+デプロイ(2〜5分) インストール+ビルド+デプロイ(1〜3分) インストール+ビルド+デプロイ(1〜2分)
サーバー側インタラクティブ性 ネイティブ(HTMX) APIルート + クライアントfetch 限定的(フォームアクション) なし(静的出力)
クライアント状態管理 Alpine.js(15KB) React state/context/Redux フレームワークアイランド 手動JS
バックエンド言語 Python JavaScript/TypeScript JavaScript/TypeScript JavaScript
i18nアプローチ サーバーサイド(ミドルウェア) next-intlまたは類似パッケージ @astrojs/i18n 手動
Lighthouseパフォーマンス 100(実測値) 一般的に70〜904 一般的に95〜100 一般的に95〜100
最適な用途 コンテンツサイト、CRUD、ダッシュボード 複雑なSPA、大規模チーム コンテンツサイト、マーケティング 静的ブログ、ドキュメント

Astroと11tyは、コンテンツサイトにおいて最も近い競合です。どちらも優れた静的出力を生成しますが、ビルドステップとJavaScriptツールチェーンが必要です。FastAPI+HTMXスタックは、静的サイトのパフォーマンスと引き換えに、ビルドステップを追加することなくサーバーサイドのインタラクティブ性(カテゴリフィルタリング、フォーム処理、リアルタイム検索)を実現します。サーバーとのやり取りが一切不要な純粋な静的サイトであれば、AstroやI11tyの方が適しているかもしれません。


アーキテクチャ概要

リクエストフロー

すべてのリクエストは、4つのレイヤーを通る単一のパスに従います。

Browser                FastAPI                Jinja2              HTMX/Alpine
  |                      |                     |                     |
  |--- GET /about ------>|                     |                     |
  |                      |-- render template ->|                     |
  |                      |                     |-- base.html ------->|
  |                      |                     |   + about.html      |
  |                      |<-- full HTML -------|                     |
  |<--- HTML response ---|                     |                     |
  |                                                                  |
  |--- hx-get /search ------------------------------------------------>|
  |                      |<-- HTMX request ----|                     |
  |                      |-- render partial -->|                     |
  |                      |                     |-- _results.html     |
  |                      |<-- HTML fragment ---|                     |
  |<--- HTML fragment ---|                     |                     |
  |--- DOM swap -------------------------------------------------------->|

フルページロードでは完全な HTML ドキュメント(ベーステンプレート+ページテンプレート)を返します。HTMX リクエストは HTML フラグメント(パーシャル)を返します。サーバーがリクエストの種類に応じてレンダリング内容を決定する仕組みです。Alpine.js はサーバーに一切触れないクライアント側のみの状態を管理します。

コンポーネントの役割

コンポーネント 役割 スコープ
FastAPI ルーティング、ビジネスロジック、データアクセス、バリデーション サーバー
Jinja2 テンプレートレンダリング、継承、マクロ サーバー
HTMX サーバー駆動のインタラクティビティ(フォーム、ページネーション、検索) クライアント ↔ サーバー
Alpine.js クライアントのみの状態(ドロップダウン、モーダル、トグル) クライアントのみ
Bootstrap 5 グリッドシステム、ユーティリティクラス、レスポンシブレイアウト クライアント(CSS)
Plain CSS カスタムプロパティ、コンポーネントスタイル、デザイントークン クライアント(CSS)
Pydantic リクエスト/レスポンスのバリデーション、設定管理 サーバー

プロジェクト構成

app/
├── main.py              # FastAPI app, middleware, templates
├── config.py            # Pydantic settings management
├── routes/
│   ├── pages.py         # Page routes (HTML responses)
│   └── api.py           # API routes (JSON/HTML fragment responses)
├── content.py           # Markdown loading, blog post parsing
├── security/
│   ├── headers.py       # CSP, HSTS, security headers middleware
│   ├── csrf.py          # HMAC-signed CSRF tokens
│   ├── rate_limit.py    # 3-tier rate limiting
│   └── logging.py       # Security event logging
├── i18n/
│   ├── config.py        # Supported locales, mappings
│   ├── middleware.py     # URL-based locale detection
│   ├── jinja.py         # Translation functions for templates
│   └── d1_client.py     # Cloudflare D1 translation storage
├── cache_assets.py      # Content-hash asset versioning
└── templates/
    ├── base.html         # Base layout with Alpine.js state
    ├── components/       # Reusable partials (_language_switcher.html, etc.)
    └── pages/            # Page templates (home.html, about.html, etc.)

content/
├── blog/                # Markdown blog posts with YAML frontmatter
└── guides/              # Multi-section guide markdown

static/
├── css/                 # Plain CSS (no preprocessors)
├── js/                  # Vanilla JavaScript (no bundlers)
│   └── vendor/          # Self-hosted HTMX, Alpine.js
└── images/              # Optimized images with WebP srcset

この構成は一つの原則に従っています。各ディレクトリには1種類のものだけを配置するということです。ルートは routes/ に、テンプレートは templates/ に、静的アセットは static/ に格納されます。ビルドステップによって一方から他方へ変換されることはありません。

SPA アーキテクチャとの比較

React + Next.js プロジェクトでは、同等の構成は以下のようになります。

src/
├── components/       # React components (JSX)
├── pages/            # Route handlers (also JSX)
├── api/              # API routes (also in pages/)
├── hooks/            # Custom React hooks
├── context/          # React context providers
├── lib/              # Utility functions
├── styles/           # CSS modules or Tailwind config
└── types/            # TypeScript type definitions

# Plus build configuration
next.config.js
tsconfig.json
postcss.config.js
tailwind.config.js
eslint.config.js
package.json
package-lock.json
node_modules/         # 187+ MB of dependencies

SPA アーキテクチャでは、これらのディレクトリ間でビルド時の連携が必要になります。TypeScript が .tsx を JavaScript にコンパイルし、PostCSS が Tailwind ディレクティブを CSS に変換し、Webpack(または Turbopack)が出力をチャンクにバンドルします。各ステップは独立して失敗する可能性があります。

ノービルドアーキテクチャでは、こうした連携は一切不要です。テンプレートが CSS ファイルを参照し、その CSS ファイルが static/css/ に存在し、ブラウザが直接読み込みます。ファイル名を変更すると、テンプレートの参照がリクエスト時に壊れます——ビルド時ではありません。これはエラーの発生タイミングがコンパイル時から実行時へ移行するという、本質的なトレードオフです。uvicorn --reload で開発中のソロデベロッパーにとっては、実行時エラーはブラウザに即座に表示されます。一方、大規模チームでは、TypeScript がコンパイル時に検出するエラーは、実行時エラーでは防げないカテゴリのバグを未然に防ぎます。


FastAPI パターン

アプリケーションのセットアップ

アプリケーションは main.py で初期化され、ミドルウェアの順序を明示的に指定します。

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.gzip import GZipMiddleware

app = FastAPI(
    title="Blake Crosley",
    docs_url=None,     # Disable docs in production
    redoc_url=None,
    openapi_url=None,  # Prevent /openapi.json exposure
)

# Middleware order matters: last added = first executed
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(LocaleMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(SecurityLogMiddleware, site_name="blakecrosley.com")

# Static files
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

# Templates
templates = Jinja2Templates(directory=TEMPLATES_DIR)

ここでは3つの設計判断が重要です。まず、docs_url=Noneopenapi_url=None により、自動生成される API ドキュメントエンドポイントを無効化しています。公開向けのコンテンツサイトでは、/docs/openapi.json をインターネットに露出させる必要はありません。8 次に、ミドルウェアの順序が重要です。セキュリティログは最初に実行される(最後に追加される)ため、レート制限で拒否されたリクエストも含め、すべてのリクエストを記録できます。最後に、GZipMiddleware は500バイトを超えるすべてのレスポンスを圧縮し、HTML の転送サイズを通常70〜80%削減します。

ルーティング

ルートは2つのカテゴリに分かれます。ページルートは完全な HTML ドキュメントを返し、API ルートは JSON または HTML フラグメントを返します。

# routes/pages.py — full HTML responses
from fastapi import APIRouter, Request

router = APIRouter()

@router.get("/about")
async def about(request: Request):
    templates = request.app.state.templates
    return templates.TemplateResponse("pages/about.html", {
        "request": request,
        "page_title": "About — Blake Crosley",
        "page_description": "Designer, developer, dad.",
    })
# routes/api.py — JSON or HTML fragment responses
@router.get("/api/quiz/{quiz_id}/step")
async def quiz_step(request: Request, quiz_id: str, answers: str = ""):
    # Parse answers, compute next question or result
    question = get_next_question(quiz_id, answers)
    templates = request.app.state.templates
    return templates.TemplateResponse("components/_quiz_step.html", {
        "request": request,
        "question": question,
        "answers": answers,
        "step": len(answers.split(",")) if answers else 0,
    })

この区別は HTMX において重要です。フルページルートは base.html を継承するドキュメントを返します。API ルートは HTML フラグメントを返し、HTMX がそれを既存のDOM要素にスワップします。どちらも同じ Jinja2 テンプレートエンジンでレンダリングされ、別途 API レイヤーを用意する必要はありません。

依存性注入

FastAPI の Depends() システムにより、ルートハンドラーと共有ロジックを明確に分離できます。

from fastapi import Depends, Request

def get_templates(request: Request):
    """Get templates from app state."""
    return request.app.state.templates

def get_current_locale(request: Request) -> str:
    """Get locale from middleware-set request state."""
    return getattr(request.state, "locale", "en")

@router.get("/blog/{slug}")
async def blog_post(
    request: Request,
    slug: str,
    templates=Depends(get_templates),
    locale: str = Depends(get_current_locale),
):
    post = load_post_by_slug(slug)
    if not post:
        raise HTTPException(404, "Post not found")
    return templates.TemplateResponse("pages/blog/post.html", {
        "request": request,
        "post": post,
        "locale": locale,
    })

依存関係は合成可能です。get_db 依存関係が get_current_locale に依存し、それがリクエストに依存するといった構成が可能です。FastAPI はこのチェーンを自動的に解決します。

Pydantic 設定

設定には Pydantic の BaseSettings を使用し、環境変数が優先されます。

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    D1_WORKER_URL: str = ""
    D1_AUTH_SECRET: str = ""
    CLOUDFLARE_ACCOUNT_ID: str = ""
    CLOUDFLARE_API_TOKEN: str = ""
    ANALYTICS_PASSKEY: str = ""

    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"

settings = Settings()

環境変数は .env ファイルの値を上書きします。本番環境(Railway)ではシークレットを環境変数として設定し、ローカル開発では .env ファイルでデフォルト値を提供します。Settings クラスは起動時に型を検証するため、必須フィールドが不足している場合は実行時ではなく起動時に即座に失敗します。

非同期パターン

FastAPI のルートはデフォルトで非同期です。I/Oバウンドの操作(データベースクエリ、HTTPリクエスト、ファイル読み取り)では、非同期処理によりイベントループのブロックを防止できます。

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Load translations into memory cache at startup
    async with httpx.AsyncClient() as client:
        for locale in SUPPORTED_LOCALES:
            resp = await client.post(f"{D1_URL}/query", ...)
            TRANSLATIONS[locale] = resp.json()["results"]
    yield
    # Cleanup on shutdown (if needed)

app = FastAPI(lifespan=lifespan)

CPUバウンドの操作(Markdownレンダリング、CSS の抽出)には同期関数を使用できます。ルートハンドラーが async で宣言されていない場合、FastAPI は自動的にスレッドプールで実行します。

# Sync function — FastAPI runs it in a thread pool
@router.get("/blog/{slug}")
def blog_post(slug: str):
    post = load_post_by_slug(slug)  # CPU-bound Markdown parsing
    return templates.TemplateResponse(...)

基本ルールは次のとおりです。関数がI/Oを await する場合は async にし、CPU処理を行う場合は同期のままにします。同じ関数内で await とブロッキング呼び出しを混在させてはいけません。9


Jinja2 テンプレート

テンプレートの継承

Jinja2 の継承システムは、React のコンポーネント合成をよりシンプルなモデルで置き換えます。1つのベーステンプレートでページの骨格を定義し、子テンプレートが名前付きブロックを埋めていく仕組みです。

<!-- base.html — the skeleton -->
<!DOCTYPE html>
<html lang="{{ lang_attr() }}">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{ page_title | default("Blake Crosley") }}</title>
  <meta name="description" content="{{ page_description | default('...') }}">

  <!-- CSS — single file, no preprocessor -->
  <link rel="stylesheet" href="{{ asset('css/styles.css') }}">

  <!-- JSON-LD structured data -->
  <script type="application/ld+json">
  { "@context": "https://schema.org", "@graph": [...] }
  </script>

  {% block head %}{% endblock %}
</head>
<body>
  <header class="header">...</header>

  <main id="main" role="main">
    {% block content %}{% endblock %}
  </main>

  <footer class="footer">...</footer>

  <!-- Scripts deferred for performance -->
  <script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
  <script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
  <script defer src="{{ asset('js/main.js') }}"></script>

  {% block scripts %}{% endblock %}
</body>
</html>
<!-- pages/about.html — fills the blocks -->
{% extends "base.html" %}

{% block head %}
<script type="application/ld+json">
{ "@type": "AboutPage", "name": "About Blake Crosley", ... }
</script>
{% endblock %}

{% block content %}
<section class="hero">
  <h1>About</h1>
  <p>Designer, developer, dad.</p>
</section>
{% endblock %}

{% extends %} ディレクティブが親子関係を確立します。子テンプレートはオーバーライドしたいブロックだけを定義すればよく、それ以外の <head>、ヘッダー、フッター、スクリプトタグはすべてベースから引き継がれます。これは構築による合成ではなく、差分による合成です。

asset() グローバル関数

静的アセットはキャッシュバスティングのためにコンテンツハッシュによるバージョニングを使用します。

# cache_assets.py
def build_asset_map(static_dir: Path) -> dict[str, str]:
    """Compute MD5 hashes of all static files at startup."""
    asset_map = {}
    for filepath in static_dir.rglob("*"):
        if filepath.is_file():
            rel_path = str(filepath.relative_to(static_dir))
            content_hash = hashlib.md5(filepath.read_bytes()).hexdigest()[:10]
            asset_map[rel_path] = content_hash
    return asset_map

def make_asset_url(asset_map: dict, path: str) -> str:
    """Generate versioned URL: /static/css/styles.css?v=a3f8b2c1d0"""
    clean_path = path.lstrip("/")
    version = asset_map.get(clean_path, "0")
    return f"/static/{clean_path}?v={version}"

テンプレート内で {{ asset('css/styles.css') }} と書くと、/static/css/styles.css?v=a3f8b2c1d0 としてレンダリングされます。ファイルが変更されるとハッシュも変わり、CDN キャッシュが無効化されます。webpack の [contenthash] ファイル名戦略を、起動時に計算されるわずか30行の Python で置き換えているのです。

再利用可能なパーシャルのための Include

複数ページで繰り返し使うコンポーネントには {% include %} を使います。

<!-- base.html -->
{% include "components/_language_switcher.html" %}
<!-- components/_language_switcher.html -->
{%- set current = current_locale() -%}
{%- set locales = all_locales() -%}

<div class="language-switcher"
     x-data="{ open: false }"
     @click.away="open = false">
  <button @click="open = !open" :aria-expanded="open">
    {{ current_locale_native() }}
  </button>
  <ul class="language-switcher-menu"
      :class="{ 'is-open': open }"
      x-cloak>
    {% for locale in locales %}
    <li>
      <a href="{{ locale_url(request.url.path, locale.code) }}"
         hreflang="{{ locale.code }}">
        {{ locale.native }}
      </a>
    </li>
    {% endfor %}
  </ul>
</div>

アンダースコアプレフィックス(_language_switcher.html)は、パーシャル — 単独でレンダリングされることを意図しないテンプレートフラグメント — であることを示す命名規約です。このコンポーネントは Alpine.js(ドロップダウンの開閉用)と Jinja2(ロケールリスト用)の両方を使っています。責務の境界は明確で、Alpine.js が開閉状態を管理し、Jinja2 がデータを管理します。

再利用可能なコンポーネントのためのマクロ

マクロは Jinja2 の関数 — パラメータを持つ再利用可能なテンプレートブロックです。

<!-- components/_macros.html -->
{% macro card(title, description, href, badge=None) %}
<article class="card">
  <a href="{{ href }}" class="card__link">
    {% if badge %}
    <span class="card__badge">{{ badge }}</span>
    {% endif %}
    <h3 class="card__title">{{ title }}</h3>
    {% if description %}
    <p class="card__description">{{ description }}</p>
    {% endif %}
  </a>
</article>
{% endmacro %}

{% macro optimized_image(image_config, loading="lazy") %}
{% if image_config.get("svg") %}
  <img src="{{ image_config.svg }}"
       width="{{ image_config.width }}"
       height="{{ image_config.height }}"
       alt="{{ image_config.alt }}">
{% else %}
  <picture>
    <source type="image/webp"
            srcset="{{ image_config.webp_srcset }}"
            sizes="(max-width: 768px) 100vw, 50vw">
    <img src="{{ image_config.fallback }}"
         width="{{ image_config.width }}"
         height="{{ image_config.height }}"
         alt="{{ image_config.alt }}"
         loading="{{ loading }}">
  </picture>
{% endif %}
{% endmacro %}

ページテンプレートでマクロをインポートして使用します。

{% from "components/_macros.html" import card, optimized_image %}

<section class="projects">
  {% for project in projects %}
    {{ card(
      title=project.title,
      description=project.description,
      href=project.link,
      badge="New" if project.is_new else None
    ) }}
  {% endfor %}
</section>

マクロは表示パターンにおいて React コンポーネントの代わりとなります。パラメータを受け取り、デフォルト値をサポートし、他のマクロと組み合わせることもできます。違いは、マクロがサーバー上で一度だけレンダリングされて静的な HTML を生成するのに対し、React コンポーネントはクライアント上でレンダリングされ状態を保持する点です。コンテンツ表示には、マクロが適切な選択となります。

テンプレートコンテキストとグローバル関数

Jinja2 グローバルは、明示的に渡さなくてもすべてのテンプレートで利用可能な関数です。

# In main.py — register globals
templates.env.globals["asset"] = lambda path: make_asset_url(_asset_map, path)
templates.env.globals["csrf_token"] = generate_csrf_token
templates.env.globals["analytics_script"] = analytics.tracking_script

asset() グローバルはバージョン付き URL を生成します。csrf_token() グローバルは新しい CSRF トークンを生成します。analytics_script() グローバルはトラッキングスニペットを挿入します。これらの関数はルートハンドラーから明示的に渡さなくても、どのテンプレートからでも呼び出せます。

i18n の場合はより複雑になります — 翻訳関数が現在のリクエストのロケールにアクセスする必要があるためです。

# i18n/jinja.py
def setup_i18n_jinja(env):
    """Register translation functions as Jinja2 globals."""
    env.globals["_"] = get_translation        # _('ui.nav.about')
    env.globals["locale_prefix"] = get_locale_prefix  # '/ja' or ''
    env.globals["current_locale"] = get_current_locale
    env.globals["all_locales"] = get_all_locales
    env.globals["alternate_urls"] = get_alternate_urls
    env.globals["lang_attr"] = get_lang_attr  # 'ja' for HTML lang
    env.globals["og_locale"] = get_og_locale  # 'ja_JP' for og:locale
    env.globals["jsonld_lang"] = get_jsonld_lang  # 'ja-JP' for JSON-LD

各関数はロケールミドルウェアで設定されたリクエストコンテキスト変数からロケールを読み取ります。テンプレートで {{ _('ui.nav.about') }} と呼び出すと、明示的なロケールパラメータなしで現在のリクエストのロケールに対応する翻訳文字列が返されます。

条件付きブロック

Jinja2 のブロックシステムは条件付きオーバーライドをサポートしています。

<!-- base.html -->
{% block head %}{% endblock %}

<!-- pages/blog/post.html -->
{% block head %}
<script type="application/ld+json">
{
  "@type": "Article",
  "headline": "{{ post.meta.title }}",
  "author": { "@id": "https://blakecrosley.com/#person" },
  "datePublished": "{{ post.meta.date.isoformat() }}",
  "dateModified": "{{ post.meta.updated.isoformat() if post.meta.updated else post.meta.date.isoformat() }}"
}
</script>

{% if post.meta.scripts %}
{% for script in post.meta.scripts %}
<script defer src="{{ asset(script.lstrip('/static/')) }}"></script>
{% endfor %}
{% endif %}

{% if post.meta.styles %}
{% for style in post.meta.styles %}
<link rel="stylesheet" href="{{ asset(style.lstrip('/static/')) }}">
{% endfor %}
{% endif %}
{% endblock %}

ブログ記事は YAML フロントマター(scripts: ["/static/js/boids.js"])で依存関係を宣言し、テンプレートが条件に応じてそれらを読み込みます。追加のスクリプトやスタイルが不要なページにはそれらが配信されることはありません — デッドコードも未使用のインポートもありません。

カスタムフィルター

Jinja2 フィルターはレンダリング時にデータを変換します。sanitize フィルターはユーザー生成コンテンツの XSS を防止します。

import nh3

ALLOWED_TAGS = {"a", "b", "blockquote", "br", "code", "em", "h1", "h2",
                "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "ol",
                "p", "pre", "span", "strong", "table", "td", "th", "tr", "ul"}

def sanitize_html(value: str) -> str:
    """Sanitize HTML to prevent XSS attacks."""
    if not value:
        return ""
    return nh3.clean(
        value,
        tags=ALLOWED_TAGS,
        attributes={"a": {"href", "title"}, "img": {"src", "alt"}},
        link_rel="noopener noreferrer",
    )

templates.env.filters["sanitize"] = sanitize_html

テンプレートでは {{ user_content | sanitize }} のように使います。nh3 ライブラリは Rust ベースの HTML サニタイザーで、高速かつ安全です。許可リストにないタグや属性をすべて除去し、信頼できないソースからのコンテンツであっても格納型 XSS を防止します。10


HTMX ディープダイブ

HTMXを使うと、あらゆるHTML要素がHTTPリクエストを発行し、レスポンスをDOMにスワップできるようになります。ここで重要なのはアーキテクチャ上の考え方です。サーバーレンダリングされたHTMLがAPIそのものとなります。サーバーが最終的な表現を返すため、クライアントサイドレンダリングもJSONシリアライゼーションもハイドレーションも不要です。

コア属性

属性 用途
hx-get GETリクエストを発行 hx-get="/search?q=term"
hx-post POSTリクエストを発行 hx-post="/contact"
hx-target レスポンスの配置先 hx-target="#results"
hx-swap レスポンスの挿入方法 hx-swap="innerHTML"(デフォルト)、outerHTMLbeforeend
hx-trigger リクエストのトリガー hx-trigger="click"keyup changed delay:300msload
hx-indicator リクエスト中に表示する要素 hx-indicator="#spinner"
hx-push-url ブラウザURLを更新 hx-push-url="true"
hx-replace-url 履歴エントリなしでURLを置換 hx-replace-url="true"

パターン1:インタラクティブクイズ(マルチステップサーバーステート)

blakecrosley.comには、ツール選択をガイドするインタラクティブなクイズがあります。クイズの状態はすべてサーバー側で管理されており、クライアントサイドの状態管理は一切ありません。

<!-- _quiz_container.html — initial load -->
<div hx-get="/api/quiz/claude-vs-codex/step?answers="
     hx-trigger="load"
     hx-swap="innerHTML"
     id="quiz-wrapper">
  <p>Loading quiz...</p>
</div>
<!-- _quiz_step.html — each question -->
<div class="quiz-step" id="quiz-container">
  <p>Question {{ step }} of {{ total }}</p>
  <h3>{{ question.question }}</h3>
  <div class="quiz-step__options">
    {% for opt in question.options %}
    <button class="quiz-step__btn"
            hx-get="/api/quiz/claude-vs-codex/step?answers={{ answers }},{{ opt.value }}"
            hx-target="#quiz-container"
            hx-swap="outerHTML">
      {{ opt.label }}
    </button>
    {% endfor %}
  </div>
</div>

各ボタンをクリックすると、蓄積された回答がクエリパラメータとして送信されます。サーバーは回答履歴に基づいて、次の質問または最終結果を算出します。状態はURLに蓄積されるため、Cookie、セッション、クライアントサイドJavaScriptは一切不要です。クイズはouterHTMLスワップで進行し、各レスポンスがクイズステップ要素全体を置き換えます。

パターン2:ページネーション付きブログ一覧

ライティングページでは、URLを更新しながらシームレスにページネーションするためにHTMXを使用しています。

<!-- Pagination link -->
<a href="/writing?page=2&category=Engineering"
   hx-get="/writing?page=2&category=Engineering"
   hx-target="#writing-content"
   hx-swap="innerHTML"
   hx-replace-url="true"
   hx-indicator="#writing-loading"
   aria-label="Go to page 2">
  2
</a>

4つの属性が連携して動作します。

  1. hx-gethrefと同じURLにリクエストを発行します(プログレッシブエンハンスメント——JavaScriptなしでも動作します)
  2. hx-targetはレスポンスを#writing-contentコンテナに配置します
  3. hx-replace-url="true"は履歴エントリを追加せずにブラウザURLを更新します
  4. hx-indicatorはリクエスト中にローディングスピナーを表示します

サーバーはHX-RequestヘッダーでHTMXリクエストを検出し、フルページの代わりに投稿リストのフラグメントのみを返します。セキュリティヘッダーミドルウェアがVary: HX-Requestを追加するのはこのためです。CDNキャッシュがフルページとフラグメントを別々に保存できるようにしています。11

パターン3:デバウンス付き検索

<input type="search" name="q"
       hx-get="/api/search"
       hx-trigger="keyup changed delay:300ms"
       hx-target="#results"
       hx-indicator="#search-spinner" />
<div id="results"></div>

hx-trigger属性は3つの修飾子を組み合わせています。

  • keyupはキーを離した時に発火します
  • changedは値が実際に変更された場合のみ発火します(修飾キーによる重複リクエストを防止)
  • delay:300msはデバウンス処理で、最後のkeyupから300ms待ってから発火します

サーバーはレンダリング済みのHTMLフラグメントを返します。

@router.get("/api/search")
async def search(request: Request, q: str = ""):
    results = search_content(q)
    return templates.TemplateResponse("components/_search_results.html", {
        "request": request,
        "results": results,
        "query": q,
    })

クライアントサイドの状態管理もデバウンスライブラリもuseEffectも不要です。テンプレートが結果をレンダリングし、HTMXがそれをスワップし、サーバーが唯一の信頼できるソースとなります。

パターン4:Out-of-Band(OOB)スワップ

単一のサーバーアクションで複数のDOM要素を更新する必要がある場合があります。HTMXのOut-of-Bandスワップメカニズムを使えば、クライアントサイドのオーケストレーションなしで実現できます。

<!-- Server returns multiple elements in one response -->
<!-- Primary target: swapped normally via hx-target -->
<div id="cart-items">
  <ul>
    <li>Widget A — $29.99</li>
    <li>Widget B — $14.99</li>
  </ul>
</div>

<!-- OOB target: swapped independently via hx-swap-oob -->
<span id="cart-count" hx-swap-oob="true">2 items</span>
<span id="cart-total" hx-swap-oob="true">$44.98</span>

hx-swap-oob="true"属性は、HTMXに対してhx-targetに関係なく、DOM内の任意の場所でidにより要素を検索して置換するよう指示します。これはReactの「stateを上位に持ち上げる」パターンの代替です。サーバーがすべての派生状態を計算し、各要素の最終HTMLを単一のレスポンスで返します。

コンタクトフォームはこのパターンの良い例です。フォームを送信すると、フォーム本体をサクセスメッセージに置き換えると同時に、OOBスワップで通知バッジを更新できます。

パターン5:ブーストリンク

HTMXは標準的なナビゲーションリンクを「ブースト」して、フルページロードの代わりにAJAXを使用するようにできます。

<nav hx-boost="true">
  <a href="/about">About</a>
  <a href="/writing">Writing</a>
  <a href="/guides">Guides</a>
</nav>

hx-boost="true"を指定すると、リンクをクリックした際にAJAXでページを取得し、<body>のコンテンツをスワップしてURLを更新します。フルページリロードは発生しません。ブラウザの履歴は通常通り動作します(戻る/進むボタンも有効)。JavaScriptが読み込めない場合、リンクは通常のナビゲーションとして機能します。

体感パフォーマンスの向上がメリットです。ブーストされたナビゲーションは瞬時に感じられます。ブラウザがCSSを再パースしたり、スクリプトを再評価したり、レイアウトを再レンダリングする必要がないためです。変更されるのは<body>のコンテンツのみです。ブーストリンクはメインナビゲーション要素に適しており、SPAアーキテクチャなしでシングルページアプリケーションのようなページ遷移を実現できます。

パターン6:HTMXリクエストヘッダー

HTMXはすべてのリクエストにカスタムヘッダーを送信します。

ヘッダー 用途
HX-Request true サーバーサイドでHTMXリクエストを検出
HX-Target 要素ID レスポンスを受け取る要素を特定
HX-Trigger 要素ID リクエストをトリガーした要素を特定
HX-Current-URL フルURL ユーザーの現在のページを特定

サーバーはHX-Requestを使って異なるレスポンスを返すことができます。

@router.get("/writing")
async def writing(request: Request, page: int = 1, category: str = None):
    posts = load_all_posts(page=page, category=category)
    context = {"request": request, "posts": posts, "current_page": page}

    # HTMX request: return only the post list fragment
    if request.headers.get("HX-Request"):
        return templates.TemplateResponse(
            "pages/writing/_post_list.html", context
        )

    # Normal request: return the full page
    return templates.TemplateResponse("pages/writing/index.html", context)

このデュアルレスポンスパターンがアーキテクチャの中核です。フルページロードでは完全なドキュメント(ベーステンプレート+ページコンテンツ)を返します。HTMXナビゲーションでは変更されたコンテンツのみを返します。何を返すかはクライアントではなくサーバーが決定します。

パターン7:プログレッシブエンハンスメント

blakecrosley.comのすべてのHTMXリンクには標準のhref属性が含まれています。

<a href="/writing?page=2"
   hx-get="/writing?page=2"
   hx-target="#writing-content"
   hx-swap="innerHTML">
  Next Page
</a>

JavaScriptの読み込みに失敗した場合、hrefが通常のリンクとして機能します。HTMXが読み込まれると、クリックをインターセプトしてAJAXスワップを実行します。これがプログレッシブエンハンスメントです。サイトはJavaScriptなしでも動作し、HTMXが利用可能な場合にエクスペリエンスを強化します。

パターン8:ローディングステート

<button hx-post="/api/contact"
        hx-target="#form-result"
        hx-indicator="#submit-spinner">
  <span id="submit-spinner" class="htmx-indicator">Sending...</span>
  <span>Send Message</span>
</button>

HTMXはリクエスト中にトリガー要素にhtmx-requestクラスを追加します。hx-indicator属性はリクエスト中に表示される要素を指定します。CSSでスタイリングしましょう。

.htmx-indicator {
  display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  display: inline;
}

ローディングステートの管理は不要です。useState(false)setLoading(true)も必要ありません。CSSが表示・非表示を制御し、HTMXがクラスのトグルを担当します。


Alpine.js パターン

Alpine.js は、HTMX がカバーしない領域を補完します。サーバーとの通信が一切不要な、クライアント側のみの状態管理です。ドロップダウンをクリックして開く——この状態はブラウザ内にしか存在しません。Alpine.js は HTML 属性でこれを管理します。

境界ルール

HTMX と Alpine.js の境界は明確です:

状態の種類 ツール
サーバーデータが必要 HTMX 検索結果、フォームバリデーション、ページネーション
ブラウザ内のみに存在 Alpine.js ドロップダウンの開閉、モバイルメニューの切り替え、モーダルの表示制御
両方を組み合わせる 両方 言語切り替え(Alpine.js によるトグル、HTMX 的なナビゲーション)

モバイルナビゲーション

ベーステンプレートでは、ヘッダー全体を Alpine.js コンポーネントでラップしています:

<div x-data="{ navOpen: false, langOpen: false }"
     @keydown.escape.window="navOpen = false; langOpen = false">

  <!-- Mobile hamburger button -->
  <button @click="navOpen = !navOpen; langOpen = false"
          :aria-expanded="navOpen"
          :class="navOpen ? 'nav__toggle is-open' : 'nav__toggle'"
          aria-label="Toggle navigation">
    <span class="nav__toggle-icon">
      <span class="nav__toggle-bar"></span>
      <span class="nav__toggle-bar"></span>
      <span class="nav__toggle-bar"></span>
    </span>
  </button>

  <!-- Mobile menu panel -->
  <div class="mobile-menu" x-show="navOpen" x-cloak>
    <nav class="mobile-menu__nav">
      <a href="/about" @click="navOpen = false">About</a>
      <a href="/#work" @click="navOpen = false">Work</a>
      <a href="/writing" @click="navOpen = false">Writing</a>
    </nav>
  </div>
</div>

主要な Alpine.js パターン:

  • x-data はコンポーネントのスコープと初期状態を宣言します
  • x-show は状態に基づいて表示・非表示を切り替えます(CSS の display: none を使用)
  • x-cloak は Alpine.js の初期化完了まで要素を隠します(スタイル未適用コンテンツのちらつきを防止)
  • @click は式を使ってクリックハンドラーをバインドします
  • :aria-expandedx-bind:aria-expanded の省略形)は属性を動的に設定します
  • @keydown.escape.window はグローバルに Escape キーを監視し、パネルを閉じます

ドロップダウンコンポーネント

言語切り替えでは、Alpine.js でトグル状態を管理し、@click.away で外側クリック時に閉じる動作を実現しています:

<div x-data="{ open: false }"
     @click.away="open = false"
     @keydown.escape.window="open = false">

  <button @click="open = !open"
          :aria-expanded="open"
          aria-haspopup="listbox">
    English
    <svg :class="{ 'rotated': open }">...</svg>
  </button>

  <ul :class="{ 'is-open': open }"
      :aria-hidden="!open"
      role="listbox"
      x-cloak>
    <li role="option">
      <a href="/ja/about">日本語</a>
    </li>
    <!-- more languages -->
  </ul>
</div>

@click.away 修飾子は、外側をクリックした際にドロップダウンを閉じます。Alpine.js なら属性一つで完結し、イベントリスナーの登録もクリーンアップも ref の管理も不要です。

Alpine.js と素の JavaScript の使い分け

Alpine.js が適しているケース:

  • 状態が単一の DOM 要素にスコープされている場合(ドロップダウン、モーダル、トグル)
  • インタラクションが二値またはシンプルな場合(開閉、表示切替、トグル)
  • 複数の要素が同じ状態変化に反応する必要がある場合
  • アクセシビリティ属性を表示状態と同期させる必要がある場合

素の JavaScript が適しているケース:

  • 複雑な計算を伴うインタラクションの場合(ビジュアライゼーション、シミュレーション)
  • コンポーネントが独自の描画ループを持つ場合(canvas、アニメーション)
  • パフォーマンスが重要な場合(Alpine.js は x-data コンポーネントごとにオーバーヘッドが発生)
  • ロジックが Alpine.js の式で20〜30行を超える場合

blakecrosley.com では、ナビゲーション、言語切り替え、コンテンツトグルに Alpine.js を使用しています。20個のインタラクティブなブログコンポーネント(boids シミュレーション、ハミング符号ビジュアライザーなど)は、canvas レンダリングと複雑な状態マシンが必要なため、素の JavaScript を使用しています。


エンドツーエンドの実例:/writing のカテゴリフィルタリング

このセクションでは、本番コードベースの実際の機能を、ルート、テンプレート、HTMX インタラクション、セキュリティ、キャッシュ、レンダリング結果まで、すべてのレイヤーを通して追跡します。対象の機能は、writing ページのカテゴリタブで、ページ全体をリロードせずにブログ記事をフィルタリングするものです。

ルート(app/routes/pages.py:508

async def writing_listing(request: Request, page: int = 1, category: str | None = None):
    """Writing page — blog posts and external publications."""
    templates = get_templates(request)
    markdown_posts = load_all_posts(published_only=True)
    all_posts = CUSTOM_BLOG_POSTS + markdown_posts

    # Filter by category if specified
    if category and category in CATEGORY_MAP:
        display_name = CATEGORY_MAP[category]
        all_posts = [
            p for p in all_posts
            if _get_post_category(p).lower() == display_name.lower()
        ]

    # Pagination
    total_pages = max(1, (len(all_posts) + POSTS_PER_PAGE - 1) // POSTS_PER_PAGE)
    page = max(1, min(page, total_pages))
    paginated = all_posts[(page - 1) * POSTS_PER_PAGE : page * POSTS_PER_PAGE]

    template_context = {
        "request": request,
        "posts": paginated,
        "categories": categories,
        "current_category": category,
        "current_page": page,
        "total_pages": total_pages,
        # ... SEO: canonical, prev/next URLs
    }

    # HTMX partial: return just the post list fragment
    if request.headers.get("HX-Request"):
        return templates.TemplateResponse(
            "pages/writing/_post_list.html",
            template_context,
        )

    # Full page for direct navigation
    return templates.TemplateResponse(
        "pages/writing/index.html",
        template_context,
    )

HX-Request ヘッダーのチェックがコアパターンです。同じルート、同じデータ、異なるテンプレート。HTMX にはフラグメントを、ブラウザにはフルページを返します。

カテゴリタブ(HTMX)

<!-- Category filter tabs -->
<nav class="writing-categories">
  <a href="/writing"
     hx-get="/writing"
     hx-target="#post-list"
     hx-push-url="true"
     class="category-tab {% if not current_category %}active{% endif %}">
    All ({{ total_posts }})
  </a>
  {% for cat in categories %}
  <a href="/writing?category={{ cat.slug }}"
     hx-get="/writing?category={{ cat.slug }}"
     hx-target="#post-list"
     hx-push-url="true"
     class="category-tab {% if current_category == cat.slug %}active{% endif %}">
    {{ cat.name }} ({{ cat.count }})
  </a>
  {% endfor %}
</nav>

<div id="post-list">
  {% include "pages/writing/_post_list.html" %}
</div>

各タブには href(JavaScript なしでも動作)と hx-get(記事リストのみをスワップ)の両方があります。hx-push-url によりブラウザの URL が更新されるため、フィルタリングされたビューの共有やブックマークが可能です。

パーシャル(pages/writing/_post_list.html

パーシャルは、ページ読み込み時のインクルードでも HTMX によるスワップでも、同一のレンダリング結果を返します:

{% for post in posts %}
<article class="post-card">
  <a href="{{ locale_prefix() }}/blog/{{ post.meta.slug }}">
    <h3>{{ post.meta.title }}</h3>
    <p>{{ post.meta.description }}</p>
    <time>{{ post.meta.date }}</time> · {{ post.reading_time }}m
  </a>
</article>
{% endfor %}

パーシャル内に HTMX 固有のマークアップはありません。クライアント側のレンダリングロジックも不要です。同じ HTML が初回ページ読み込みとその後のフィルタリングの両方で機能します。

セキュリティ

カテゴリの値はフィルタリング前に CATEGORY_MAP(サーバー側の辞書)に対してバリデーションされます。無効なカテゴリは無視され、エコーバックされません。ユーザー入力が SQL や HTML に補間されることはありません。CSP ヘッダーがインラインスクリプトをブロックします。

キャッシュ

カテゴリレスポンスは動的であり、CDN キャッシュは使用しません。一方、静的アセット(CSS、HTMX、Alpine.js)はコンテンツハッシュ付きで、初回読み込み後は無期限にキャッシュされます。以降のカテゴリ切り替えでは HTML パーシャル(約3〜5KB)のみが転送され、CSS、JS、画像の再取得は発生しません。

この実例が示すもの

一つの機能、本番コード、ビルドツールゼロ。サーバーが HTML をフィルタリングしてレンダリングし、HTMX が記事リストをスワップします。Alpine.js は関与しません(クライアント状態が不要なため)。URL は共有可能性のために更新されます。プログレッシブエンハンスメント:タブは JavaScript なしでも通常のリンクとして機能します。この機能のためのカスタム JavaScript:ゼロ行。


オプション拡張

以下のセクションでは、コアスタックを補完するパターンを紹介しますが、blakecrosley.com では使用していません。このアーキテクチャを採用するチームが最も頻繁に追加するパターンであるため、参考として掲載しています。


Sassなしで使うBootstrap 5

注記: blakecrosley.comではBootstrapを使わず、カスタムプロパティを活用したプレーンなCSSを採用しています。このセクションでは、ビルドステップなしでユーティリティフレームワークを導入したいチーム向けに、Bootstrap 5の活用方法を紹介します。Bootstrapのコンパイル済みCSSはCDNから読み込むことも、スタイルシートにバンドルすることも可能です。以下のパターンは汎用的なもので、前のセクションで解説したHTMX + Alpine.jsのアプローチと併用できます。

Bootstrap 5ではjQueryへの依存が廃止され、スタンドアロンでのCSS利用がサポートされています。グリッドシステムやユーティリティクラスを使うために、SassやPostCSS、その他のビルドツールは一切不要です。

CDN不要のセルフホスティング

blakecrosley.comではすべてのベンダーライブラリをセルフホスティングしています:

<!-- base.html — no CDN, no external requests -->
<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>

セルフホスティングにより外部依存を排除し、CDN障害によるサイトダウンを防ぎ、コンテンツハッシュURLによるイミュータブルキャッシュが可能になります。Bootstrapのコンパイル済みCSS(Sassソースではなく)をダウンロードし、static/css/vendor/に配置してください。

グリッドシステム

BootstrapのグリッドはプレーンなHTMLクラスで動作します:

<div class="container">
  <div class="row">
    <div class="col-12 col-md-8">
      <article>Main content</article>
    </div>
    <div class="col-12 col-md-4">
      <aside>Sidebar</aside>
    </div>
  </div>
</div>

Sassミックスインは不要です。@include make-col()も必要ありません。コンパイル済みCSSにレスポンシブグリッドクラスがすべて含まれています。Bootstrapのデフォルトを超えるカスタムブレークポイントが必要な場合は、プレーンなCSSメディアクエリを記述しましょう。

プレーンCSSによるオーバーライド

CSSカスタムプロパティと標準セレクターを使って、Bootstrapのデフォルトをオーバーライドできます:

/* Custom design tokens — no Sass, no Tailwind */
:root {
  --color-bg-dark:        #000000;
  --color-text-primary:   #ffffff;
  --color-text-secondary: rgba(255, 255, 255, 0.65);
  --color-text-tertiary:  rgba(255, 255, 255, 0.40);
  --spacing-sm:           1rem;
  --spacing-md:           1.5rem;
  --spacing-lg:           2rem;
  --gutter:               48px;
  --font-size-lg:         1.25rem;
}

/* Responsive override — the browser reads this at runtime */
@media (max-width: 768px) {
  :root {
    --gutter: var(--spacing-md);  /* 48px → 24px on mobile */
  }
}

/* Override Bootstrap's default body styles */
body {
  background: var(--color-bg-dark);
  color: var(--color-text-primary);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}

CSSカスタムプロパティはDOM内をカスケードし、親要素から継承され、実行時にメディアクエリに応答します。一方、Sass変数はコンパイル時に静的な値へ変換され、消失します。この違いはテーマ設計において重要です。カスタムプロパティを1つ変更するだけで、再コンパイルなしにすべての派生値を更新できるのです。12

ユーティリティクラスとコンポーネントCSSの使い分け

一回限りのスペーシングやレイアウトにはBootstrapのユーティリティクラスを、繰り返し使うパターンにはコンポーネントCSSを使いましょう:

<!-- Bootstrap utility for one-off spacing -->
<div class="mt-4 mb-3 px-2">One-off layout</div>

<!-- Component class for repeated patterns -->
<article class="writing__item">
  <h3 class="writing__item-title">Post Title</h3>
  <p class="writing__item-description">Description</p>
</article>
/* Component CSS — BEM naming, reusable */
.writing__item {
  padding: var(--spacing-md);
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  transition: background 0.15s ease;
}
.writing__item:hover {
  background: rgba(255, 255, 255, 0.03);
}
.writing__item-title {
  font-size: var(--font-size-lg);
  margin-bottom: 0.5rem;
}

基本原則は次のとおりです。レイアウトの仕組み(マージン、パディング、フレックスボックス)にはBootstrapユーティリティを使い、ビジュアルアイデンティティ(カラー、タイポグラフィ、アニメーション)にはカスタムCSSを使います。同じ関心事に対してユーティリティクラスとコンポーネントスタイルを混在させてはいけません。


i18nとローカライゼーション

blakecrosley.comは10言語でコンテンツを提供しています:英語、日本語、韓国語、簡体字中国語、繁体字中国語、ドイツ語、フランス語、スペイン語、ポーランド語、ポルトガル語(ブラジル)。

URLベースのロケールルーティング

ロケールはURLパスに含まれます:/about(英語)、/ja/about(日本語)、/zh-Hans/about(簡体字中国語)。英語がデフォルトで、プレフィックスは付きません。

# i18n/config.py
SUPPORTED_LOCALES = [
    "en", "zh-Hans", "zh-Hant", "fr", "de", "ja", "ko", "pl", "pt-BR", "es"
]
DEFAULT_LOCALE = "en"

ロケールミドルウェアがURLパスからロケールを抽出します:

# i18n/middleware.py
class LocaleMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        path = request.url.path
        # Check if path starts with a supported locale
        for locale in SUPPORTED_LOCALES:
            if path.startswith(f"/{locale}/") or path == f"/{locale}":
                request.state.locale = locale
                # Strip locale prefix for route matching
                request.scope["path"] = path[len(f"/{locale}"):]
                break
        else:
            request.state.locale = DEFAULT_LOCALE

        response = await call_next(request)
        return response

ミドルウェアはルートマッチングの前にロケールプレフィックスを除去します。そのため、ルートハンドラにロケール固有のパスを定義する必要はありません。/aboutは英語(/about)と日本語(/ja/about)の両方を処理できます。ミドルウェアがパスを正規化するためです。

テンプレートでの翻訳関数

Jinja2のグローバル変数が翻訳関数を提供します:

<!-- Template usage -->
<h3>{{ _('ui.footer.navigate') | default('Navigate') }}</h3>
<a href="{{ locale_prefix() }}/about">
  {{ _('ui.nav.about') | default('About') }}
</a>

_()関数はメモリキャッシュから翻訳キーを検索します。| default()フィルターは翻訳が見つからない場合に英語のフォールバックを提供します。locale_prefix()関数は現在のロケールのURLプレフィックスを返します(英語は""、日本語は"/ja")。

hreflangタグ

すべてのページに、サポートされている全ロケールのhreflangタグが含まれます:

<!-- Generated in base.html -->
{% for alt in alternate_urls(request.url.path) %}
<link rel="alternate" hreflang="{{ alt.hreflang }}" href="{{ alt.url }}">
{% endfor %}

生成される出力は以下のとおりです:

<link rel="alternate" hreflang="en" href="https://blakecrosley.com/about">
<link rel="alternate" hreflang="ja" href="https://blakecrosley.com/ja/about">
<link rel="alternate" hreflang="zh-Hans" href="https://blakecrosley.com/zh-Hans/about">
<!-- ... all 10 locales -->
<link rel="alternate" hreflang="x-default" href="https://blakecrosley.com/about">

検索エンジンはhreflangを使用して、検索結果に適切な言語バージョンを表示します。x-defaultエントリはフォールバックとして英語バージョンを指定しています。13

翻訳ストレージとメモリキャッシュ

翻訳データはCloudflare D1(エッジのSQLite)に保存され、lifespanハンドラを通じてインメモリキャッシュに読み込まれます:

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Load translations into memory at startup
    for locale in SUPPORTED_LOCALES:
        data = await fetch_translations(locale)
        TRANSLATIONS[locale] = data
    yield

app = FastAPI(lifespan=lifespan)

メモリキャッシュにより、ページレンダリングのたびにデータベースへクエリを発行する必要がなくなります。翻訳の更新にはキャッシュのリフレッシュが必要です(管理エンドポイントまたはデプロイによってトリガーされます)。このアーキテクチャはパフォーマンスを優先し、鮮度とのトレードオフを取っています。翻訳が変更される頻度は低いものの、ページレンダリングはリクエストのたびに発生するためです。

ヘルスモニタリング

blakecrosley.comにはi18nヘルスチェックエンドポイントがあり、ロケールごとの翻訳カバレッジを監視しています:

@app.get("/health/i18n")
async def health_i18n():
    cache = get_translation_cache()
    result = {
        "status": "healthy",
        "cache_loaded": cache.is_loaded,
        "locales": {},
        "alerts": [],
    }

    # Check coverage for each locale
    for locale in SUPPORTED_LOCALES:
        coverage = await calculate_coverage(locale, en_count)
        result["locales"][locale] = {"coverage": round(coverage, 2)}

        if coverage < 99.5:
            result["alerts"].append(
                f"{locale}: {coverage:.1f}% coverage (threshold: 99.5%)"
            )
            result["status"] = "warning"

    return result

99.5%のカバレッジ閾値により、未翻訳の文字列にユーザーが遭遇する前に検出できます。このヘルスエンドポイントはRailwayのモニタリングと連携し、カバレッジが低下した際にアラートを発します。たとえば、まだ翻訳されていない新しいUI文字列を追加した場合などです。

ロケール対応コンテンツレンダリング

ブログ記事やガイドは、メタデータとコンテンツのロケール別翻訳に対応しています:

# In route handler
translated = get_blog_translation(post.meta.slug, locale)
return templates.TemplateResponse("pages/blog/post.html", {
    "request": request,
    "post": post,
    "translated_title": translated.title if translated else post.meta.title,
    "translated_description": translated.description if translated else post.meta.description,
})
<!-- In template -->
<h1>{{ translated_title }}</h1>
<p class="post__description">{{ translated_description }}</p>
<!-- Body content falls back to English if translation unavailable -->
{{ post.html | sanitize | safe }}

パターンは一貫しています:まず翻訳されたコンテンツを試み、なければ英語にフォールバックします。これにより部分的な翻訳が可能になります。日本語ユーザーは、記事本文が英語のままでも、翻訳されたタイトルと説明文を見ることができます。| default()のJinja2フィルターは、このパターンを1つのパイプで表現しています:

{{ translated.title if translated else post.meta.title }}

ロケールデータの翻訳

プロジェクトの説明文やナビゲーションラベルなどの静的コンテンツは、同じデータ構造を維持しながらロケール固有の文字列に差し替えるヘルパー関数を通じて翻訳されます:

# i18n/data.py
def translate_projects(projects: list, locale: str) -> list:
    """Return projects with translated titles and descriptions."""
    if locale == "en":
        return projects
    translated = []
    for project in projects:
        t = get_translation(f"project.{project['slug']}.title", locale)
        d = get_translation(f"project.{project['slug']}.description", locale)
        translated.append({
            **project,
            "title": t or project["title"],
            "description": d or project["description"],
        })
    return translated

このアプローチにより、翻訳レイヤーとデータレイヤーが分離されます。ルートはロケールに関係なく同じprojectsリストを渡し、翻訳関数がデータを透過的にラップします。

hreflang付きサイトマップ

動的サイトマップには、すべてのロケールの全ページとその相互参照が含まれます:

@app.get("/sitemap.xml")
async def sitemap():
    for page in static_pages:
        for locale in SUPPORTED_LOCALES:
            # Each URL entry includes alternates for all locales
            locale_path = f"/{locale}{path}" if locale != "en" else path
            xml_parts.append(f"<loc>{base_url}{locale_path}</loc>")
            # Add xhtml:link alternates
            for alt_locale in SUPPORTED_LOCALES:
                alt_path = f"/{alt_locale}{path}" if alt_locale != "en" else path
                hreflang = LOCALE_TO_HREFLANG[alt_locale]
                xml_parts.append(
                    f'<xhtml:link rel="alternate" hreflang="{hreflang}" '
                    f'href="{base_url}{alt_path}"/>'
                )

1ページあたり10個のURLエントリ(ロケールごとに1つ)が生成され、それぞれに11個の代替リンク(10ロケール + x-default)が付きます。50ページのサイトでは、サイトマップには500のURLエントリと5,500のhreflangリンクが含まれることになります。サイトマップは動的に生成され、1時間キャッシュされます。


データベースパターン

注記: blakecrosley.comでは永続データの管理にCloudflare D1(サーバーレスSQLite)をHTTP経由で使用しており、SQLAlchemyは使用していません。このセクションでは、リレーショナルデータベースを必要とするFastAPIプロジェクト向けの標準的なSQLAlchemy非同期パターンを解説します。このスタックで最も一般的な本番構成です。

SQLAlchemy 2.0 Async

リレーショナルデータベースが必要なアプリケーションでは、SQLAlchemy 2.0の非同期サポートがFastAPIとスムーズに統合できます:

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, DeclarativeBase

engine = create_async_engine("sqlite+aiosqlite:///./data.db")
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

class Base(DeclarativeBase):
    pass

データベースセッションの依存性注入

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession

async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with async_session() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

@router.get("/users/{user_id}")
async def get_user(request: Request, user_id: int, db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(User).where(User.id == user_id))
    user = result.scalar_one_or_none()
    if not user:
        raise HTTPException(404, "User not found")
    return templates.TemplateResponse("pages/user.html", {
        "request": request, "user": user
    })

get_db依存関数がセッションのライフサイクルを管理します。セッションを開き、ルートハンドラーに渡し、成功時にコミット、例外時にロールバックするという流れです。すべてのデータベース操作ではパラメータ化クエリを使用し、文字列補間は一切行いません。

Pydanticとの統合

PydanticモデルはAPI境界で入力を検証し、テンプレート向けに出力をシリアライズします:

from pydantic import BaseModel, EmailStr

class ContactForm(BaseModel):
    name: str
    email: EmailStr
    message: str

@router.post("/contact")
async def submit_contact(request: Request, form: ContactForm):
    # form.name, form.email, form.message are validated
    await send_email(form)
    return templates.TemplateResponse("components/_contact_success.html", {
        "request": request
    })

Pydanticは型、フォーマット(メール、URL)、制約(最小/最大長)をルートハンドラーの実行前に検証します。無効な入力には自動的に422レスポンスが返されます。これによりクライアントサイドのフォームバリデーションライブラリが不要になり、サーバーが検証を行い、HTMXが成功メッセージまたはエラーフィードバックをスワップします。

Alembicによるマイグレーション

Alembicでデータベーススキーマの変更を管理します:

# Generate a migration from model changes
alembic revision --autogenerate -m "add user preferences table"

# Apply migrations
alembic upgrade head

# Roll back one migration
alembic downgrade -1

autogenerate機能はSQLAlchemyモデルと現在のデータベーススキーマを比較し、マイグレーションスクリプトを生成します。これらのスクリプトはリポジトリに保存されるバージョン管理されたPythonファイルです:

# alembic/versions/001_add_user_preferences.py
def upgrade():
    op.create_table(
        "user_preferences",
        sa.Column("id", sa.Integer, primary_key=True),
        sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id")),
        sa.Column("locale", sa.String(10), default="en"),
        sa.Column("theme", sa.String(20), default="dark"),
    )

def downgrade():
    op.drop_table("user_preferences")

マイグレーションはデプロイ時(アプリケーション起動前)に実行されます。これにより、データベーススキーマとアプリケーションコードの整合性が保証されます。blakecrosley.comでは、ほとんどのデータはCloudflare D1(HTTP経由でアクセス)に格納されているため、Alembicマイグレーションはセッションデータや分析用のローカルSQLiteまたはPostgreSQLデータベースに適用されます。

Cloudflare D1パターン

blakecrosley.comでは、Cloudflare D1をCloudflare Workerプロキシ経由でアクセスするリモートデータベースとして使用しています:

class D1Client:
    """HTTP client for Cloudflare D1 via Worker proxy."""

    def __init__(self, worker_url: str, auth_secret: str):
        self.worker_url = worker_url
        self.auth_secret = auth_secret

    async def fetch_all(self, sql: str, params: list = None) -> list[dict]:
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.worker_url}/query",
                json={"sql": sql, "params": params or []},
                headers={"Authorization": f"Bearer {self.auth_secret}"},
            )
            return response.json()["results"]

このパターンは、データベースが必要だがデータベースサーバーの管理は避けたいアプリケーションに適しています。D1はCloudflareのエッジで動作するSQLiteであり、HTTP経由でアクセスします。Workerプロキシが認証とレート制限を処理します。トレードオフはレイテンシです。すべてのクエリがHTTPリクエスト(約50〜100ms)となり、ローカルデータベース接続(約1〜5ms)と比較すると遅くなります。起動時のインメモリキャッシュにより、翻訳のような読み取り中心のワークロードではこの影響を軽減できます。


セキュリティ

セキュリティヘッダーミドルウェア

blakecrosley.comでは、カスタムミドルウェアにより強化されたセキュリティヘッダーを実装しています:

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    CSP_DIRECTIVES = {
        "default-src": "'self'",
        "script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
        "style-src": "'self' 'unsafe-inline'",
        "img-src": "'self' data: https:",
        "connect-src": "'self'",
        "frame-ancestors": "'self'",
        "base-uri": "'self'",
        "form-action": "'self'",
        "upgrade-insecure-requests": "",
    }

    async def dispatch(self, request, call_next):
        response = await call_next(request)
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-Frame-Options"] = "SAMEORIGIN"
        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
        response.headers["Strict-Transport-Security"] = (
            "max-age=31536000; includeSubDomains"
        )
        response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
        response.headers["Content-Security-Policy"] = self.csp
        response.headers["Permissions-Policy"] = self.PERMISSIONS_POLICY
        return response

CSPに'unsafe-inline''unsafe-eval'が含まれているのは、Alpine.jsの式評価に必要なためです。代替手段としてAlpine.jsのCSP互換ビルドがありますが、制限があります。14 それ以外の機能はすべてロックダウンされています。frame-ancestorsはクリックジャッキングを防止し、form-actionはフォーム送信を同一オリジンに制限し、upgrade-insecure-requestsはHTTPSを強制します。

HTMXにおけるCDNキャッシュの安全性

セキュリティヘッダーミドルウェアは、HTMXレスポンスにVary: HX-Requestを追加します:

if request.headers.get("HX-Request"):
    existing_vary = response.headers.get("Vary", "")
    if "HX-Request" not in existing_vary:
        parts = [v.strip() for v in existing_vary.split(",") if v.strip()]
        parts.append("HX-Request")
        response.headers["Vary"] = ", ".join(parts)

このヘッダーがないと、CDNがHTMXのフラグメントレスポンスをキャッシュし、HTMX以外のリクエストにフルページとして配信してしまう可能性があります(逆も同様)。Varyヘッダーは、HX-Requestヘッダーの値に基づいて別々のキャッシュエントリを保持するようCDNに指示します。11

CSRF対策

HTMXフォームでは、ステートレスなHMAC署名付きCSRFトークンを使用します:

# csrf.py
def generate_csrf_token() -> str:
    """Token format: timestamp:random:HMAC-SHA256-signature"""
    timestamp = str(int(time.time()))
    random_value = secrets.token_hex(16)
    payload = f"{timestamp}:{random_value}"
    signature = hmac.new(
        CSRF_SECRET.encode(), payload.encode(), hashlib.sha256
    ).hexdigest()
    return f"{payload}:{signature}"

def validate_csrf_token(token: str) -> bool:
    """Verify signature and check expiration (1 hour)."""
    timestamp, random_value, signature = token.split(":")
    if int(time.time()) - int(timestamp) > 3600:
        return False
    expected = hmac.new(
        CSRF_SECRET.encode(),
        f"{timestamp}:{random_value}".encode(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

トークンはJinja2グローバルを介してテンプレート内で生成され、HTMXフォームリクエストに含まれます:

<form hx-post="/contact" hx-target="#form-result">
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
  <!-- form fields -->
</form>

ステートレストークンにより、サーバーサイドのセッションストレージが不要になります。HMAC署名はトークンがサーバーで生成されたことを保証し、タイムスタンプはリプレイ攻撃を防止します。hmac.compare_digestはタイミング攻撃を防ぎます。15

HTMLサニタイゼーション

ユーザー生成コンテンツはレンダリング前にnh3を通してサニタイズされます:

templates.env.filters["sanitize"] = sanitize_html
# In templates: {{ content | sanitize }}

nh3ライブラリは、許可リストにないタグと属性を除去します。リンクには自動的にrel="noopener noreferrer"が付与されます。この防御はCSPとは独立しており、レンダリング層で格納型XSSを防止し、CSPはブラウザ層でインジェクトされたスクリプトを防止します。多層防御の実践です。

入力バリデーション

PydanticモデルがAPI境界ですべての入力を検証します:

from pydantic import BaseModel, Field, EmailStr

class ContactRequest(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    email: EmailStr
    message: str = Field(..., min_length=10, max_length=5000)

FastAPIは無効な入力に対して自動的に422 Unprocessable Entityを返します。パラメータ化データベースクエリ(SQLAlchemyは文字列補間を行いません)と組み合わせることで、SQLインジェクションを防止し、境界での型安全性を確保できます。


パフォーマンス

Lighthouse 100/100/100/100

blakecrosley.comはLighthouseの4つのカテゴリすべて(パフォーマンス、アクセシビリティ、ベストプラクティス、SEO)で100点を獲得しています。PageSpeed Insightsで確認できます。2

主な最適化ポイントは以下の通りです。

CSSの読み込み戦略

blakecrosley.comでは、単一の<link>タグとコンテンツハッシュ付きURLによるイミュータブルキャッシングでCSSを読み込んでいます。

<link rel="stylesheet" href="{{ asset('css/styles.css') }}">

asset()ヘルパーがコンテンツハッシュ(?v=a3b2c1d4)を付加するため、コンテンツが変更されるまでブラウザはファイルを無期限にキャッシュします。クリティカルCSSの抽出も、print-mediaトリックも、JavaScriptベースの読み込みも不要です。CSSファイルはgzip圧縮後約8KB——この程度のサイズであれば、最適化の曲芸なしにLighthouse Performanceで100点を達成できます。

GZip圧縮

app.add_middleware(GZipMiddleware, minimum_size=500)

500バイトを超えるレスポンスが圧縮されます。HTMLは70〜80%圧縮され、15KBのドキュメントが3〜4KBまで縮小されます。

イミュータブルな静的アセットキャッシング

# In security headers middleware
if request.url.path.startswith("/static/"):
    if os.environ.get("RAILWAY_ENVIRONMENT"):
        response.headers["Cache-Control"] = "public, max-age=31536000, immutable"

コンテンツハッシュ付きURL(?v=a3f8b2c1d0)の静的アセットは、immutable指定で1年間キャッシュされます。ファイルが変更されるとハッシュが変わり、ブラウザやCDNに新しいバージョンの取得を強制します。

スクリプトの遅延読み込み

<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
<script defer src="{{ asset('js/main.js') }}"></script>

defer属性はスクリプトをHTMLのパースと並行してダウンロードしつつ、ドキュメントのパース完了後に実行します。非同期読み込みや実行順序管理の複雑さなしに、レンダリングブロックを防止できます。

画像の最適化

画像にはWebPとレスポンシブなsrcset、明示的なサイズ指定を使用しています。

OPTIMIZED_IMAGES = {
    "vision-sprint": {
        "webp_srcset": (
            "/static/images/optimized/vision-sprint-400w.webp 400w, "
            "/static/images/optimized/vision-sprint-800w.webp 800w, "
            "/static/images/optimized/vision-sprint-1200w.webp 1200w"
        ),
        "fallback": "/static/images/optimized/vision-sprint-fallback.jpg",
        "width": 1200,
        "height": 1045,
    },
}
<picture>
  <source type="image/webp"
          srcset="{{ image.webp_srcset }}"
          sizes="(max-width: 768px) 100vw, 50vw">
  <img src="{{ image.fallback }}"
       width="{{ image.width }}"
       height="{{ image.height }}"
       alt="{{ image.alt }}"
       loading="lazy">
</picture>

明示的なwidthheight属性によりCumulative Layout Shift(CLS)を防止します。loading="lazy"属性は画面外の画像の読み込みを遅延させます。WebPはJPEGと同等の品質で25〜35%小さいファイルサイズを実現します。16

Early Hints

# In main.py
app.state.preload_links = [
    f'<{make_asset_url(_asset_map, "css/styles.css")}>; rel=preload; as=style',
]

# In security headers middleware
if "text/html" in content_type:
    preload_links = getattr(request.app.state, "preload_links", [])
    if preload_links:
        response.headers["Link"] = ", ".join(preload_links)

rel=preload付きのLinkヘッダーにより、Cloudflareが103 Early Hintsレスポンスを送信します。サーバーがHTMLレスポンスの生成を完了する前に、ブラウザがCSSの取得を開始できるようになります。17

最小限のJavaScript

JavaScriptのフットプリント全体は以下の通りです。

ライブラリ サイズ(minified + gzip)
HTMX 約14 KB
Alpine.js 約14 KB
ページ固有のJS 4〜8 KB
合計 32〜36 KB

一般的なReactアプリケーションは、アプリケーションコードの前にフレームワークJavaScriptだけで100〜300 KBを配信します。18 ノービルドのアプローチでは、そもそも配信するJavaScriptが少ないため、JavaScriptの転送量も少なくなります。


デプロイ

Railway

blakecrosley.comはgit pushでRailwayにデプロイしています。

# railway.toml
[build]
builder = "nixpacks"

[deploy]
startCommand = "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"
healthcheckPath = "/health"
healthcheckTimeout = 300
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10

RailwayのNixpacksビルダーがrequirements.txtからPythonプロジェクトを検出し、依存関係をインストールして起動コマンドを実行します。Dockerファイルは不要です。ヘルスチェックエンドポイントにより、トラフィックを受ける前にアプリケーションの応答性を確認します。

@app.get("/health")
async def health():
    return {"status": "healthy"}

デプロイパイプライン

git push origin main
  → Railway detects push
  → Nixpacks installs Python + requirements.txt (cached)
  → uvicorn starts
  → Health check passes
  → Traffic routes to new deployment
  → ~40 seconds total

npm installなし。npm run buildなし。webpackのコンパイルなし。TypeScriptのコンパイルなし。唯一のインストール手順はpip install -r requirements.txtのみで、デプロイ間でキャッシュされます。

Procfile

web: uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}

ProcfileはHeroku互換の代替手段を提供します。Railwayはrailway.tomlProcfileの両方をサポートしています。${PORT:-8000}構文はプラットフォーム提供のポートを使用し、指定がなければローカル開発用にデフォルトの8000番を使用します。

Uvicornの本番構成

高トラフィックのデプロイでは、複数のワーカーを使用します。

uvicorn app.main:app \
  --host 0.0.0.0 \
  --port ${PORT:-8000} \
  --workers 4 \
  --loop uvloop \
  --http httptools
  • --workers 4は4つのワーカープロセスを起動します(一般的な目安:2 × CPUコア数 + 1)
  • --loop uvloopはより高速なuvloopイベントループを使用します(asyncioのドロップイン置き換え)
  • --http httptoolsはより高速なhttptools HTTPパーサーを使用します

開発時は--reloadでファイル変更を監視できます。

uvicorn app.main:app --reload --port 8000

Dockerを使う場合

Dockerが必要なプラットフォーム向けの構成です。

FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

slimベースイメージによりコンテナサイズを抑えます。--no-cache-dirはpipがダウンロードしたパッケージをイメージレイヤーに保存するのを防ぎます。

Cloudflare CDN

blakecrosley.comはCDNキャッシング、DNS、WorkersにCloudflareを使用しています。

# Cache headers for HTML pages (set in security middleware)
response.headers["Cache-Control"] = (
    "public, max-age=300, s-maxage=3600, "
    "stale-while-revalidate=86400"
)
  • max-age=300 — ブラウザが5分間キャッシュ
  • s-maxage=3600 — CDNが1時間キャッシュ
  • stale-while-revalidate=86400 — 再検証中は24時間、古いコンテンツを配信

静的アセットにはコンテンツハッシュ付きURLにより鮮度が保証されるため、max-age=31536000, immutableを設定します。


判断フレームワーク

ビルドツールは必要か?

4つの質問に答えてください。

1. 5人以上の開発者がJavaScriptインターフェースを共有しているか? 「はい」なら、TypeScriptのコンパイル時型チェックが、ランタイムテストでは遅すぎる統合バグを防止します。ビルドステップを追加しましょう。

2. アプリケーションは複雑なクライアントサイド状態を管理しているか? ドラッグ&ドロップ、リアルタイムコラボレーション、オフラインファーストのデータがコア機能(あったら便利、ではなく)であれば、ReactやSvelteのようなフレームワークはその複雑さに見合う価値があります。ビルドステップを追加しましょう。

3. 複数のプロダクトが共有コンポーネントライブラリを利用しているか? 「はい」なら、そのライブラリにはnpmパッケージング、セマンティックバージョニング、ツリーシェイキングが必要です。ビルドステップを追加しましょう。

4. バンドラーを前提としたnpmエコシステムのライブラリに依存しているか? Radix、Framer Motion、TanStack Queryなどのライブラリがプロダクトの中核であれば、ビルドパイプラインは必須です。

4つすべてが「いいえ」なら、ノービルドのアプローチは有効です。1つでも「はい」があれば、ビルドツールは実際の問題を解決しています。よくある間違いは、4つすべてが「いいえ」なのにビルドツールを導入すること——存在しない問題を解決しながら、依存関係管理のオーバーヘッドを生み出してしまいます。1

スタックの比較

カテゴリ ノービルド(本ガイド) React + ビルドツール
最適な用途 コンテンツサイト、ポートフォリオ、社内ツール、CRUDアプリ SaaSプロダクト、複雑なSPA、デザインシステム利用者
チーム規模 1〜5名 5〜50名以上
状態管理 サーバー(HTMX)+ クライアント(Alpine.js) クライアント(React state、Redux、Zustand)
型安全性 ランタイム(サーバーサイドのPydantic) コンパイル時(TypeScript)
コンポーネント再利用 Jinja2のincludesとマクロ npmパッケージ、共有ライブラリ
SEO デフォルトでサーバーレンダリング SSR/SSGの設定が必要
パフォーマンスの下限 高い(最小限のJS、サーバーレンダリング) ばらつきあり(フレームワークのオーバーヘッド)
複雑さの上限 低い(オフライン、リッチなクライアント状態は非対応) 高い(あらゆるクライアントインタラクションが可能)
依存関係 Pythonパッケージ15個 npmパッケージ300個以上
ビルド時間 0秒 15〜60秒

HTMXが適さないケース

HTMXはクライアント状態をサーバーとのラウンドトリップに置き換えます。レイテンシが問題になるまでは、このアプローチは有効です。

  • ドラッグ&ドロップインターフェース — ドラッグイベントごとに200msのサーバーラウンドトリップは許容できません
  • リアルタイムコラボレーション — WebSocket駆動の状態にはクライアントサイドの競合解決が必要です
  • オフラインファーストアプリケーション — サーバーがなければHTMXも使えません
  • 状態に紐づく複雑なアニメーション — Framer MotionやReact SpringはReactのリコンシリエーションモデルを前提としています
  • Canvas/WebGLアプリケーション — レンダリングループは本質的にクライアントサイドです

これらのユースケースには、クライアントサイドフレームワークが適切なツールです。ノービルドのアプローチはそれらを置き換えようとするものではありません。


クイックリファレンスカード

FastAPI

# Development
source venv/bin/activate
uvicorn app.main:app --reload --port 8000

# Production
uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}

# Testing
python -m pytest -v --cov=app

# Database migrations
alembic upgrade head
alembic revision --autogenerate -m "description"

HTMX 属性

hx-get="/url"                     <!-- GET request -->
hx-post="/url"                    <!-- POST request -->
hx-target="#element"              <!-- Where to put response -->
hx-swap="innerHTML"               <!-- How to insert (innerHTML, outerHTML, beforeend) -->
hx-trigger="click"                <!-- What triggers request -->
hx-trigger="keyup changed delay:300ms"  <!-- Debounced input -->
hx-trigger="load"                 <!-- Fire on element load -->
hx-indicator="#spinner"           <!-- Show during request -->
hx-push-url="true"                <!-- Update browser URL -->
hx-replace-url="true"             <!-- Replace URL (no history) -->

Alpine.js 属性

x-data="{ open: false }"         <!-- Component scope + state -->
x-show="open"                    <!-- Toggle visibility -->
x-cloak                          <!-- Hide until Alpine inits -->
@click="open = !open"            <!-- Event handler -->
@click.away="open = false"       <!-- Outside click -->
@keydown.escape="open = false"   <!-- Keyboard event -->
:class="{ 'active': open }"      <!-- Dynamic class -->
:aria-expanded="open"            <!-- Dynamic attribute -->
x-text="count"                   <!-- Dynamic text content -->
x-init="fetchData()"             <!-- Run on init -->

CSS カスタムプロパティ

:root {
  --color-bg:     #000000;
  --color-text:   #ffffff;
  --spacing-sm:   1rem;
  --spacing-md:   1.5rem;
  --font-size-lg: 1.25rem;
}
@media (max-width: 768px) {
  :root { --gutter: 24px; }
}

セキュリティヘッダー

Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval'
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Cross-Origin-Opener-Policy: same-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()

プロジェクトセットアップチェックリスト

[ ] FastAPI app with Jinja2Templates
[ ] Security headers middleware (CSP, HSTS, X-Frame-Options)
[ ] CSRF token generation and validation
[ ] GZip middleware (minimum_size=500)
[ ] Content-hash asset versioning (cache busting)
[ ] HTMX self-hosted in /static/js/vendor/
[ ] Alpine.js self-hosted in /static/js/vendor/
[ ] CSS custom properties for design tokens
[ ] Health check endpoint (/health)
[ ] Error handlers (404, 500)
[ ] robots.txt, sitemap.xml, llms.txt
[ ] JSON-LD structured data in base template
[ ] Hreflang tags for i18n (if multi-language)
[ ] HTML sanitization filter (nh3)
[ ] Rate limiting middleware
[ ] Deferred script loading

よくある質問

HTMX は本番環境のWebアプリケーションに使えますか?

はい。HTMX は2020年から安定しており、複数の業界で本番運用されています。作者のCarson Grossは後方互換性をコア設計原則として維持しており、HTMX のドキュメントではメジャーバージョン内で既存アプリケーションを破壊しないことが明記されています。19 ライブラリはminify+gzip後わずか14KBで、依存関係はゼロ、セマンティックバージョニングに従っています。blakecrosley.comでは3年間 HTMX を本番運用しており、HTMX に起因するバグはゼロです。

ビルドステップなしで TypeScript を使えますか?

部分的に可能です。TypeScript ファイルは tsc --noEmit でコンパイル時の型チェックをリンターとして実行できます。ただし、ブラウザは .ts ファイルを直接実行できないため、TypeScript を配信するにはビルドステップが必要です。代替手段として、プレーンな .js ファイルにJSDoc型アノテーションを記述する方法があります。TypeScript はコンパイルなしでこれらをチェックできるため、開発時に型安全性を確保しつつ、標準の JavaScript をそのまま配信できます。

このアプローチはAstroや11tyと比べてどうですか?

Astroと11tyは静的サイトジェネレーターで、最小限のクライアント JavaScript を含むプレーンな HTML を生成しますが、ビルドステップ(Node.js、npm install、ビルドコマンド)が必要です。ノービルドアプローチではそのステップ自体を排除し、サーバーがリクエストごとに HTML をレンダリングします。トレードオフとして、Astro/11tyはより高速な静的ページを生成(サーバー計算不要)できる一方、FastAPI + HTMX は動的コンテンツ(ユーザー固有のデータ、フォーム送信、リアルタイム更新)を別途 API レイヤーなしでネイティブに処理できます。

Reactのサーバーサイドレンダリング(SSR)との違いは?

Next.js SSRと FastAPI + HTMX アプローチは、サーバーレンダリングされた HTML をブラウザに送信するという目標を共有しています。違いは初期レンダリング後の動作にあります。Next.jsはReactでページをハイドレートし、フレームワークのランタイムとコンポーネントコードをクライアントに配信します。FastAPI + HTMX はハイドレートしません — HTML が最終出力です。HTMX は後続のインタラクションをサーバーから新しい HTML フラグメントをリクエストすることで処理します。結果として、FastAPI + HTMX は合計30〜40KBの JavaScript を配信するのに対し、Next.jsアプリケーションでは100〜300KBとなります。18

このスタックでフォームバリデーションはどう行いますか?

サーバーサイドで行います。フォーム送信時にPydanticが入力をバリデーションし、失敗した場合はサーバーがエラーメッセージ付きのフォームを返します。HTMX がそのレスポンスをDOMにスワップします:

<form hx-post="/contact" hx-target="#form-container" hx-swap="outerHTML">
  <input type="email" name="email" required>
  <button type="submit">Send</button>
</form>
@router.post("/contact")
async def contact(request: Request, email: str = Form(...)):
    if not validate_email(email):
        return templates.TemplateResponse("components/_contact_form.html", {
            "request": request,
            "error": "Please enter a valid email address",
            "email": email,  # Preserve input
        })
    await send_email(email)
    return templates.TemplateResponse("components/_contact_success.html", {
        "request": request
    })

サーバーがバリデーションし、サーバーがエラー状態をレンダリングし、HTMX が結果をスワップします。クライアントサイドのバリデーションライブラリは不要です。HTML の required 属性が第一防衛線としてブラウザレベルの基本的なバリデーションを提供します。

リアルタイム機能(WebSocket)は追加できますか?

はい。FastAPI には WebSocket サポートが組み込まれています:

from fastapi import WebSocket

@app.websocket("/ws/notifications")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    while True:
        data = await get_notification()
        await websocket.send_text(render_notification_html(data))

HTMX には WebSocket エクステンション(hx-ws)があり、要素を WebSocket エンドポイントに接続できます:

<!-- HTMX 2.x WebSocket extension syntax -->
<div hx-ext="ws" ws-connect="/ws/notifications">
  <div id="notifications" ws-send></div>
</div>

注意: HTMX 1.xでは hx-ws="connect:..." 構文を使用していました。HTMX 2.xでは WebSocket サポートが別のエクステンション(htmx-ext-ws)に移行され、上記の ws-connectws-send 属性を使用します。HTMX 1.xを使用している場合、旧来の hx-ws 構文は引き続き動作します。

サーバーからのメッセージは、HTTPレスポンスと同じターゲティングとスワップの仕組みでDOMにスワップされます。サーバーが HTML フラグメントを WebSocket 経由で送信し、HTMX がそれを挿入します。

このスタックのSEO対応はどうなっていますか?

サーバーレンダリングされた HTML は本質的にSEOフレンドリーです。クローラーは JavaScript を実行せずに完全なページコンテンツを受信できるためです。blakecrosley.comではさらに複数のSEOレイヤーを追加しています:

  • JSON-LD 構造化データ:すべてのページの <head> に配置(Person、Article、WebSite、FAQPageスキーマ)
  • 動的サイトマップ:全10ロケールのhreflang alternatesを含む
  • RSSフィード/blog/feed.xml
  • llms.txt:AIクローラーの発見性向上のためルートに配置
  • 正規URLOpen Graphタグ:ベーステンプレートに記述
  • セマンティック HTML<article><section><main>、適切な見出し階層

SSRの設定は不要です。getStaticProps もISRも不要です。HTML はリクエストごとにレンダリングされます — これは最適化ではなく、デフォルトの動作です。

Reactと比べた学習曲線は?

Python 開発者にとって、学習曲線は大幅に低くなります。すでに言語を理解しているからです。FastAPI のルートハンドラーはテンプレートレスポンスを返します — FlaskやDjangoのビューと同じメンタルモデルです。HTMX は少数の HTML 属性(hx-gethx-targethx-swap)を追加するだけです。Alpine.js もいくつかの属性(x-datax-show@click)を追加するのみです。JSX、仮想DOM、Hooksシステム、状態管理ライブラリ、ビルドツールの設定を学ぶ必要はありません。

HTMX のドキュメントは1つの長いページに収まります。Alpine.js のドキュメントも数ページです。一方、Reactのドキュメントはhooks、context、refs、effects、suspense、サーバーコンポーネント、ストリーミングSSRなどをカバーする数百ページに及びます。

JavaScript/React開発者にとっては、構文的な変化よりも概念的な転換が求められます。核心的なインサイトは、サーバーが状態を所有し、サーバーが HTML をレンダリングするということです。クライアントサイドの状態管理はサーバーサイドのルートハンドリングに置き換わります。クライアントサイドのデータフェッチは HTML 要素上の HTMX 属性になります。構文はよりシンプルですが、クライアントがレンダリングを所有するというSPAの前提を「アンラーン」するメンタルモデルの転換が必要です。


変更履歴

日付 変更内容
2026年3月24日 初版公開

参考文献


このガイドはblakecrosley.comの構築に使用された完全なシステムを解説しています。No-Build Manifestoでは哲学的な論拠を提示しています。Lighthouse Perfect Scoreの記事ではパフォーマンス最適化の過程を記録しています。Vibe Coding vs. Engineeringの記事では、AI支援開発がこのワークフローにどう位置づけられるかを探っています。


  1. 2026年4月時点のblakecrosley.com本番環境メトリクスです。100以上のブログ記事、インタラクティブなJavaScriptコンポーネント、9つの包括的ガイド、9言語の翻訳を、最小限のPython依存関係とビルドツールゼロで配信しています。ライブサイトおよびrequirements.txtから検証済みです。 

  2. Google PageSpeed Insights(pagespeed.web.dev)は任意の公開URLに対してLighthouse監査を実行します。blakecrosley.comは2026年3月時点でパフォーマンス・アクセシビリティ・ベストプラクティス・SEOすべて100/100を達成しています。結果は誰でも検証可能です。最適化の全過程についてはFrom 76 to 100: Achieving a Perfect Lighthouse Scoreをご覧ください。 

  3. npx create-next-app@latest(Next.js 15、2026年2月テスト)を新規実行すると、node_modules/に311パッケージ、合計187 MBがインストールされます。追加の依存関係を含む本番プロジェクトではさらに増加する傾向があります。プロジェクトごとに差異があります。ソース:著者によるテスト、The No-Build Manifestoに記載。 

  4. VercelのNext.jsパフォーマンスドキュメントでは、90以上のスコアを達成するために特定の最適化(画像最適化、フォント読み込み、コード分割)が推奨されています。nextjs.org/docs/app/building-your-application/optimizingを参照してください。70〜90の範囲は、これらの最適化を適用する前のデフォルト設定を反映したものです。 

  5. 2026年3月時点のblakecrosley.comのrequirements.txtから完全な依存関係リストを検証済みです。ビルドツール、コンパイラ、バンドラーはゼロです。 

  6. 著者がNext.jsプロジェクトを保守した経験(2021〜2024年)に基づくと、JavaScriptエコシステムではアクティブなプロジェクトに対して月あたり15〜25件のDependabot PRが生成されます。その大半は、開発者が直接インポートしていない推移的依存関係の更新です。 

  7. Tim Berners-Leeはウェブデザインの原則として後方互換性を明確にしました。「ブラウザは後方互換でなければならない」と。1996年のページは2026年のChromeでも表示されます。w3.org/DesignIssues/Principlesを参照してください。 

  8. OWASPは攻撃対象領域を減らすため、本番環境でAPIドキュメントエンドポイントを無効化することを推奨しています。/openapi.jsonエンドポイントはすべてのルート定義、パラメータ、レスポンスモデルを公開します。 

  9. FastAPIの非同期ハンドラと同期ハンドラに関するドキュメント:fastapi.tiangolo.com/async/async関数内でawaitとブロッキング呼び出しを混在させると、イベントループが枯渇します。 

  10. nh3はRustベースのHTMLサニタイザーで、Bleachライブラリの後継です。PyO3プロジェクトによってメンテナンスされており、許可リストベースのHTMLサニタイズ機能を提供します。github.com/messense/nh3を参照してください。 

  11. VaryヘッダーはRFC 9110 Section 12.5.5で定義されています。指定されたリクエストヘッダー値に基づいて、キャッシュに別々のレスポンスを保存するよう指示するものです。Vary: HX-Requestがない場合、CDNがHTMXフラグメントをフルページレスポンスとして配信してしまう可能性があります。httpwg.org/specs/rfc9110.html#field.varyを参照してください。 

  12. CSSカスタムプロパティ(CSS変数)は、グローバルブラウザの97%以上でサポートされています。カスケード、継承、実行時のメディアクエリへの応答が可能であり、これらはプリプロセッサ変数にはない機能です。ソース:caniuse.com/css-variables。 

  13. Googleのhreflangドキュメント:developers.google.com/search/docs/specialty/international/localized-versionsx-default値は、hreflangリストに言語が含まれていないユーザー向けのフォールバックページを指定します。 

  14. Alpine.jsの式評価エンジンには、Content Security Policyで'unsafe-eval'が必要です。CSP互換ビルド(@alpinejs/csp)はこの要件を回避しますが、制限があります。alpinejs.dev/advanced/cspを参照してください。 

  15. HMACベースのCSRFトークンは、OWASP CSRF Prevention Cheat Sheetに記載されている「Signed Double-Submit Cookie」パターンに従います。hmac.compare_digestは定数時間比較を使用し、タイミングサイドチャネル攻撃を防止します。cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.htmlを参照してください。 

  16. WebPは同等の視覚品質でJPEGより25〜35%小さいファイルサイズを実現します。GoogleのWebP研究:developers.google.com/speed/webp/docs/webp_study。 

  17. 103 Early Hintsにより、サーバー(またはCDN)は最終レスポンスの準備が整う前に、プリロードヒントを含む予備レスポンスを送信できます。Cloudflareはrel=preload付きのLinkヘッダーに対してEarly Hintsをサポートしています。developer.chrome.com/blog/early-hintsを参照してください。 

  18. React 18 + ReactDOMは圧縮・gzip後で約42 KBです。ルーター、状態管理ライブラリ、ビルドフレームワークのランタイムを含めると、一般的なReactアプリケーションは100〜300 KBのフレームワークJavaScriptを配信します。ソース:bundlephobia.com/package/[email protected]。 

  19. HTMXのバージョニングポリシーと後方互換性への取り組みはhtmx.org/migration-guide-htmx-1/に記載されています。Carson GrossはHypermedia Systems(2023年、Gross、Stepinski、Cotter共著)でこの後方互換性の原則を述べています:hypermedia.systems。 

NORMAL fastapi-htmx.md EOF