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

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

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

words: 1138 read_time: 38m updated: 2026-03-25 08:25
$ less fastapi-htmx.md

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

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

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


主要ポイント

  • サーバーレンダリングのHTMLは、3つの問題カテゴリを丸ごと排除します。クライアント側の状態管理、JSONのシリアライゼーション境界、ハイドレーションの不一致がなくなります。HTMXはサーバーのレスポンスを最終出力として扱うため、クライアント側でのレンダリング工程が不要です。
  • ビルドツールがゼロなら、ビルド失敗もゼロです。npm installのピア依存関係の競合も、触っていないファイルでのTypeScriptコンパイルエラーも、一度もインポートしていない推移的依存関係に対するDependabot PRも発生しません。デプロイパイプラインはgit pushだけで完結します。
  • HTMXでは扱えないクライアント専用の状態はAlpine.jsが担当します。ドロップダウン、モーダル、モバイルナビゲーションのトグルなど、ブラウザ内にのみ存在するUI状態はAlpine.jsの領域です。境界は明確で、サーバーが必要な状態にはHTMXを、不要な状態にはAlpine.jsを使いましょう。
  • カスタムプロパティを活用したプレーンなCSSが、SassやTailwindを置き換えます。CSSカスタムプロパティは、カスケード・継承・メディアクエリへの応答をランタイムで行います。プリプロセッサの変数は静的な値にコンパイルされて消えてしまいますが、ブラウザはカスタムプロパティを直接読み取るため、コンパイル工程が不要です。
  • このアプローチには明確な適用範囲があります。コンポーネントインターフェースを共有する大規模チーム、複雑なクライアント側状態を持つSaaS製品、npmエコシステムのライブラリに依存するアプリケーションには適していません。セクション15の判断フレームワークで、その境界を正確に見極められます。
  • blakecrosley.comがその証明です。本ガイドのすべてのパターンは本番環境で稼働しています。すべての主張にはファイルパス、設定ブロック、または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/サイズ 存在しない ベースライン187 MB、追加で250〜400 MB
インストール時間 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個が含まれます。ビルドツールは一つもありません。コンパイラもバンドラーもありません。5

何を手放すのか

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

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

Hot Module Replacementがありません。CSSの変更にはブラウザの手動リフレッシュが必要です。HTMXのhx-boostによりナビゲーションは十分高速なため、フルリフレッシュは許容範囲内ですが、細かいビジュアル調整の繰り返しではHMRの方が時間を節約できます。

Tree Shakingがありません。記述した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という、30年にわたり後方互換性を維持してきた標準技術を使用しています。7 Webpack 4 → 5の移行も、Create React Appの廃止も、Next.jsのApp Router移行も無縁です。


アーキテクチャの概要

リクエストフロー

すべてのリクエストは、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リクエスト、ファイル読み込み)では、非同期処理によりイベントループのブロッキングを防げます。

@app.on_event("startup")
async def startup_load_translations():
    """Load translations from D1 into memory at startup."""
    client = init_d1_client(
        worker_url=settings.D1_WORKER_URL,
        auth_secret=settings.D1_AUTH_SECRET,
    )
    if not client.is_configured:
        logger.warning("i18n: D1 not configured, translations use defaults")
        return
    cache = await load_translations(client)
    logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")

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:ページネーション付きブログ一覧

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

<!-- 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)スワップ

1つのサーバーアクションで複数の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に対してDOM内の任意の場所からidで要素を見つけて置換するよう指示します。hx-targetの指定に関係なく動作します。これはReactの「状態を上位に持ち上げる」パターンを置き換えるものです。サーバーがすべての派生状態を計算し、各要素の最終的なHTMLを1つのレスポンスで送信します。

blakecrosley.comでは、このパターンがコンタクトフォームで使用されています。フォームを送信すると、フォーム本体がサクセスメッセージに置き換わると同時に、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>のコンテンツだけです。blakecrosley.comではメインナビゲーションにブーストリンクを使用しており、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が利用可能な場合にはエクスペリエンスを向上させます。

パターン5:ローディング状態

<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 パターン

HTMX が扱わない領域を Alpine.js が補完します。それは、サーバーとのやり取りが一切不要なクライアント側のみの状態です。ユーザーがドロップダウンをクリックして開く場合、その状態はブラウザ内にしか存在しません。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 を採用しています。


Sass なしの Bootstrap 5

Bootstrap 5 は jQuery への依存を廃止し、単独での CSS 利用をサポートしています。Bootstrap のグリッドシステムやユーティリティクラスを使うために、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 の mixin も @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 変数はコンパイル時に静的な値に変換され、消滅します。この違いはテーマ設定において重要です。単一のカスタムプロパティを変更するだけで、再コンパイルなしにすべての派生値を更新できるのです。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)に保存され、起動時にインメモリキャッシュへ読み込まれます:

@app.on_event("startup")
async def startup_load_translations():
    client = init_d1_client(worker_url=settings.D1_WORKER_URL,
                            auth_secret=settings.D1_AUTH_SECRET)
    cache = await load_translations(client)
    logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")

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

ヘルスモニタリング

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 フィルターはこのパターンを単一のパイプで表現します:

{{ 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}"/>'
                )

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


データベースパターン

SQLAlchemy 2.0 Async

リレーショナルデータベースが必要なアプリケーションでは、SQLAlchemy 2.0のAsync対応が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(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依存関数がセッションのライフサイクルを管理します。セッションを開き、ルートハンドラにyieldし、成功時にコミット、例外発生時にロールバックするという流れです。すべてのデータベース操作はパラメータ化クエリを使用し、文字列補間は一切行いません。

Pydantic統合

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

from pydantic import BaseModel, EmailStr

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

@router.post("/contact")
async def submit_contact(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 Workerプロキシを通じてアクセスするリモートデータベースとしてCloudflare D1を使用しています。

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つのカテゴリすべて(Performance、Accessibility、Best Practices、SEO)で100点を獲得しています。PageSpeed Insightsで確認できます。2

主な最適化手法は以下の通りです。

クリティカルCSS

クリティカル(ファーストビュー)CSSを抽出し、<head>にインラインで埋め込みます。完全なスタイルシートは非同期で読み込まれます。

<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>

<!-- Full CSS loads async — doesn't block render -->
<link rel="stylesheet" href="/static/css/styles.css"
      media="print" onload="this.media='all'">
<noscript>
  <link rel="stylesheet" href="/static/css/styles.css">
</noscript>

media="print"トリックは、このスタイルシートが画面レンダリングに不要であるとブラウザに伝えることで、初回ペイントをブロックしません。onloadハンドラーが読み込み完了後にmedia="all"へ切り替えます。<noscript>フォールバックにより、JavaScriptが無効な環境でもスタイルシートが読み込まれます。16

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のパースと並行してダウンロードしつつ、ドキュメントのパース完了後に実行します。非同期読み込みや実行順序管理の複雑さを避けながら、レンダリングブロックを防止できます。

画像の最適化

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

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%小さいファイルサイズを実現します。17

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の取得を開始できます。18

最小限のJavaScript

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

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

一般的なReactアプリケーションは、アプリケーションコードの前にフレームワークJavaScriptだけで100〜300KBを配信します。19 ノービルドアプローチでは、配信する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プロジェクトを検出し、依存関係をインストールして起動コマンドを実行します。Dockerfileは不要です。ヘルスチェックエンドポイントにより、トラフィックを受信する前にアプリケーションが応答可能であることを確認します。

@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のincludeとマクロ 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 のドキュメントにはメジャーバージョン内で既存のアプリケーションを壊さないと明記されています。20 ライブラリは圧縮・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 の JavaScript 総量は30〜40KBであるのに対し、Next.jsアプリケーションでは100〜300KBになります。19

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

サーバーサイドで行います。フォーム送信時に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 エンドポイントに接続できます:

<div hx-ws="connect:/ws/notifications">
  <div id="notifications" hx-ws="send"></div>
</div>

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

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

サーバーレンダリングされた HTML は、クローラーが JavaScript を実行せずに完全なページコンテンツを受け取れるため、本質的にSEOに適しています。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、フックシステム、状態管理ライブラリ、ビルドツールの設定を学ぶ必要はありません。

HTMX のドキュメントは1つの長いページに収まります。Alpine.js のドキュメントも数ページに収まります。一方、Reactのドキュメントはフック、コンテキスト、ref、エフェクト、サスペンス、サーバーコンポーネント、ストリーミング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年3月時点のblakecrosley.com本番環境メトリクスです。37件のブログ記事、20のインタラクティブなJavaScriptコンポーネント、20のガイドセクション、10言語の翻訳を、15のPythonパッケージとビルドツールゼロで提供しています。依存関係の全リスト:fastapi, uvicorn, starlette, pydantic, pydantic-settings, jinja2, markdown, pygments, beautifulsoup4, lxml, nh3, resend, python-multipart, httpx, analytics-941。requirements.txtから検証済みです。 

  2. Google PageSpeed Insights(pagespeed.web.dev)は、任意の公開URLに対してLighthouse監査を実行します。blakecrosley.comは2026年3月時点で100/100/100/100(Performance、Accessibility、Best Practices、SEO)を達成しています。結果は誰でも検証可能です。最適化の全過程については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は後方互換性をWebデザインの原則として明確にしました。「ブラウザは後方互換性を持つべきである」と述べています。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 Custom Properties(CSS Variables)は、グローバルブラウザの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. media="print"による非同期CSS読み込みテクニックは、web.devチームによって文書化されています。ブラウザは、印刷メディア用として宣言されているため、このスタイルシートをレンダリングブロッキングしないものとして扱います。onloadハンドラがダウンロード後にallメディアにアップグレードします。web.dev/articles/defer-non-critical-cssを参照してください。 

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

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

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

  20. HTMXのバージョニングポリシーと後方互換性のコミットメントはhtmx.org/migration-guide-htmx-1/に文書化されています。Carson GrossはHypermedia Systems(2023年、Gross、Stepinski、Cotter共著)でこの後方互換性の原則を述べています:hypermedia.systems。 

NORMAL fastapi-htmx.md EOF