← ブログ一覧

Astro / Next.js で Markdown ミラーを実装する

AI エージェント対応で最後の壁になる Markdown ミラー (P15-P20) を、Astro と Next.js それぞれで実装する具体的手順。コピペで動くコード付き。

·2·YomuScore 編集部

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 でこれをやるのは難易度が一段上がるので、リプラットフォームを検討する判断材料にもなる。


関連リンク

Next.jsAstroMarkdown実装

自社サイトを今すぐ診断

47項目のチェックを約5秒で実行。登録不要・無料。

診断ページへ