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

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

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

author: words: 2005 read_time: 59m updated: 2026-07-30 02:36
$ 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 で初期化し、ミドルウェアの順序を明示します。

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 ルートが返すのは、HTMX によって既存の DOM 要素へ差し込まれる HTML フラグメントです。どちらも同じ 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 に依存でき、さらに 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 クラスは起動時に型を検証するため、必須フィールドが欠けていれば実行時まで待たず、即座にエラーとなります。

Async のパターン

FastAPI のルートはデフォルトで async です。I/O バウンドな処理(データベースクエリ、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 リスト内の RouteWebSocketRoute に置き換わりました。FastAPI 0.137.0(2026年6月14日)では、独自のルーター内部構造もリファクタリングされています。router.routes は APIRoute オブジェクトのフラットなリストではなく、中間ノードからなるツリーになったため、反復処理の対象ではなく内部実装の詳細として扱ってください。その代わり、include_router() の実行後にルーターへ追加したルートが即座に反映されるようになり、ルートを定義する前にサブルーターをインクルードすることも可能です。FastAPI 自体は、Starlette を1.x系に固定しているわけではありません。ランタイム要件は0.136.3以降、下限だけを指定した starlette>=0.46.0 であり、0.140.7まで変更されていません。上限はなく、Starlette 0.4xも要件を満たします。0.137.0のリリースノートに記載された1.xのバージョン番号は、リポジトリ自身のテスト用ロックファイルに対する dependabot の更新であり、アプリに課されるランタイム制約ではありません。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 を配布する場合には便利ですが、ビルドを使わずサーバー側でレンダリングする本ガイドの手法とは別のものです(サーバーで HTML をレンダリングするのではなく、dist/ ディレクトリをマウントします)。25 FastAPI 0.139.0(2026年7月1日)では、さらに app.frontend() で依存関係がサポートされました。たとえば、配信するフロントエンドへCookie認証を自動適用できます。これにより、API ルートで使用するものと同じ Depends() の仕組みを、静的フロントエンドのマウントにも利用できます。26 FastAPI 0.141.0(2026年7月29日)では app.frontend(check_dir="auto") が追加され、ビルドディレクトリがまだ存在しない場合でも fastapi dev が失敗しなくなりました。これは、フロントエンドをビルドする前にサーバーを起動する一般的なケースに対応するものです。同日にリリースされた FastAPI 0.141.1では、app.frontend() の依存関係によって バックグラウンドタスクとレスポンスヘッダーが通知なく破棄される問題が修正されました。Cookieを設定したり BackgroundTask を予約したりする依存関係は、API ルートでは正常に動作していた一方、フロントエンドのマウントではその処理が失われていました。0.139.0で追加された依存関係サポートを採用している場合、アプリのほかの部分と同様に動作するようになるのは0.141.1です。28

FastAPI 0.140.0では、2025年11月以降のすべてのリリースに含まれていたメモリのリグレッションが解消されています。アップグレードしてください。 2026年7月24日のリリースは、1つのリファクタリングながら非常に大きな効果をもたらします。Dependant は、各ルートの依存関係グラフに含まれるすべてのノードに対して FastAPI が構築する内部オブジェクトです。このオブジェクトには0.121.0(2025年11月3日)から functools.cached_property 属性が追加され続け、0.139.2では10個に達していました。キャッシュされたプロパティは、結果を書き込むためにインスタンスごとの __dict__ を必要とします。そのため、コストはアプリ内の全グラフにある全ノードへ積み重なっていました。PR #16049では、このロジックをクラスからモジュールレベルのヘルパー(_get_cache_key()_get_oauth_scopes()_uses_scopes())へ移し、Dependant@dataclass(slots=True) として宣言することで、純粋なデータホルダーにしています。マージ済みPRに対する FastAPI 自身の CodSpeed 実行結果では、test_dependency_graph のメモリベンチマークが 17.5 MBから1.1 MBへ減少し、16分の1になりました。この作業のきっかけとなった報告では、0.120.4で約400 MB未満に収まっていた本番サービスが、0.121.3では OOM に達したと説明されています。それ以降に本ガイドで推奨してきた0.137.x、0.138.0、0.139.2のすべてに、この問題が含まれていました。依存関係ツリーが深い、または広いアプリ(ネストした Depends()、セキュリティスキーム、多数のインクルード済みルーター)であれば、0.140.0へ更新するだけで、アプリのコードを変更せずにメモリ使用量を削減できます。27

0.140.0は修正の始まりにすぎず、すべてを解決したわけではありません。0.140.7以降に固定してください。 このリリースから3日後の2026年7月27日、FastAPI は 5時間半でさらに7つのバージョン、0.140.1から0.140.7までをリリースしました。そのすべてが、同じ依存関係の仕組みに対するリファクタリングです。作業は2つに分かれます。1つ目は、フラット化された依存関係ツリーです。従来の FastAPI は各ルートの依存関係グラフをフラット化したコピーを構築して保持していましたが、0.140.2ではその保持をやめました。さらに0.140.3、0.140.5、0.140.6、0.140.7では、OpenAPI の生成、bodyフィールド、リクエストパラメーター、そして再びOpenAPI と、そのコピーを再構築していた残りの箇所が削除されています。0.140.4では、どこからも読み取られていなかった、重複する依存関係を追跡するための管理処理が削除されました。2つ目は、目に見えるしきい値を伴う唯一の変更です。0.140.1では、fastapi/dependencies/models.py にある呼び出し可能オブジェクトの分類用ヘルパーに対する lru_cache が、1,024件から4,096件へ拡張されました。また、名前付き定数 _CALLABLE_CLASSIFICATION_CACHE_SIZE で管理されるようになっています。これは、1,024個を超える異なる依存関係を持つアプリでキャッシュの入れ替えが頻発するというユーザー報告を受けた対応です。ここで変更されたものに、呼び出し側で使用する API は含まれないため、アップグレードはバージョンを更新するだけで完了します。ただし、2つの注意点を明確にしておきます。まず、このリリース頻度からも分かるように、この系列はまだ変化が続いています。0.140.7が最終版だと決めつけず、リリースノートを確認してください。次に、FastAPI がこの作業を測定するためのOpenAPI 依存関係ベンチマークを追加したのも同じ期間です(PR #16075)。そのため、公表された数値が対象としているのは最後の数リリースであり、7バージョンにわたる変更全体ではありません。29

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


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 ビルダーは、requirements.txt から Python プロジェクトを検出し、依存関係をインストールして起動コマンドを実行します。Dockerfile は不要です。ヘルスチェックエンドポイントにより、トラフィックを受信する前にアプリが応答可能であることを確認します。

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

デプロイパイプライン

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

npm install は不要です。npm run build も不要です。webpack のコンパイルも、TypeScript のコンパイルもありません。インストール手順は pip install -r requirements.txt のみで、デプロイ間でキャッシュされます。

Procfile

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

Procfile は、Heroku 互換の代替手段です。Railway は railway.tomlProcfile の両方をサポートしています。${PORT:-8000} 構文は、プラットフォームから提供されるポートを使用し、提供されない場合はローカル開発用の 8000 をデフォルト値として使用します。

Uvicorn の本番環境設定

トラフィック量が多い環境では、複数のワーカーを使用します。

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

各ワーカーはアプリのコピーを個別に保持する独立したプロセスであるため、プロセスごとのメモリ使用量はワーカー数に応じて増加します。まさにここで、FastAPI 0.140.0 の依存関係グラフ修正が効果を発揮します。依存関係の多いアプリで 0.139.2 を使用すると、4 つのワーカーすべてで従来の Dependant オーバーヘッドが発生します。27

開発時には、--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 時間、再検証中も古いコンテンツを配信します

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


判断フレームワーク

ビルドツールは必要ですか?

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

1. 5 人を超える開発者が JavaScript インターフェースを共有していますか? 「はい」の場合、TypeScript のコンパイル時型チェックにより、ランタイムテストでは発見が遅すぎる統合バグを防げます。ビルド手順を追加してください。

2. アプリで複雑なクライアント側の状態を管理しますか? ドラッグ&ドロップ、リアルタイム共同編集、オフラインファーストのデータが、あると便利な機能ではなく中核機能である場合、React や Svelte のようなフレームワークを導入するだけの価値があります。ビルド手順を追加してください。

3. 複数の製品で共有コンポーネントライブラリを利用しますか? 「はい」の場合、そのライブラリには npm パッケージ化、セマンティックバージョニング、ツリーシェイキングが必要です。ビルド手順を追加してください。

4. バンドラーを前提とする npm エコシステムのライブラリに依存していますか? Radix、Framer Motion、TanStack Query、または同様のライブラリが製品の中核を担う場合、ビルドパイプラインは必須です。

4 つすべての答えが「いいえ」なら、ビルド不要のアプローチを採用できます。1 つでも「はい」があれば、ビルドツールは実際の問題を解決します。間違いは、4 つすべてが「いいえ」なのにビルドツールを追加することです。存在しない問題を解決しようとして、実在する依存関係管理の負担を生み出してしまいます。1

スタックの比較

カテゴリー ビルド不要(本ガイド) React + ビルドツール
最適な用途 コンテンツサイト、ポートフォリオ、社内ツール、CRUD アプリ SaaS 製品、複雑な SPA、デザインシステムの利用側
チーム規模 1〜5 人の開発者 5〜50 人以上の開発者
状態管理 サーバー(HTMX)+ クライアント(Alpine.js) クライアント(React state、Redux、Zustand)
型安全性 ランタイム(サーバー側の Pydantic) コンパイル時(TypeScript)
コンポーネントの再利用 Jinja2 の include + macro npm パッケージ、共有ライブラリ
SEO デフォルトでサーバーレンダリング SSR/SSG の設定が必要
パフォーマンスの下限 高い(最小限の JS、サーバーレンダリング) 状況による(フレームワークのオーバーヘッド)
複雑さの上限 低い(オフライン対応や高度なクライアント状態には非対応) 高い(あらゆるクライアント操作に対応可能)
依存関係 17 個の Python パッケージ 300 個以上の npm パッケージ
ビルド時間 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 このライブラリは、圧縮および gzip 適用後で約16KB、依存関係はゼロで、セマンティックバージョニングに準拠しています。blakecrosley.com では HTMX を3年間本番運用しており、HTMX に起因するバグは一度も発生していません。20

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

一部は可能です。TypeScript ファイルは、出力ファイルを生成せずに tsc --noEmit で型チェックできるため、コンパイル時チェックを行うリンターとして利用できます。ただし、ブラウザは .ts ファイルを直接実行できないので、TypeScript を配信するにはビルドステップが必要です。代替手段として、通常の .js ファイルに JSDoc の型注釈を記述すれば、コンパイルせずに TypeScript でチェックできます。これにより、標準の JavaScript を配信しながら、開発時の型安全性を確保できます。

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

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

React によるサーバーサイドレンダリング(SSR)はどうですか?

Next.js SSR と FastAPI + HTMX のアプローチには、サーバーでレンダリングした HTML をブラウザへ送信するという共通の目的があります。違いは、最初のレンダリング後に何が起こるかです。Next.js は React でページをハイドレーションし、フレームワークのランタイムとコンポーネントコードをクライアントへ送ります。FastAPI + HTMX はハイドレーションを行わず、HTML がそのまま最終出力となります。その後の操作は、HTMX がサーバーへ新しい HTML フラグメントをリクエストすることで処理します。その結果、送信される JavaScript の合計サイズは、Next.js アプリが100〜300KBであるのに対し、FastAPI + HTMX では約35〜40KBです。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-connect 属性と ws-send 属性を使用します。HTMX 1.x を使用している場合は、従来の hx-ws 構文も引き続き動作します。

HTMX 4.0 ベータ版の動向: htmx 4.0.0-beta6 は現在、npm の next タグと4.0ドキュメントで公開されています(beta6 は2026年7月23日公開)。一方、htmx.org のクイックスタートと npm の latest タグは、引き続き2.0.10を参照しています。このガイドでは今も HTMX 2.x を対象としています。4.0が安定版になるまでは、本番運用に推奨されるバージョンであり続けます。2.xから4.xへの移行は世代をまたぐ大幅な変更であり、2.xのポイントリリースではありません。big-skies-software のバージョニングパターンでは奇数のメジャーバージョンを飛ばすため、2.xの次は4.0となります。2122

4.0ドキュメントで注目すべき点。 4.0 GA に先立つセキュリティとアーキテクチャのレビューでは、特に2つの追加機能が注目に値します。新しい hx-live 拡張機能では、参照している状態が変化すると再評価される DOM リアクティブ式が導入されます。また、新しい hx-nonce 拡張機能では、CSP nonce によって htmx 属性の処理を制限できます。4.0移行ガイドでは、いくつかの設定概念も移動され、一部のイベントや履歴に関する動作が復元または変更されているほか、いくつかの JavaScript ヘルパーがコアから削除されています。4.0は2.xの単純な差し替え用パッチではなく、移行プロジェクトとして扱ってください。21

サーバーからのメッセージは、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
  • ベーステンプレート内の Canonical URLOpen Graph タグ
  • セマンティックな HTML<article><section><main>、適切な見出し階層

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

React と比べて学習コストはどうですか?

Python 開発者にとっては、学習コストが大幅に低くなります。すでに扱い慣れた言語をそのまま使えるためです。FastAPI のルートハンドラーはテンプレートレスポンスを返します。これは Flask や Django のビューと同じ考え方です。HTMX では、いくつかの HTML 属性(hx-gethx-targethx-swap)が加わります。Alpine.js では、さらに少数の属性(x-datax-show@click)を使用します。JSX、仮想 DOM、フックシステム、状態管理ライブラリはなく、ビルドツールの設定を学ぶ必要もありません。

HTMX のドキュメントは、長い1ページに収まります。Alpine.js のドキュメントも数ページ程度です。一方、React のドキュメントは数百ページにわたり、フック、コンテキスト、ref、エフェクト、Suspense、サーバーコンポーネント、ストリーミング SSR などを扱っています。

JavaScript/React 開発者にとって、必要なのは構文ではなく考え方の転換です。重要なのは、状態をサーバーが所有し、HTML もサーバーがレンダリングするという点です。クライアント側の状態管理は、サーバー側のルート処理に置き換わります。クライアント側のデータ取得は、HTML 要素に指定する HTMX 属性へと変わります。構文はよりシンプルですが、レンダリングの主体はクライアントであるという SPA の前提を手放し、新しいメンタルモデルを身につける必要があります。


変更履歴

日付 変更内容 出典
2026-07-29 FastAPI 0.141.0 + 0.141.1(いずれも7月29日)。 0.141.0ではapp.frontend(check_dir="auto")が追加され、ビルドディレクトリが存在しない場合でもfastapi devが失敗しなくなりました。これは、フロントエンドのビルドを実行する前にサーバーを起動する一般的なケースに対応するものです。その数時間後に公開された0.141.1では、app.frontend()の依存関係によってバックグラウンドタスクとレスポンスヘッダーが失われる問題が修正されました。Cookieを設定したりBackgroundTaskをスケジュールしたりする依存関係は、APIルートでは正しく動作する一方、フロントエンドのマウントではその処理が破棄されていました。この修正により、0.139.0で追加された依存関係サポートに実在した欠落が解消されています。このガイドのサーバーレンダリング方式ではdist/ディレクトリをマウントしないため、いずれも新しいセクションではなく、既存のapp.frontend()に関する説明へ反映しました。0.141.1では、FastAPIのCLIガイドにもFASTAPI_ENVが記載されています(ドキュメントのみの変更で、本文の変更はありません)。 28
2026-07-27 FastAPIは7月27日の5時間半で0.140.1から0.140.7までをリリースしました。計7リリースで、すべて0.140.0から始まった依存関係機構のリファクタリングです。変更は大きく2系統あります。まず、FastAPIが構築・保持していたフラット化済み依存関係グラフのコピーが0.140.2で廃止されました。さらに、それを再構築していた残りの箇所も、OpenAPI生成(0.140.3、0.140.7)、ボディフィールド(0.140.5)、リクエストパラメーター(0.140.6)の順にすべて削除されています。0.140.4では、参照されていなかった重複追跡用の管理処理も削除されました。目に見えるしきい値の変更は0.140.1です。fastapi/dependencies/models.pyにある呼び出し可能オブジェクト分類ヘルパーのlru_cacheが、1,024件から4,096件へ拡大されました(定数名は_CALLABLE_CLASSIFICATION_CACHE_SIZE)。これは、異なる依存関係が1,024件を超えるアプリでキャッシュの入れ替わりが頻発するという報告を受けたものです。APIの変更はありません。依存関係のメモリに関する段落では、推奨バージョンを0.140.0から0.140.7以降へ更新し、この系列はまだ変更が続いていること、OpenAPIの依存関係ベンチマーク(PR #16075)が系列最後のリリースでようやく取り込まれたことを追記しました。 29
2026-07-25 FastAPI 0.140.0(7月24日21:16 UTC)は、0.121.0(2025年11月3日)以降存在していた依存関係システムのメモリ回帰を修正しました。 PR #16049では、Dependantから10個のfunctools.cached_property属性を削除してモジュールレベルのヘルパーへ移し、このクラスを@dataclass(slots=True)に変更しています。マージ済みPRに対する公式CodSpeed実行では、test_dependency_graphのメモリベンチマークが17.5 MBから1.1 MBへ改善(16倍)しました。元の報告では、0.120.4が約400 MB未満に収まっていた環境で、0.121.3にすると本番環境でOOMが発生したとされています。Asyncパターンに0.140.0の説明を追加し、Uvicornの本番設定にはワーカーのメモリに関する記述を追加しました。既存の誤りも修正しました。 このガイドでは、0.137.0が「Starletteを1.x系列に固定する」と説明していましたが、実際には固定していません。FastAPIの実行時要件はstarlette>=0.46.0であり、上限のない最低バージョン指定です。Starlette 0.4xでも満たされ、0.136.3、0.137.0、0.138.0、0.139.2、0.140.0のすべてで一貫しています。0.137.0の注記にある1.xのバージョン番号は、リポジトリのテスト用ロックファイルに対するdependabotの更新です(PR #15722で変更されたのはuv.lockのみ)。本文の記述と24を修正しました。変更ではないものの、注意すべき点が2つあります。Dependantの内部仕様に依存するツールでは破壊的変更となります。oauth_scopescache_key_uses_scopes_is_security_schemeは属性として削除され、モジュール関数の_get_oauth_scopes()_get_cache_key()_uses_scopes()に置き換えられました。また、slots=Trueによりインスタンスへのモンキーパッチもできなくなっています。ただし、これはこのガイドで参照していない非公開の内部APIであり、0.137.0のrouter.routes変更と同じ分類です。さらに、FastAPIの公式ドキュメントでは、README、index.mdvirtual-environments.md、Docker/デプロイページなど30ファイルにわたり、pip/venvではなくuvプロジェクトがデフォルトになりました(PR #16032、7月21日マージ)。コードへの影響は表面的ですが、このガイドでは一貫してpip install -r requirements.txtを説明しているため、上流の入口とは異なる状態です。これは今後の編集上の判断事項とし、今回は意図的に変更していません。 27
2026-07-24 htmx 4.0.0-beta6が、npmのnextタグでbeta5に置き換わりました(2026年7月23日公開、GitHubリリースも同日)。ベータ版の主な変更は、新しいhx-multipart拡張(パートごとのHX-*アクションヘッダーを伴うmultipart/mixed/multipart/parallelレスポンスのストリーミング)、Navigation APIによる履歴スクロール位置の復元とFirefox向けフォールバック、ベータ版内部イベント名のhtmx:swap:finallyからhtmx:finally:swapへの変更、スワップ後に発火するようになったHX-Triggerレスポンスヘッダーイベント、カスタムリクエストメソッド、protocols転送に対応したhx-wsの再実装です。推奨事項は変わりません。4.0 GAまでは、本番環境でHTMX 2.xlatest = 2.0.10)を使います。このイベント名変更が破壊的なのは4.0ベータ系列内だけです。ベータ版追跡の注記と21を更新しました。FastAPI 0.139.2、Uvicorn 0.51.0、Alpine.js 3.15.12、Starlette 1.3.1、Jinja2 3.1.6に変更がないことも確認済みです。この期間のセキュリティアドバイザリは0件でした。
2026-07-17 FastAPI 0.139.1 + 0.139.2(7月16日)app.frontend()のフォールバックにおけるドットを含むパスの修正(/users/john.doe、PR #16011)と、並列スレッドテストに対応するスレッドセーフなルータールート構築(PR #16013)が含まれます。アプリ側に影響するAPI変更はありません。Uvicorn 0.49.0 → 0.51.0:従来のwebsockets実装は非推奨となり、autoのデフォルトがwebsockets-sansioへ変更されました(0.50.0)。デフォルト実装にはwebsockets>=13.0が必要です(0.50.2)。また、0.51.0(7月8日)では、ほぼ無停止でリロードできるよう、ワーカーを重複稼働させるSIGHUP再起動が追加されました。リポジトリは現在Kludex/uvicornにあります。HTMX(2.0.10 / 4.0.0-beta5 next)、Alpine.js 3.15.12、Starlette 1.3.1、Pydantic 2.13.4、SQLAlchemy 2.0.51、Bootstrap 5.3.8に変更がないことも確認済みです。この期間のセキュリティアドバイザリは0件でした。
2026-07-07 htmx 4.0.0-beta5がnpmのnextタグになりました(2026年6月26日公開)。beta4を置き換えるもので、HTMX 4.0ベータ版の追跡注記と[^22]も更新しました。推奨事項は変わりません。4.0 GAまでは、本番環境でHTMX 2.xlatest = 2.0.10)を使用します。htmx.orgのnpm dist-tagsで確認済みです。
2026-07-02 FastAPI 0.139.0(7月1日)。 app.frontend()依存関係をサポートしました。たとえば、配信するフロントエンドにCookie認証を自動適用できます(PR #15908)。0.138.0で追加された静的フロントエンドのマウントを、標準のDepends()機構で拡張するものです。このガイドが主題とするサーバーレンダリング方式とは引き続き独立しているため、同じ比較用の段落に追記しました。そのほかのスタックに変更はなく、HTMX 2.0.10、Alpine.js 3.15.12、Bootstrap 5.3.8、SQLAlchemy 2.0.51のままです。 26
2026-06-22 FastAPI 0.138.0 + 0.137.2。 0.138.0(6月20日)では、ビルド済みの静的フロントエンド(SPAのdist/出力)を配信するapp.frontend("/", directory="dist") / router.frontend(...)が追加されました。このガイドが主題とする、ビルド不要のサーバーレンダリング方式とは独立した機能であるため、Asyncパターンのセクションに比較として記載しました。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)はすべて変更されていません。 25
2026-06-16 FastAPI 0.137.0/0.137.1 + Starlette 1.0→1.3.1。 FastAPI 0.137.0(6月14日)では、ルーター内部がリファクタリングされました。router.routesはフラットなAPIRouteリストではなく内部ツリーとなり、これを反復処理するコードには破壊的変更です。一方で、include_router()の後に追加されたルートが有効になり、新しいAPIRouter.matches()/.handle()フックも利用できます。0.137.1(6月15日)では、APIRouteの型付けと、パスが空でプレフィックスもないルーターが修正されました。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に固定すると記載していましたが、2026-07-25に訂正しました。実際には固定しておらず、実行時要件は上限なしのstarlette>=0.46.0です。)Asyncパターンのセクションに、lifespanとルーターに関する注記を追加しました。SQLAlchemy 2.0.51(6月15日)は不具合修正のみです。 24
2026-06-08 SQLAlchemy 2.0.50におけるasyncインストールの変更。 SQLAlchemy 2.0.50以降、asyncスタックのgreenlet依存関係はデフォルトではインストールされません。sqlalchemy[asyncio] extraをインストールしてください。インストールしない場合、エンジンに対する最初のawaitがgreenlet不足エラーで失敗します。2.0.50ではPython 3.10以降も必須となり、3.7〜3.9のサポートが終了したほか、フリースレッド版3.13tのwheelが追加されました。SQLAlchemy 2.0 Asyncのセクションにインストール時の注記を追加しました。残りのスタックについて本文の変更はありません。FastAPIの最新版は引き続き0.136.3(2026年5月23日、6月のリリースなし)、htmxの安定版は2.0.10のままです(4.0.0-beta4「The Fetchening」はベータ版で、安定版の目標は2027年初頭ごろとされており、まだ本番環境には推奨しません)。Alpine.jsは3.15.12、Bootstrapは5.3.xのままです。本番環境への推奨も変わらず、4.0の安定版が公開されるまではHTMX 2.xを使用します。23
2026-05-24 メンテナンス確認:ローカルのコンテンツ一覧は引き続き、ブログ記事210件、主要ガイド11件、デザインスタディ48件、英語を含む対応ロケール10件です。FastAPIの最新版は0.136.3(2026年5月23日)です。リリースノートでアプリに影響する変更として挙げられているのは、convert_underscores=Trueの場合のアンダースコア付きヘッダー処理の厳格化だけです。また、0.136.2では、壊れたイベントデータを防ぐためServer-Sent Eventのフィールド検証が追加されました。htmxの安定版は引き続き2.0.10ですが、npmのnextと4.0のドキュメントは4.0.0-beta4を参照するようになりました。SQLAlchemy 2.0の最新版は2.0.50、Pydanticの最新版は引き続き2.13.4です。本番環境への推奨は変わらず、4.0が安定版になるまではHTMX 2.xを使用します。122
2026-05-18 サイト一覧の更新:ローカルのコンテンツ一覧は、ブログ記事210件、主要ガイド11件、デザインスタディ48件、英語を含む対応ロケール10件となりました。FastAPIの最新版は引き続き0.136.1、htmxの安定版も引き続き2.0.10で、npmのnextは4.0.0-beta3です。Alpine.jsのnpm最新版も引き続き3.15.12です。本番環境への推奨は変わらず、4.0が安定版になるまではHTMX 2.xを使用します。12021
2026-05-15 メンテナンス確認:FastAPIの最新版は引き続き0.136.1です。このローカルサイト環境ではFastAPI 0.128.0とStarlette 0.50.0がインポートされています。htmxの安定版は引き続き2.0.10で、npmのnextは4.0.0-beta3になりました。Alpine.jsのnpm最新版は3.15.12、Bootstrapの最新版は5.3.8、SQLAlchemy 2.0の最新版は2.0.49、Pydanticの最新版は2.13.4です。本番環境への推奨は変わらず、4.0が安定版になるまでは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拡張(DOMリアクティブ式)、新しいhx-nonce拡張(htmx属性をCSP nonceで保護)、設定、履歴、イベント、コアJavaScriptヘルパーに関する移行ガイドの変更があります。本番環境への推奨は変わりません。htmx 2.xが引き続きnpmの最新タグであり、4.0 GAまでは推奨バージョンです。21
2026-05-07 メンテナンス確認:FastAPIの最新版は引き続き0.136.1、htmxの安定版は2.0.10で、v4は2026年夏を目標とするベータ版のままです。Alpine.jsのnpm最新版は3.15.12、Bootstrapの最新版は5.3.8、SQLAlchemy 2.0の最新版は2.0.49、Pydanticの最新版は2.13.4です。サイト内の指標は、ブログ記事182件、ガイド11件、対応ロケール10件、Python要件17件へ更新しました。移行に関する推奨事項は変わらず、4.0が安定版になるまでは本番環境でHTMX 2.xを使用します。20
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日)が公開されました。移行に関する推奨事項は変わりません。4.0が安定版になるまで、htmx 2.xがnpmのlatestタグに残ります。セキュリティ修正も継続されており、アップグレードを急ぐ必要はありません。現時点で設計時に考慮すべき4.0の主な変更は、(1)コアのajax基盤としてXMLHttpRequestfetch()で置き換えること、(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の機能(検証済みモデルデータへアクセスできる非公開属性のデフォルトファクトリー、3.14をサポートする1.10.26までのpydantic.v1名前空間)も追記しました。Alpine.js 3.15.11の修正内容は、x-anchor.noflip修飾子、x-forにおける複数ルート要素の警告、$refsのmorph回帰修正です。
2026-03-24 初版公開

参照


このガイドでは、blakecrosley.comの構築に使用しているシステム全体を解説しています。The No-Build Manifestoでは、その背景にある思想を論じています。Lighthouseで満点を達成するまでの記事では、パフォーマンス最適化の過程を記録しています。Vibe Codingとエンジニアリングの記事では、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監査を実行できます。2026年3月時点で、blakecrosley.comは100/100/100/100(パフォーマンス、アクセシビリティ、ベストプラクティス、SEO)を記録しています。結果は公開情報から検証できます。最適化の全過程については、76から100へ:Lighthouseで満点を達成するまでをご覧ください。 

  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年5月時点のblakecrosley.comのrequirements.txtから、依存関係の全一覧を検証しました。現在、このファイルには17件のPython要件エントリがあり、ビルドツール、コンパイラ、バンドラーはゼロです。 

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

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

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

  9. asyncハンドラーと同期ハンドラーに関する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 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)からプリロードのヒントを含む予備レスポンスを送信できます。Cloudflareは、rel=preloadを指定したLinkヘッダーのEarly Hintsをサポートしています。developer.chrome.com/blog/early-hintsをご覧ください。 

  18. React 18とReactDOMのサイズは、minifyとgzip圧縮後で約42 KBです。ルーター、状態管理ライブラリ、ビルドフレームワークのランタイムを加えると、一般的なReactアプリでは100〜300 KBのフレームワークJavaScriptが配信されます。出典:bundlephobia.com/package/react-dom@18.2.0。 

  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が掲載されています。ローカルでインポートを検証したところ、このサイト環境ではFastAPI 0.128.0とStarlette 0.50.0が返されました。htmx.orgのクイックスタートには2.0.10が掲載されています。npm view htmx.org version dist-tagsではlatest=2.0.10next=4.0.0-beta3npm view alpinejs versionnpm view @alpinejs/csp versionでは3.15.12が返されました。Bootstrapの公式ブログとnpmパッケージのメタデータには5.3.8、SQLAlchemyのPyPIとドキュメントには2.0.49、PydanticのPyPIには2.13.4が掲載されています。 

  21. htmx 4.0.0-beta6は現在のnpm nextタグです(2026年7月23日公開。ベータ系列は2026年5月8日のbeta3からbeta4、beta5、beta6へ進みました)。一方、npm latestは引き続き2.0.10です。four.htmx.orgの4.0ドキュメントはnextビルドに対応しています。4.0拡張機能一覧にはhx-livehx-nonceが掲載され、4.0移行ガイドには本番アプリを2.xから移行する前に確認すべき変更点が記載されています。2026年7月24日にhtmx.orgのnpm dist-tagsと照合して検証しました。 

  22. 2026年5月24日の保守確認です。ローカルのインベントリコマンドでは、Markdown形式のブログ記事210件、トップレベルのガイドファイル11件、デザインスタディファイル48件が返されました。FastAPIのリリースノートには、2026年5月23日付の0.136.3が掲載され、convert_underscores=Trueの場合のアンダースコアを含むヘッダー処理が厳格化されています。0.136.2ではServer-Sent Eventのフィールドを検証します。python3 -m pip index versions fastapiでは最新の0.136.3python3 -m pip index versions sqlalchemyでは最新の2.0.50python3 -m pip index versions pydanticでは最新の2.13.4が返されました。npm view htmx.org dist-tags version time.modified --jsonでは、latest=2.0.10next=4.0.0-beta4time.modified=2026-05-22T15:56:21.948Zが返されました。four.htmx.orgのインストールドキュメントにはhtmx.org@4.0.0-beta4が掲載されています。 

  23. 2026年5月24日にリリースされたSQLAlchemy 2.0.50の変更履歴リリースブログです。asyncioのgreenlet依存関係はデフォルトではインストールされなくなり、導入するにはsqlalchemy[asyncio]インストールターゲットが必要になりました。2.0.50ではPython 3.7/3.8/3.9のサポートも終了し(現在は3.10以降)、自由スレッド対応のPython wheelとover(..., exclude=...)ウィンドウフレームパラメータが追加されています。2026年6月8日時点で、PyPIから最新版であることを検証しました。htmx 4.0.0-beta4(「The Fetchening」、2026年5月22日)は引き続きベータ版で、安定版は2027年初頭を目標としています。この期間、FastAPI 0.136.3(2026年5月23日)、Alpine.js 3.15.12、Bootstrap 5.3.xに変更はありません。 

  24. FastAPIのリリースノート:0.137.0(2026年6月14日)ではルーター内部がリファクタリングされ、router.routesAPIRouteオブジェクトのフラットなリストではなく、中間オブジェクトからなるツリーになりました(内部実装として扱ってください)。また、include_router()の後にルートを追加できるようになり、ルートを定義する前にサブルーターを組み込めるようになりました。ルートのコピーも回避され、APIRouter.matches().handle()が追加されています。Starletteを1.xに固定しているわけではありません。FastAPIの実行時要件はstarlette>=0.46.0であり、上限のない下限指定です。0.136.3、0.137.0、0.138.0、0.139.2、0.140.0のすべてで同一であることを、2026年7月25日にPyPI JSON APIrequires_distメタデータと照合して検証しました。0.137.0のリリースノートにある「bump starlette from 1.1.0 to 1.2.1」(PR #15722)という記述は、Internalに分類されたDependabotの更新であり、リポジトリのuv.lockテスト用ロックファイルだけを変更しています。(以前は上限があり、0.120.4と0.121.0ではstarlette<0.50.0,>=0.40.0が指定されていましたが、0.136.3までに削除されました。)2026年7月25日に訂正済みです。この脚注の以前の記述と本文の主張は誤っていました。0.137.1(2026年6月15日)では、APIRouteの型指定と、プレフィックスのないルーターにおける空のパスが修正されています。Starletteのリリースノート:約8年ぶりの最初の安定版である1.0.0(2026年3月22日)では、on_startupon_shutdownon_event()と、@app.route()@app.websocket_route()デコレーターが削除されました(lifespanRouteWebSocketRouteを使用してください)。最新版は1.3.1(2026年6月12日)です。SQLAlchemy 2.0.51(変更履歴、2026年6月15日)はバグ修正のみで、asyncやインストールへの影響はありません。2026年6月16日にPyPIと公式リリースノートで検証しました。 

  25. FastAPIのリリースノート:0.138.0(2026年6月20日)では、ビルド済みの静的フロントエンドを配信するためのapp.frontend("/", directory="dist")router.frontend("/", directory="dist")が追加されました(PR #15800、フロントエンドのドキュメント)。これは静的なdist/を配信するSPA向け機能であり、サーバーレンダリングのパターンではありません。破壊的変更もありません。0.137.2(2026年6月18日)では、従来router.routesを走査していた高度なユースケース向けにiter_route_contexts()が追加されました(router.routesは0.137.0以降、内部実装です)。破壊的変更はありません。2026年6月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年6月22日にPyPIと公式リリースノートで検証しました。 

  26. FastAPIの0.139.0リリースノート、2026年7月1日:「フロントエンドの自動Cookie認証などに利用できる、app.frontend()での依存関係のサポート」(PR #15908)が追加されました。リリースの残りは翻訳、ドキュメント、依存関係の更新で、破壊的変更はありません。2026年7月2日(PST)にこのセッションで検証した時点では、GitHubのリリースページで0.139.0が最新版です。 

  27. FastAPIの0.140.0リリースノート。2026年7月24日21:16 UTCに公開されました(PyPIのupload_time_iso_86012026-07-24T21:16:42Z)。リファクタリングの項目は「⚡️ 依存関係のメモリ使用量を削減。PR #16049」の1件のみです(2026年7月24日21:07:52 UTCにマージ)。この回帰はPR #14262(2025年11月3日にマージ)で発生し、同日リリースの0.121.0に含まれました。このPRではDependant.cache_keyfunctools.cached_propertyが追加され、0.139.2までにクラスには10個の@cached_property定義が含まれるようになっていました。0.140.0では、fastapi/dependencies/models.py@dataclass(slots=True) class Dependantが宣言され、ロジックはモジュールレベルの_get_cache_key()_get_oauth_scopes()_uses_scopes()_is_security_scheme()へ移されています。タグ0.140.0のソースから検証しました。マージ済みPRのCodSpeedボットによると、test_dependency_graphのメモリベンチマークは17.5 MB(ベース)から1.1 MB(変更後)となり、「パフォーマンスが16倍向上」しています。0.140.0では、再発防止のためCIメモリベンチマーク(PR #16046)も追加されました。元となった報告はdiscussion #14742です。そこでは0.120.4が約400 MB未満に収まっていた一方、0.121.3は本番環境でOOMを起こしています。ツール開発者向けの注意点として、Dependant.oauth_scopes.cache_key._uses_scopes._is_security_schemeは属性として存在しなくなり、slots=Trueによってインスタンスへのモンキーパッチもできなくなりました。これは、このガイドでは使用していない非公開の内部APIであり、0.137.0のrouter.routes変更と同じ種類のものです。2026年7月25日に、PyPI、GitHub API、タグ付きソースを照合し、すべての事実を再検証しました。 

  28. FastAPI 0.141.0(2026年7月29日14:47 UTC)では、fastapi devを使ったローカル開発向けにapp.frontend(check_dir="auto")が追加されました(PR #16102)。FastAPI 0.141.1(2026年7月29日17:17 UTC)では、app.frontend()の依存関係から返されるバックグラウンドタスクとヘッダーのサポートが修正され(PR #16105)、FastAPI CLIガイドにFASTAPI_ENVが記載されました(PR #16104)。いずれも@tiangoloによるものです。2026年7月29日に、PyPIの最新版が0.141.1であることを確認しました。 

  29. FastAPIの0.140.1から0.140.7までの各リリースは、すべて2026年7月27日の12:07〜17:34 UTCに公開されました(PyPIのupload_time_iso_8601:0.140.1は12:07:51Z、0.140.2は14:15:38Z、0.140.3は15:30:52Z、0.140.4は15:46:49Z、0.140.5は16:02:53Z、0.140.6は16:31:48Z、0.140.7は17:34:47Z)。各リリースの本文には、リファクタリング項目が1件だけ記載されています。0.140.1は「大規模アプリに対応するため、依存関係のlru_cache上限を更新」(PR #16062)、0.140.2は「フラット化した依存関係ツリーの保持を停止」(PR #16065)、0.140.3は「OpenAPIで依存関係を繰り返しフラット化する処理を回避」(PR #16067)、0.140.4は「未使用の依存関係について、重複管理処理を省略」(PR #16069)、0.140.5は「ボディフィールド用の依存関係のフラット化を回避」(PR #16071)、0.140.6は「主にOpenAPI向けに、リクエストパラメータ用の依存関係のフラット化を回避」(PR #16073)、0.140.7は「OpenAPI用の依存関係のフラット化を回避」(PR #16076)です。キャッシュの数値は#16062の差分に基づいています。この差分では、fastapi/dependencies/models.pyにある3つの@lru_cache(maxsize=1024)デコレーターが@lru_cache(maxsize=_CALLABLE_CLASSIFICATION_CACHE_SIZE)に置き換えられ、tests/test_dependency_models.pyではcache_info.maxsize == 4096を検証するよう更新されています。PR本文には、「依存関係が1024を超えるという報告が一部のユーザーから寄せられており、これでより大規模なアプリにも対応できるはずです」と記載されています。0.140.2ではメモリベンチマーク(PR #16064)も追加され、0.140.7ではOpenAPI依存関係のベンチマーク(PR #16075)が追加されました。そのため、ベンチマークのカバレッジが整ったのは、この一連のリリースの大半より後です。2026年7月27日に、GitHubのリリースAPI、PRの差分、PyPIを照合して検証しました。執筆時点では0.140.7が最新版でした。 

NORMAL fastapi-htmx.md EOF