llms.txt は、サイトルートに置く LLM 向けの Markdown 形式の目次です。sitemap.xml が URL の機械可読リストなのに対し、llms.txt は「このサイトは何で、どこを読めばいいか」を要約付きリンクで並べます。
Astro なら、Content Collections を情報源にして API ルートから動的生成できます。記事一覧を手で書かずに済むので、記事を足してもメンテが要りません。この記事では、英日 2 言語のサイトで /llms.txt・/ja/llms.txt・/llms-full.txt の 3 本を 1 つのレンダラから出す最小構成を紹介します。
先に断っておくと、llms.txt が AI 検索の流入にどれだけ効くかは、まだ慣習も計測も固まっていません。ここは実装の話だけに絞ります。
最小の生成ルート
Astro の file-based API ルートで、src/pages/ に .txt.ts を置けばテキストを返せます。GET ハンドラから text/plain の Response を返すだけです。
// src/pages/llms.txt.ts
import type { APIContext } from "astro";
import { renderLlmsTxt } from "../lib/llmsTxt";
export async function GET(_context: APIContext) {
const body = await renderLlmsTxt({ docLang: "en" });
return new Response(body, {
status: 200,
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
}
.txt.ts という拡張子がポイントで、ビルド時に /llms.txt の URL に出力されます。中身を組み立てるロジックは src/lib/llmsTxt.ts に切り出して、ルート側は薄い入口にしておきます。こうすると言語別エンドポイントでロジックを再利用できます。
Content Collections から組み立てる
記事リストは getCollection で取得して、その場で並べます。手書きのリストにすると記事を足すたびに更新を忘れて、本文と llms.txt がずれます。
// src/lib/llmsTxt.ts(抜粋)
export async function renderLlmsTxt(opts: LlmsTxtOptions): Promise<string> {
const blog = await getCollection("blog", ({ data }) => !data.draft);
blog.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
// ...セクションを組み立てて join("\n") で返す
}
({ data }) => !data.draft で draft: true を落とすのを忘れないでください。これを抜くと、書きかけの下書きが llms.txt に載って、未公開の URL を LLM に案内してしまいます。sitemap や RSS と同じ除外条件をここでも揃えます。
多言語は 2 軸で分ける
多言語サイトの肝はここです。レンダラに 2 つの軸を持たせます。
- filterLang: どの言語の記事を載せるか
- docLang: 見出しや説明文をどの言語で書くか
この 2 軸を分けると、同じレンダラから 3 本のエンドポイントを出せます。
// src/pages/llms.txt.ts → 英語見出し・全言語の記事
renderLlmsTxt({ docLang: "en" });
// src/pages/ja/llms.txt.ts → 日本語見出し・日本語の記事だけ
renderLlmsTxt({ filterLang: "ja", docLang: "ja" });
英語版 /llms.txt は filterLang を指定しません。英語版を「サイト全体の入口」と位置づけて、両言語の記事を拾える状態にしています。日本語版 /ja/llms.txt は filterLang: "ja" で日本語面に閉じます。
代表記事だけは docLang で絞る
1 つだけ設計判断があります。英語版は記事自体は両言語を拾えますが、「代表記事(Featured)」のセクションだけは docLang の言語に絞ります。
const featuredSource = filteredBlog.filter(
(p) => entryLangLocal(p.id) === opts.docLang,
);
翻訳ペアの両方を代表枠に並べると、同じ内容が 2 言語で 2 枠を占めて、限られた枠の中のユニークなシグナルが半分になります。枠が有限なリスト(代表記事)では言語を絞り、容量制約のゆるい全文ダンプ(llms-full.txt)では両言語を載せる、という住み分けです。
ハマりどころ
-
draft除外を共通レンダラに置く: 3 本のエンドポイントが同じ除外条件を通るように、getCollectionのフィルタをレンダラ側に集約します。1 本だけ素通しにすると下書きが漏れます -
filterLangとdocLangを混同しない: 「載せる記事の言語」と「文言の言語」は別軸です。英語版でfilterLangを未指定にしているのは意図的な設計で、後から filter を足してバグ修正したつもりにならないように
まとめ
llms.txt は Content Collections を情報源に Astro の API ルートで生成すれば、手動メンテが要りません。多言語は filterLang と docLang の 2 軸に分けると、1 つのレンダラから 3 本出せます。
llms-full.txt(全文インデックス)での言語の相互参照、robots.txt との役割分担、利用条件セクションの書き方は Aulvem 本家にまとめました → llms.txt / llms-full.txt を Astro で多言語生成する — Aulvem の GEO 実装