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

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

# React、webpack、ビルドツールなしで本番Webアプリケーションを構築するための完全なシステムです。FastAPIバックエンド、サーバー駆動のインタラクティビティを実現するHTMX、クライアント状態管理のためのAlpine.js、Jinja2テンプレート、そしてプレーンなCSSで構成されています。ユーティリティフレームワークを求めるチーム向けに、Bootstrap 5のパターンも含まれています。

words: 1469 read_time: 42m updated: 2026-04-30 04:43
$ 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

FAQ

HTMX は実際の Web アプリケーションで本番運用に耐えられますか?

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

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

部分的には可能です。TypeScript ファイルは tsc --noEmit で型チェックでき、出力ファイルを生成せずにコンパイル時チェックを linter として活用できます。ただし、ブラウザは .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 構文は引き続き動作します。

HTMX 4.0(2026年4月時点で beta): HTMX は2026年4月9日に4.0 betaサイクルへ突入しました(GitHub 上で v4.0.0-beta1v4.0.0-beta2)。本ガイドは HTMX 2.x を対象としており、これが安定版ラインで本番運用に推奨されるバージョンです。4.0 GA がリリースされた後にこのセクションを再確認してください。2.x → 4.x の移行は世代交代であり、2.x のポイントリリースではありません。big-skies-software のバージョニングパターンでは奇数のメジャーバージョンが飛ばされるため、4.0 は 2.x の次のステップとなります。

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

このスタックは SEO にどう対応しますか?

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

  • すべてのページの <head>JSON-LD 構造化データ(Person、Article、WebSite、FAQPage スキーマ)
  • 全10ロケールの hreflang 代替を含む動的サイトマップ
  • /blog/feed.xmlRSS フィード
  • AI クローラーへの可視性を高めるための、ルート直下の llms.txt
  • ベーステンプレート内の正規 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 のドキュメントは、フック、コンテキスト、refs、エフェクト、サスペンス、サーバーコンポーネント、ストリーミング SSR をカバーする数百ページに及びます。

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

変更履歴

日付 変更内容
2026-04-25 FastAPI 0.136.1(2026年4月23日): Pydantic v2 の非推奨機能のクリーンアップ(アプリコードへの動作変更はありません)。HTMX 4.0 のタイムラインが確定: htmx 4.0.0-beta1(4月6日)と 4.0.0-beta2(4月14日)がリリース、リリース候補は2026年5月〜6月、安定版は晩夏を予定。移行ガイダンスは変更なし — htmx 2.x は 4.0 が安定するまで最新の npm タグを維持し、セキュリティ修正は継続、アップグレードを急ぐ必要はありません。今のうちに設計上意識しておくべき 4.0 の主要変更点: (1)コアの ajax 基盤として XMLHttpRequest の代わりに fetch() を採用、(2)属性継承がデフォルトで明示的に、(3)履歴サポートが復元コンテンツに対してネットワークリクエストを発行(ローカル DOM スナップショットなし)。FastAPI 0.135.4(4月16日)では、0.135.3 に紛れ込んだエイプリルフール用の @app.vibe() デコレーターが削除されました。
2026-04-16 HTMX 4.0-beta への対応認識を追加(前方参照)。FastAPI 0.136.0 が Python 3.14t フリースレッドビルドをサポートすることに言及。Pydantic 2.13.x の機能(検証済みモデルデータにアクセス可能なプライベート属性のデフォルトファクトリ、pydantic.v1 名前空間を 1.10.26 に更新し 3.14 をサポート)。Alpine.js 3.15.11 の修正: x-anchor.noflip 修飾子、x-for の複数ルート要素警告、$refs の morph 回帰修正。
2026-03-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月時点で 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/ に合計 187 MB の 311 パッケージをインストールします。追加の依存関係を持つ本番プロジェクトでは、さらに増加する傾向があります。プロジェクトごとに差があります。出典: 著者によるテスト、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. 著者が 2021〜2024 年に Next.js プロジェクトを保守してきた経験に基づくと、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 セクション 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)が preload ヒント付きの予備レスポンスを送信できる仕組みです。Cloudflare は rel=preload 付きの Link ヘッダーで Early Hints をサポートします。developer.chrome.com/blog/early-hints を参照。 

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

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

NORMAL fastapi-htmx.md EOF