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

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

# Reactやwebpackを使わずに本番向けWebアプリを構築できます。FastAPI、HTMX、Alpine.js、Jinja2、プレーンCSS、Bootstrapパターン、i18n、デプロイ、SEO、パフォーマンスを扱います。

words: 2570 read_time: 45m updated: 2026-06-22 16:48
$ less fastapi-htmx.md

TL;DR: FastAPI + HTMX + Alpine.js + Jinja2 + plain CSS により、ビルドツールなし、node_modules/ なし、Lighthouse スコア満点の本番環境向けWebアプリを構築できます。このガイドでは、アーキテクチャからデプロイまでシステム全体を扱います。本番環境の参照例として blakecrosley.com を使います。同サイトは、210本のブログ記事、インタラクティブな JavaScript コンポーネント、11本のコアガイド、48件のデザインスタディ、英語と9つの翻訳ロケールを、bundler、compiler、transpiler を一切使わずに配信しています。1

現代のWeb開発スタックでは、React、webpack、TypeScript、そしてビルドパイプラインが必要だと考えられがちです。しかし、コンテンツ中心のサイト、社内ツール、CRUDアプリ、ポートフォリオサイト、ドキュメントプラットフォームなど、多くの種類のアプリでは、その前提は正しくありません。このガイドで説明するスタックは、フロントエンドのビルドツールチェーン全体を取り除きながら、Lighthouse で 100/100/100/100 を達成するサイトを実現します。2

これは主張ではありません。測定結果です。ここで説明するアーキテクチャは本番環境で稼働しており、10言語で実際のユーザーに提供されています。そして、その数値は検証可能です。


重要なポイント

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

本ガイドの使い方

これは包括的なリファレンスです。ご自身の経験レベルに合った場所から読み始めてください。

経験 開始地点 次に読む
Python開発者で、HTMXは初めて The No-Build ThesisArchitecture OverviewHTMX Deep Dive Alpine.js PatternsSecurity
代替案を検討中のReact/Vue開発者 The No-Build ThesisDecision Framework Architecture OverviewPerformance
インタラクティビティを追加するFastAPI開発者 HTMX Deep DiveAlpine.js Patterns i18n and LocalizationDeployment
ゼロから構築するフルスタック開発者 Architecture Overviewから順に読む 継続的に活用するためのQuick Reference Card

特定のパターンや属性を探すにはCtrl+F / Cmd+Fを使ってください。末尾のQuick Reference Cardはざっと一覧できる要約となっています。


ノービルド・テーゼ

このテーゼは限定的かつ具体的です。ソロ開発者または小規模チームによるコンテンツ駆動型のサイトにおいて、ビルドツールは存在しない問題を解決する一方で、本来なかった問題を作り出してしまうのです。

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

指標 blakecrosley.com(ノービルド) 典型的なNext.jsプロジェクト3
依存関係 Pythonパッケージ17個 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秒で公開 Install → build → deploy:2〜5分
Lighthouseパフォーマンス 100 明示的な最適化なしで70〜904

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

諦めるもの

正直であるためには、実際のコストを列挙する必要があります。

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

Hot Module Replacementがない。 CSSの変更にはブラウザの手動リフレッシュが必要です。HTMXのhx-boostによりナビゲーションは十分高速で、フルリフレッシュも許容範囲ですが、視覚的な反復作業が密な場面ではHMRの方が時間を節約できます。

Tree Shakingがない。 書いたJavaScriptは1バイト残らずブラウザに届きます。この制約が規律を強制します。大きなユーティリティモジュールではなく、小さく焦点を絞ったファイルになるのです。

npmコンポーネントライブラリがない。 Radixも、shadcn/uiも、Headless UIもありません。すべてのインタラクティブ要素は手作りするか、Bootstrap 5の組み込みコンポーネントを使います。

npmからのデザインシステムトークンがない。 デザインシステムはCSSカスタムプロパティに存在します。別プロジェクトにパッケージとしてimportすることはできません。

これらのトレードオフは、開発者1〜3人のコンテンツ駆動型サイトでは許容できます。15人のエンジニアリングチームを抱えるSaaSプロダクトでは許容できないでしょう。Section 15に意思決定フレームワークを示します。

得られるもの

ビルド失敗ゼロ。 peer dependency競合でnpm installが失敗することはありません。触ってもいないファイル内のTypeScriptエラーでnext buildが失敗することもありません。6

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

ローカル起動が即座。 uvicorn app.main:app --reloadは2秒未満で起動します。

具体的なリクエストウォーターフォール。 初回訪問時の読み込みは、HTMLドキュメント1つ(gzip後約15KB)、CSSファイル1つ(約8KB)、HTMX(約16KB、キャッシュ可)、Alpine.js(約15KB、キャッシュ可)、ページのインタラクティブJS(約4〜8KB)。合計、初回訪問でおよそ55〜65KBです。1

将来も陳腐化しないフロントエンド。 クライアント側コードはHTML、CSS、JavaScriptを使用しており、これらは30年間にわたり後方互換性を維持してきた標準です。7 Webpack 4から5への移行も、Create React Appの非推奨化も、Next.js App Routerへの移行もありません。

スタック比較

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

観点 FastAPI+HTMX(本ガイド) Next.js(React) Astro 11ty
ブラウザに送信されるJS 35〜40KB(HTMX+Alpine+小規模ページスクリプト) 85〜250KB以上(Reactランタイム) デフォルト0KB、オプトインのislands デフォルト0KB
ビルドステップ なし 必要(webpack/turbopack) 必要(Vite) 必要(カスタム)
設定ファイル 0 5〜8(next.config、tsconfigなど) 1〜3(astro.config、tsconfig) 1〜2(.eleventy.js)
デプロイパイプライン git push(40秒) Install+build+deploy(2〜5分) Install+build+deploy(1〜3分) Install+build+deploy(1〜2分)
サーバー側インタラクティビティ ネイティブ(HTMX) APIルート+クライアントfetch 限定的(form actions) なし(静的出力)
クライアント状態管理 Alpine.js(15KB) React state/context/Redux フレームワークislands 手動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または11tyの方が適している場合もあります。


アーキテクチャ概要

リクエストフロー

すべてのリクエストは、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 で初期化し、middleware の順序を明示します。

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 つのデザイン判断が重要です。1 つ目は、docs_url=Noneopenapi_url=None によって、自動生成される API ドキュメントエンドポイントを無効化していることです。一般公開するコンテンツサイトでは、/docs/openapi.json をインターネットに公開する必要はありません。8 2 つ目は、middleware の順序が重要だという点です。セキュリティログは最初に実行されるよう最後に追加し、レート制限で拒否されたものも含め、すべてのリクエストを記録します。3 つ目は、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 ルートは、HTMX が既存の DOM 要素に差し替える HTML フラグメントを返します。どちらも同じ Jinja2 テンプレートエンジンでレンダリングします。別の API レイヤーは必要ありません。

Dependency Injection

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 Settings

設定には、環境変数を優先する 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 クラスは起動時に型を検証します。必須フィールドが欠けている場合、実行時ではなく早い段階で失敗します。

Async パターン

FastAPI のルートはデフォルトで async です。I/O バウンドな処理(database クエリ、HTTP リクエスト、ファイル読み取り)では、async によってイベントループのブロックを防げます。

@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)

現在、起動と終了の経路は lifespan のみです。 Starlette は 2026年3月に最初の安定版リリース 1.0 に到達し(6月12日時点で 1.3.1)、長らく非推奨だった on_eventon_startupon_shutdown フックを削除しました。現在は lifespan(上記)が唯一の仕組みであり、@app.route() / @app.websocket_route()routes リスト内の Route / WebSocketRoute に置き換わりました。FastAPI 0.137.0(2026年6月14日)は Starlette を 1.x 系に固定し、自身の router 内部もリファクタリングしています。router.routes は、もはや APIRoute オブジェクトのフラットなリストではなく、中間ノードを含むツリーです。そのため、反復処理の対象ではなく内部詳細として扱ってください。利点として、include_router()後に router に追加したルートが即座に反映されるようになり、sub-router はルート定義前でも include できるようになりました。24 これらは、このガイドのパターンを変えるものではありません。このガイドでは全体を通して lifespan と標準的なルート宣言を使っています。ただし、router.routes をたどるツールを保守している場合や、まだ従来の @app.on_event ハンドラーを動かしている場合、0.137.0 / Starlette 1.0 は破壊的変更になります。FastAPI 0.137.2(2026年6月18日)では、router.routes が内部実装になった現在、ルートを列挙するためのサポート済み方法として iter_route_contexts() が追加されています。続く FastAPI 0.138.0(2026年6月20日)では、ビルド済みの静的フロントエンドを配信するための app.frontend("/", directory="dist") / router.frontend(...) が追加されました。別の SPA build を配信する場合には便利ですが、このガイドの no-build、server-rendered のアプローチとは別のものです(サーバー側で HTML をレンダリングするのではなく、dist/ ディレクトリをマウントします)。25

CPU バウンドな処理(Markdown レンダリング、CSS 抽出)には同期関数を使えます。ルートハンドラーが async として宣言されていない場合、FastAPI はそれらを自動的に thread pool で実行します。

# 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時間キャッシュされます。


Database Patterns

注: blakecrosley.com では、永続データすべてに SQLAlchemy ではなく、HTTP 経由の Cloudflare D1(serverless SQLite)を使用しています。このセクションでは、リレーショナルデータベースが必要な FastAPI プロジェクト向けに、標準的な SQLAlchemy async パターンを扱います。このスタックで最も一般的な本番構成です。

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

インストール時の注意(SQLAlchemy 2.0.50+): 2.0.50 以降、async スタックの greenlet 依存関係はデフォルトではインストールされなくなりました。asyncio extra を使って取り込むようにしてください。そうしないと、engine に対する最初の await で missing-greenlet エラーが発生します。23

pip install "sqlalchemy[asyncio]" aiosqlite

SQLAlchemy 2.0.50 では、Python 3.10+ も必要です(3.7〜3.9 はサポート対象外)。また、free-threaded(3.13t)wheel も追加されています。23

データベースセッションの Dependency Injection

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 依存関係はセッションのライフサイクルを管理します。セッションを開き、route handler に渡し、成功時には commit、例外時には rollback します。すべてのデータベース操作では、文字列補間ではなく、必ずパラメーター化クエリを使用します。

Pydantic 連携

Pydantic モデルは API 境界で入力を検証し、templates 向けに出力をシリアライズします。

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 は route handler が実行される前に、型、形式(email、URL)、制約(最小/最大長)を検証します。無効な入力は自動的に 422 レスポンスを返します。これにより、クライアント側のフォーム検証ライブラリを置き換えられます。サーバーが検証し、HTMX が成功メッセージまたはエラーフィードバックを差し替えます。

Alembic による Migrations

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 モデルと現在のデータベーススキーマを比較し、migration scripts を生成します。これらの script はリポジトリ内に置かれる、バージョン管理された 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")

Migrations はデプロイ中(アプリケーション起動前)に実行します。これにより、データベーススキーマがアプリケーションコードと一致します。blakecrosley.com では、ほとんどのデータは Cloudflare D1(HTTP 経由でアクセス)にあります。そのため、Alembic migrations はセッションデータや分析に使うローカル 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 の edge で動作する SQLite で、HTTP 経由でアクセスします。Worker プロキシは認証とレート制限を扱います。トレードオフはレイテンシです。ローカルデータベース接続(約1〜5ms)に対し、すべてのクエリが HTTP リクエスト(約50〜100ms)になります。翻訳のような読み取りが多いワークロードでは、起動時のインメモリキャッシュによってこの影響を軽減できます。


セキュリティ

セキュリティヘッダーMiddleware

blakecrosley.comでは、カスタムmiddlewareを通じて強化されたセキュリティヘッダーを実装しています。

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キャッシュ安全性

セキュリティヘッダーmiddlewareは、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の読み込み戦略

blakecrosley.comは、単一の<link>タグと、immutable caching用のコンテンツハッシュ付きURLでCSSを読み込みます。

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

asset()ヘルパーはコンテンツハッシュ(?v=a3b2c1d4)を付与するため、コンテンツが変更されるまでブラウザーはファイルを無期限にキャッシュします。critical 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まで小さくなります。

Immutableな静的アセットキャッシュ

# 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読み込み

<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の解析と並行してscriptsをダウンロードし、ドキュメントの解析後に実行します。これにより、async読み込みや実行順序管理の複雑さを避けつつ、レンダリングブロックを防げます。

画像最適化

画像には、レスポンシブな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>

明示的なwidth属性とheight属性は、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 + gzipped)
HTMX ~16 KB
Alpine.js ~15 KB
Page-specific JS 4-8 KB
合計 35-39 KB

一般的なReactアプリケーションは、アプリケーションコードより前に100〜300KBのframework JavaScriptを配信します。18 no-buildアプローチで配信する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 builder は requirements.txt から Python プロジェクトを検出し、依存関係をインストールして、start command を実行します。Dockerfile は不要です。health check endpoint により、トラフィックを受ける前にアプリケーションが応答可能であることを確認できます。

@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 compilation もありません。TypeScript compilation もありません。唯一の install step は 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} 構文では、プラットフォームが提供する port を使用し、ローカル開発ではデフォルトで 8000 を使います。

Uvicorn の本番設定

トラフィックが多いデプロイでは、複数の worker を使用します。

uvicorn app.main:app \
  --host 0.0.0.0 \
  --port ${PORT:-8000} \
  --workers 4 \
  --loop uvloop \
  --http httptools
  • --workers 4 は 4 つの worker process を実行します(一般的な目安: CPU cores * 2 + 1)
  • --loop uvloop は、より高速な uvloop event loop を使用します(asyncio の drop-in replacement)
  • --http httptools は、より高速な httptools HTTP parser を使用します

開発時は、--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 base image により container を小さく保てます。--no-cache-dir は、pip がダウンロードした packages を image layer に保存しないようにします。

Cloudflare CDN

blakecrosley.com は CDN caching、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 — browser は 5 分間キャッシュします
  • s-maxage=3600 — CDN は 1 時間キャッシュします
  • stale-while-revalidate=86400 — 24 時間、再検証中に stale content を配信します

Static assets には max-age=31536000, immutable を設定します。content-hash URLs によって鮮度が保証されるためです。


判断フレームワーク

Build Tools は必要ですか?

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

1. 5 人を超える開発者が JavaScript interfaces を共有していますか? はいの場合、TypeScript の compile-time type checking は、runtime testing では発見が遅すぎる integration bugs を防ぎます。build step を追加しましょう。

2. アプリケーションは複雑な client-side state を管理しますか? drag-and-drop、real-time collaboration、offline-first data が中核機能(あると便利な機能ではなく)であれば、React や Svelte のような framework は、その複雑さに見合います。build step を追加しましょう。

3. 複数の product が shared component library を利用しますか? はいの場合、その library には npm packaging、semantic versioning、tree shaking が必要です。build step を追加しましょう。

4. bundler を前提とする npm ecosystem libraries に依存していますか? Radix、Framer Motion、TanStack Query、または同様の libraries が product の中核であれば、build pipeline は必須です。

4 つすべての答えが「いいえ」なら、no-build approach は現実的です。どれか 1 つでも「はい」なら、build tools は実在する問題を解決します。間違いは、4 つすべてが「いいえ」なのに build tools を追加することです。つまり、持っていない問題を解決しながら、dependency management の overhead を生み出してしまうのです。1

Stack 比較

Category No-Build(このガイド) React + Build Tools
最適な用途 Content sites、portfolios、internal tools、CRUD apps SaaS products、complex SPAs、design system consumers
チーム規模 1-5 developers 5-50+ developers
State management Server(HTMX)+ client(Alpine.js) Client(React state、Redux、Zustand)
Type safety Runtime(Pydantic server-side) Compile-time(TypeScript)
Component reuse Jinja2 includes + macros npm packages、shared libraries
SEO デフォルトで server-rendered SSR/SSG configuration が必要
Performance floor 高い(最小限の JS、server-rendered) ばらつきあり(framework overhead)
Complexity ceiling 低め(offline なし、rich client state なし) 高い(あらゆる client interaction が可能)
Dependencies 17 個の Python packages 300+ npm packages
Build time 0 秒 15-60 秒

HTMX が適さない場合

HTMX は client state を server round-trips に置き換えます。これは latency が重要になるまで有効です。

  • Drag-and-drop interfaces — drag event ごとに 200ms の server round-trip が発生するのは受け入れられません
  • Real-time collaboration — WebSocket-driven state には client-side conflict resolution が必要です
  • Offline-first applications — server がなければ HTMX は使えません
  • State に紐づく複雑な animations — Framer Motion と React Spring は React reconciliation model を前提としています
  • Canvas/WebGL applications — rendering loop は本質的に client-side です

これらの use cases では、client-side framework が適切なツールです。no-build approach は、それらを置き換えようとするものではありません。


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

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 Attributes

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 Attributes

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 Custom Properties

: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; }
}

Security Headers

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=()

Project Setup Checklist

[ ] 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 後で約16KB、依存関係はゼロで、セマンティックバージョニングに従っています。blakecrosley.com では3年間、本番環境で HTMX を使っており、HTMX に関連するバグはゼロです。20

ビルドステップなしで 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 の server-side rendering(SSR)はどうですか?

Next.js SSR と FastAPI + HTMX のアプローチは、サーバーでレンダリングした HTML をブラウザへ送るという目的を共有しています。違いは、初期レンダリング後に何が起きるかです。Next.js は React でページを hydrate し、フレームワークのランタイムとコンポーネントコードをクライアントへ送ります。FastAPI + HTMX は hydrate しません。HTML が最終出力です。以降のインタラクションは、HTMX がサーバーから新しい HTML フラグメントをリクエストして処理します。その結果、FastAPI + HTMX が送る JavaScript は合計でおよそ35〜40KBなのに対し、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 エンドポイントへ接続する WebSocket 拡張(hx-ws)があります。

<!-- 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 beta トラック: htmx 4.0.0-beta4 は現在 npm の next タグと 4.0 ドキュメントで公開されています。一方、htmx.org の quick start と npm の latest タグは 2.0.10 のままです。このガイドは引き続き HTMX 2.x を対象にしています。4.0 が安定するまでは、本番作業では 2.x が推奨バージョンです。2.x -> 4.x の移行は、2.x のポイントリリースではなく、世代をまたぐジャンプです。big-skies-software のバージョニングパターンでは奇数メジャーをスキップするため、4.0 が 2.x の次のステップになります。2122

4.0 ドキュメントで追跡する価値がある点。 4.0 GA の前にセキュリティとアーキテクチャレビューで特に目立つ追加点が2つあります。新しい hx-live 拡張は、参照している状態が変わると再評価される DOM リアクティブな式を導入します。また、新しい hx-nonce 拡張は、CSP nonce の背後で htmx 属性処理を制御します。4.0 移行ガイドでは、いくつかの設定概念も移動され、一部のイベント/履歴の挙動が復元または変更され、いくつかの JavaScript ヘルパーが core から削除されています。4.0 は 2.x にそのまま適用できるパッチではなく、移行プロジェクトとして扱ってください。21

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

このスタックは SEO をどう扱いますか?

サーバーでレンダリングされた HTML は、クローラーが JavaScript を実行せずに完全なページ内容を受け取れるため、本質的に SEO に向いています。blakecrosley.com では、いくつかの SEO レイヤーを追加しています。

  • すべてのページの <head>JSON-LD 構造化データ(Person、Article、WebSite、FAQPage スキーマ)
  • 10ロケールすべての hreflang alternates を含む 動的 sitemap
  • /blog/feed.xmlRSS feed
  • AI クローラーの発見性のためにルートへ配置した llms.txt
  • ベーステンプレート内の Canonical URLsOpen Graph tags
  • セマンティック HTML: <article><section><main>、適切な見出し階層

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

React と比べて学習曲線はどうですか?

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

HTMX のドキュメントは、長い1ページに収まります。Alpine.js のドキュメントは数ページに収まります。React のドキュメントは、hooks、context、refs、effects、suspense、server components、streaming SSR などを扱う数百ページにわたります。

JavaScript/React 開発者にとっての変化は、構文よりも概念面にあります。核となる考え方は、状態をサーバーが持ち、HTML をサーバーがレンダリングするということです。クライアントサイドの状態管理は、サーバーサイドのルート処理になります。クライアントサイドのデータ取得は、HTML 要素上の HTMX 属性になります。構文はよりシンプルです。ただし、クライアントがレンダリングを所有するという SPA の前提をいったん手放す必要があります。


変更履歴

日付 変更
2026-06-22 FastAPI 0.138.0 + 0.137.2。 0.138.0(6月20日)では、ビルド済みの静的 frontend(SPA の dist/ 出力)を配信するための app.frontend("/", directory="dist") / router.frontend(...) が追加されました。本ガイドのビルドなし・サーバーレンダリングという主張とは別軸の機能であり、Async Patterns セクションでは対比として触れています。0.137.2(6月18日)では、router.routes が内部用になった(0.137.0 以降)ことを受け、ルートを列挙する正式な方法として iter_route_contexts() が追加されました。どちらも機能追加であり、破壊的変更はありません。Starlette(1.3.1)、Pydantic(2.13.4)、HTMX(2.0.10)、Alpine.js(3.15.12)、Bootstrap(5.3.8)、SQLAlchemy(2.0.51)はすべて変更ありません。
2026-06-16 FastAPI 0.137.0/0.137.1 + Starlette 1.0→1.3.1。 FastAPI 0.137.0(6月14日)では router 内部がリファクタリングされ、router.routes はフラットな APIRoute リストではなく内部ツリーになりました(これを反復処理していたコードには破壊的変更です)。一方で、include_router() 後に追加されたルートや、新しい APIRouter.matches() / .handle() フックが使えるようになっています。0.137.1(6月15日)では、APIRoute の型付けと、空パスのプレフィックスなし router が修正されました。Starlette は最初の安定版 1.0(3月22日)をリリースし、現在は 1.3.1(6月12日)です。非推奨だった on_event / on_startup / on_shutdown フックと、@app.route() / @app.websocket_route() デコレーターは削除され、lifespanRoute / WebSocketRoute のみが利用経路となりました。FastAPI 0.137.0 は Starlette 1.3.1 を固定しています。Async Patterns セクションに lifespan/router の注記を追加しました。SQLAlchemy 2.0.51(6月15日)はバグ修正のみです。
2026-06-08 SQLAlchemy 2.0.50 の async インストール変更。 SQLAlchemy 2.0.50 以降、async スタックの greenlet 依存関係はデフォルトではインストールされなくなりました。sqlalchemy[asyncio] extra をインストールしてください(そうしないと、engine に対する最初の await が missing-greenlet エラーで失敗します)。2.0.50 では Python 3.10+ も必須になり(3.7〜3.9 は対象外)、free-threaded 3.13t wheel も追加されました。SQLAlchemy 2.0 Async セクションにインストール注記を追加しました。スタックの他の部分について本文変更はありません。FastAPI の最新は引き続き 0.136.3(2026-05-23、6月リリースなし)、htmx stable は 2.0.10 のままです(4.0.0-beta4 “The Fetchening” は beta で、stable 目標はおおよそ 2027年初頭、まだ本番推奨ではありません)。Alpine.js 3.15.12、Bootstrap 5.3.x も変更ありません。本番推奨も変更なしです。4.0 stable までは HTMX 2.x を使用してください。23
2026-05-24 メンテナンス確認: ローカルのコンテンツ inventory では、引き続き blog post 210件、core guide 11件、design study 48件、English を含む対応 locale 10件が確認されています。FastAPI の最新は 0.136.3(2026-05-23)です。リリースノートでアプリ側への影響として明記されている唯一のリファクタリングは、convert_underscores=True の場合の underscore-header 処理が厳格化されたことです。また 0.136.2 では、壊れた event data を避けるために Server-Sent Event フィールドの検証が追加されました。htmx stable は 2.0.10 のままですが、npm next と 4.0 docs は現在 4.0.0-beta4 を指しています。SQLAlchemy 2.0 の最新は 2.0.50、Pydantic の最新は引き続き 2.13.4 です。本番推奨は変更ありません。4.0 が stable になるまでは HTMX 2.x を使用してください。122
2026-05-18 サイト inventory 更新: ローカルのコンテンツ inventory では、blog post 210件、core guide 11件、design study 48件、English を含む対応 locale 10件になりました。FastAPI の最新は引き続き 0.136.1、htmx stable は 2.0.10 のままで、npm next は 4.0.0-beta3 です。Alpine.js の npm latest は引き続き 3.15.12 です。本番推奨は変更ありません。4.0 が stable になるまでは HTMX 2.x を使用してください。12021
2026-05-15 メンテナンス確認: FastAPI の最新は引き続き 0.136.1 です。このローカルサイト環境では FastAPI 0.128.0 と Starlette 0.50.0 を import しています。htmx stable は 2.0.10 のままで、npm next は 4.0.0-beta3 になりました。Alpine.js の npm latest は 3.15.12、Bootstrap の最新は 5.3.8、SQLAlchemy 2.0 の最新は 2.0.49、Pydantic の最新は 2.13.4 です。本番推奨は変更ありません。4.0 が stable になるまでは HTMX 2.x を使用してください。2021
2026-05-09 htmx 4.0.0-beta3 の追跡(2026年5月8日): htmx 4.0.0-beta3 は npm next タグと 4.0 ドキュメントで利用できます。一方、npm latest は 2.0.10 のままです。GA 前に追跡しておきたい主な変更点は、新しい hx-live extension(DOM-reactive expressions)、新しい hx-nonce extension(htmx 属性向けの CSP nonce 保護)、そして configuration、history、events、core JavaScript helpers に関する migration guide の変更です。本番推奨は変更ありません。htmx 2.x が引き続き最新の npm tag であり、4.0 GA までは推奨バージョンです。21
2026-05-07 メンテナンス確認: FastAPI の最新は引き続き 0.136.1 です。htmx stable は 2.0.10 で、v4 は Summer ‘26 目標の beta のままです。Alpine.js の npm latest は 3.15.12、Bootstrap の最新は 5.3.8、SQLAlchemy 2.0 の最新は 2.0.49、Pydantic の最新は 2.13.4 です。サイトローカルの指標は、blog post 182件、guide 11件、対応 locale 10件、Python requirements 17件に更新されました。migration guidance は変更ありません。4.0 が stable になるまでは、本番では HTMX 2.x を使用してください。20
2026-04-25 FastAPI 0.136.1(2026年4月23日): Pydantic v2 の非推奨項目のクリーンアップです(アプリコードの挙動変更はありません)。HTMX 4.0 timeline を追跡: htmx 4.0.0-beta1(4月6日)と 4.0.0-beta2(4月14日)がリリースされました。migration guidance は変更ありません。htmx 2.x は 4.0 が stable になるまで latest npm tag のままです。security fix は継続されており、急いで upgrade する必要はありません。今から設計上考慮しておきたい 4.0 の主要変更は、(1) core ajax infra として XMLHttpRequestfetch() に置き換わること、(2) attribute inheritance がデフォルトで明示的になること、(3) history support が復元コンテンツに対して network request を発行すること(local DOM snapshot なし)です。FastAPI 0.135.4(4月16日)では、0.135.3 に入った April Fool’s の @app.vibe() decorator が削除されました。
2026-04-16 HTMX 4.0-beta への認識を追加しました(forward-reference)。FastAPI 0.136.0 が Python 3.14t free-threaded build をサポートしたことを記載しました。Pydantic 2.13.x の機能(validated model data access を伴う private-attribute default factory、3.14 support を含む 1.10.26 への pydantic.v1 namespace)。Alpine.js 3.15.11 の修正: x-anchor.noflip modifier、x-for multiple-root-element warning、$refs morph regression fix。
2026-03-24 初版公開

参照


このガイドでは、blakecrosley.com の構築に使われている完全なシステムを扱います。No-Build Manifesto では、その背後にある思想的な主張を説明しています。Lighthouse Perfect Score の記事では、パフォーマンス最適化の道のりを記録しています。Vibe Coding vs. Engineering の記事では、AI 支援開発がこのワークフローのどこに位置づけられるのかを探っています。


  1. 2026年5月18日時点の blakecrosley.com 本番指標です。このサイトには、210本のブログ記事、インタラクティブな JavaScript コンポーネント、11本の主要ガイド、48件のデザインスタディ、英語と9つの翻訳ロケール、最小限の Python 依存関係、そしてビルドツールなしの構成があります。ローカルのコンテンツインベントリ、app/i18n/config.pyrequirements.txt から確認済みです。 

  2. Google PageSpeed Insights(pagespeed.web.dev)は、任意の公開 URL に対して Lighthouse 監査を実行します。blakecrosley.com は、2026年3月時点で 100/100/100/100(パフォーマンス、アクセシビリティ、ベストプラクティス、SEO)を記録しています。結果は公開情報として検証できます。最適化の全過程は 76から100へ:Lighthouse満点スコアを達成するまで を参照してください。 

  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年5月時点の blakecrosley.com の requirements.txt から確認済みです。このファイルには現在、17件の Python 要件エントリがあり、ビルドツール、コンパイラ、バンドラーは含まれていません。 

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

  7. Tim Berners-Lee は、Web デザイン原則として後方互換性を示し、「ブラウザは後方互換であるべき」と述べています。1996年のページは Chrome 2026 でも表示されます。w3.org/DesignIssues/Principles を参照してください。 

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

  9. async と sync ハンドラーに関する 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. 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 + gzipped で約 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。 

  20. 2026年5月15日のメンテナンス確認です。FastAPI の PyPIリリースノート では 0.136.1 が掲載されています。ローカル import 検証では、このサイト環境で FastAPI 0.128.0 と Starlette 0.50.0 が返りました。htmx.org はクイックスタートで 2.0.10 を掲載しています。npm view htmx.org version dist-tagslatest=2.0.10next=4.0.0-beta3 を返しました。npm view alpinejs versionnpm view @alpinejs/csp version3.15.12 を返しました。Bootstrap の 公式ブログ と npm パッケージメタデータは 5.3.8 を掲載しています。SQLAlchemy の PyPI とドキュメントは 2.0.49 を掲載しています。Pydantic の PyPI は 2.13.4 を掲載しています。 

  21. htmx 4.0.0-beta3 のパッケージメタデータには 2026年5月8日の公開日が記載され、npm next4.0.0-beta3 を指していました。npm latest は 2.0.10 のままでした。four.htmx.org の 4.0 ドキュメントには [email protected] が示され、4.0 extensions index には hx-livehx-nonce が掲載され、4.0 migration guide には本番アプリを 2.x から移行する前に確認すべき移行変更が記載されていました。最新系列の追跡としては 22 に置き換えられています。 

  22. 2026年5月24日のメンテナンス確認です。ローカルのインベントリコマンドでは、210本の Markdown ブログ記事、11本のトップレベルガイドファイル、48件のデザインスタディファイルが返りました。FastAPI の リリースノート では、2026-05-23 に 0.136.3 が掲載されており、convert_underscores=True の場合の underscore-header 処理がより厳密になっています。0.136.2 では Server-Sent Event フィールドを検証します。python3 -m pip index versions fastapi は最新 0.136.3 を返しました。python3 -m pip index versions sqlalchemy は最新 2.0.50 を返しました。python3 -m pip index versions pydantic は最新 2.13.4 を返しました。npm view htmx.org dist-tags version time.modified --jsonlatest=2.0.10next=4.0.0-beta4time.modified=2026-05-22T15:56:21.948Z を返しました。four.htmx.org installation docs には [email protected] が示されています。 

  23. SQLAlchemy 2.0.50 changelogrelease blog は、2026-05-24 にリリースされました。asyncio の greenlet 依存関係はデフォルトではインストールされなくなり、取り込むには sqlalchemy[asyncio] インストールターゲットが必要になりました。2.0.50 では Python 3.7/3.8/3.9(現在は 3.10+)のサポートも終了し、free-threaded Python wheels が追加され、over(..., exclude=...) window-frame パラメータも追加されています。2026-06-08 時点で PyPI により最新を確認済みです。htmx 4.0.0-beta4(”The Fetchening,” 2026-05-22)は、2027年初頭の安定版を目標とする beta のままです。FastAPI 0.136.3(2026-05-23)、Alpine.js 3.15.12、Bootstrap 5.3.x はこの期間では変更ありません。 

  24. FastAPI の リリースノート:0.137.0(2026-06-14)は router 内部をリファクタリングし、router.routesAPIRoute オブジェクトのフラットなリストではなく、中間オブジェクトのツリーになりました(内部扱いにしてください)。また、include_router() 後のルート追加、ルート定義前の sub-router 追加、ルートコピーの回避、APIRouter.matches()/.handle() の追加にも対応しています。Starlette 1.3.1 を pin しています。0.137.1(2026-06-15)は、APIRoute の型付けと、prefix なし router の空パスを修正しています。Starlette の リリースノート:1.0.0(2026-03-22)は約8年ぶりの初の安定リリースで、on_startup/on_shutdown/on_event()@app.route()/@app.websocket_route() デコレーターを削除しました(lifespanRoute/WebSocketRoute を使用します)。最新は 1.3.1(2026-06-12)です。SQLAlchemy 2.0.51(changelog、2026-06-15)はバグ修正のみで、async やインストールへの影響はありません。2026-06-16 に PyPI と公式リリースノートで確認済みです。 

  25. FastAPI の リリースノート:0.138.0(2026-06-20)は、ビルド済み static frontend を提供するための app.frontend("/", directory="dist")router.frontend("/", directory="dist") を追加しています(PR #15800、Frontend docs)。これは static な dist/ SPA 配信機能であり、サーバーレンダリングのパターンではありません。破壊的変更はありません。0.137.2(2026-06-18)は、以前 router.routes をたどっていた高度な用途向けに iter_route_contexts() を追加しています(0.137.0 以降は内部扱い)。破壊的変更はありません。2026-06-22 時点で 0.138.0 より新しいリリースはありません。Starlette 1.3.1、Pydantic 2.13.4、Uvicorn 0.49.0、SQLAlchemy 2.0.51、HTMX 2.0.10、Alpine.js 3.15.12、Bootstrap 5.3.8 はすべて変更ありません。2026-06-22 に PyPI と公式リリースノートで確認済みです。 

NORMAL fastapi-htmx.md EOF