Astro / Next.js で Markdown ミラーを実装する
AI エージェント対応で最後の壁になる Markdown ミラー (P15-P20) を、Astro と Next.js それぞれで実装する具体的手順。コピペで動くコード付き。
YomuScore の 47 項目のうち、Grade A 到達を一番阻んでいるのが P15-P20 の Markdown ミラー 6 項目だと思う。WordPress や Shopify では実装の難易度が高くて後回しになりがちな領域だが、Astro や Next.js なら 30 分から長くて 2 時間くらいで全 6 項目を pass まで持っていける。
Markdown ミラーというのは、同じコンテンツの Markdown 版を Accept: text/markdown ヘッダの content negotiation か /path.md パスで返す仕組みのこと。AI エージェントは HTML を解析して本文を抽出する処理よりも、最初から Markdown を受け取れる方が 4〜10 倍くらい効率がいいので、Markdown 版があるサイトの方が引用されやすい傾向がある。逆に Markdown 版が無いサイトは AI 側から見て「コストが高くて後回し」の扱いになりやすい。
対応する 6 つのチェックを並べておくと、P15 がミラー本体の存在、P16 が Markdown の先頭の frontmatter、P17 が HTML 側の <link rel="alternate" type="text/markdown">、P18 が Markdown レスポンスの Link ヘッダに rel="canonical" があるか、P19 が Accept: text/markdown でちゃんと content negotiation するか、P20 が Markdown 版に ## Sitemap セクションがあるか、という構成だ。要件は静的な .md ファイルを置くアプローチと動的な content negotiation の両方を組み合わせるのが互換性的にも一番安全になる。
Astro での実装
Astro は Markdown を一級市民として扱うフレームワークなので、最小工数で Markdown ミラーが完成する。
最初に Markdown ページから .md ルートを派生させる。Content Collections と組み合わせる前提で、src/pages/[...slug].md.ts を作る。
import type { APIRoute } from 'astro'
import { getCollection } from 'astro:content'
export async function getStaticPaths() {
const pages = await getCollection('pages')
return pages.map((p) => ({
params: { slug: p.slug },
props: { entry: p },
}))
}
export const GET: APIRoute = async ({ props }) => {
const { entry } = props as { entry: any }
const { title, description, updatedAt } = entry.data
// frontmatter (P16) + 本文 + sitemap section (P20)
const body = `---
title: "${title}"
description: "${description}"
last_updated: "${updatedAt.toISOString().slice(0, 10)}"
---
${entry.body}
## Sitemap
- [ホーム](/index.md)
- [About](/about.md)
- [Pricing](/pricing.md)
- [Blog](/blog.md)
`
return new Response(body, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
// P18: canonical の HTML 版を Link ヘッダで指す
'Link': `<https://yourdomain.example/${entry.slug}>; rel="canonical"`,
},
})
}
これだけで /about の Markdown 版が /about.md でアクセス可能になって、P15 / P16 / P18 / P20 が同時に pass する。
HTML 側に alternate link を追加するのも忘れずに。src/layouts/BaseLayout.astro の <head> に以下を追加して P17 が pass する。
---
const { pathname } = Astro.url
const mdUrl = `${pathname}.md`.replace('//', '/')
---
<head>
<link rel="alternate" type="text/markdown" href={mdUrl} title="Markdown 版" />
</head>
content negotiation (P19) は、リバースプロキシで対応するのが手早い。Caddy なら Caddyfile にこれを足す。
yourdomain.example {
@accept-md header Accept *text/markdown*
@no-extension path_regexp ^[^.]*$
handle @accept-md {
handle @no-extension {
rewrite {path}.md
}
}
reverse_proxy localhost:3000
}
Accept: text/markdown のリクエストが /about に来たら /about.md を返す挙動になる。Caddy / Nginx を使わない場合は Astro の middleware で代用できる。
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware'
export const onRequest = defineMiddleware(async (context, next) => {
const accept = context.request.headers.get('accept') || ''
if (accept.includes('text/markdown') && !context.url.pathname.endsWith('.md')) {
return context.redirect(`${context.url.pathname}.md`, 302)
}
return next()
})
ここまでで 6 項目全部 pass する。Astro なら 30 分くらいで終わる。
Next.js での実装
Next.js は Astro ほど Markdown ネイティブではないので、route handler を書くことになる。
まず /blog/{slug}.md のような URL で Markdown を返す route handler を作る。App Router で app/blog/[slug]/markdown/route.ts のような形で書いて、middleware で /blog/{slug}.md をここに rewrite する。
import { NextResponse } from 'next/server'
import { getBlogPost } from '@/lib/blog'
export async function GET(
_req: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params
const post = await getBlogPost(slug)
if (!post) return new NextResponse('Not found', { status: 404 })
const md = `---
title: "${post.title}"
description: "${post.description}"
last_updated: "${post.updatedAt}"
---
# ${post.title}
${post.content}
## Sitemap
- [ホーム](/)
- [About](/about)
- [Blog 一覧](/blog)
`
return new NextResponse(md, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Link': `<https://yomuscore.com/blog/${slug}>; rel="canonical"`,
},
})
}
/blog/{slug}.md というパターンの URL を上記 route に rewrite するのは middleware で。
// middleware.ts (project root)
import { NextRequest, NextResponse } from 'next/server'
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
const accept = req.headers.get('accept') || ''
// /foo.md パターン: そのまま .md ルートへ
if (pathname.endsWith('.md')) {
const base = pathname.slice(0, -3)
req.nextUrl.pathname = `${base}/markdown`
return NextResponse.rewrite(req.nextUrl)
}
// Accept: text/markdown content negotiation
if (accept.includes('text/markdown') && !pathname.startsWith('/api')) {
req.nextUrl.pathname = `${pathname}/markdown`.replace('//', '/')
return NextResponse.rewrite(req.nextUrl)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
これで P15 と P19 が pass する。
HTML 側に alternate link を出すのは app/layout.tsx の metadata で。
export const metadata: Metadata = {
alternates: {
canonical: '/',
types: { 'text/markdown': '/index.md' },
},
}
これで全ページに <link rel="alternate" type="text/markdown"> が自動付与されて P17 が pass する。
最後に Caddy 側で Link ヘッダを補強しておくと P18 が確実に取れる。Next.js のレスポンスヘッダだけだと CDN 経由で消える場合があるので、二重に保険をかけておく。
yomuscore.example {
@md path *.md
header @md Link `<https://yomuscore.example{path|cut:.md}>; rel="canonical"`
header @md Content-Type "text/markdown; charset=utf-8"
reverse_proxy localhost:3000
}
frontmatter (P16) と Sitemap セクション (P20)
frontmatter は title / description / last_updated のうち 2 つ以上が含まれていれば pass する。3 つ全部 + tag を入れておくと最も無難。
---
title: "About YomuScore"
description: "YomuScore の使い方、47 項目評価仕様、料金プランの概要"
last_updated: "2026-05-17"
tags: ["about", "company"]
---
Sitemap セクションは各 Markdown ページの末尾に同じものを足す。
## Sitemap
- [ホーム](/index.md)
- [About](/about.md)
- [Pricing](/pricing.md)
- [Blog](/blog.md)
- [Glossary](/glossary.md)
- [CMS別ガイド](/guides/cms.md)
リンク先も .md 版を指しておくのが推奨。AI エージェントは sitemap から芋づる式に Markdown 版を取りに行く挙動をする。
動作確認
実装後に YomuScore でスキャンする前に、curl で確認しておくと安心だ。
# (1) content negotiation で .md が返るか
curl -H "Accept: text/markdown" -I https://yomuscore.example/about
# Content-Type: text/markdown
# Link: <https://yomuscore.example/about>; rel="canonical"
# (2) 直接 .md パスでもアクセスできるか
curl -I https://yomuscore.example/about.md
# Content-Type: text/markdown
# (3) frontmatter が出ているか
curl -H "Accept: text/markdown" https://yomuscore.example/about | head -10
# ---
# title: "..."
# ...
# (4) HTML 側に alternate link があるか
curl https://yomuscore.example/about | grep "alternate"
# <link rel="alternate" type="text/markdown" href="/about.md">
全部期待通りなら YomuScore でスキャン して P15-P20 が全 pass になっているはず。WordPress や Shopify でこれをやるのは難易度が一段上がるので、リプラットフォームを検討する判断材料にもなる。
関連リンク
- Vercel Agent Readability Spec 完全ガイド — P15-P20 を含む 47 項目の解説
- WordPress でグレード A を取る 7 つのチェックリスト — WordPress で Markdown ミラー実装が難しい事情
- CMS別 改善ガイド: Next.js
- CMS別 改善ガイド: Astro
- llms.txt とは何か
- YomuScore で診断する