Next.js 16 (App Router) helpers to make your site AI-discoverable by serving Markdown variants and advertising them via standard <link rel="alternate" type="text/markdown" ...> tags.
What this package provides:
- A
proxy.tsfactory to rewrite Markdown requests to a single internal route handler - A route handler factory to serve per-page Markdown with correct headers (
Content-Type,Vary: Accept) - Metadata helpers to advertise per-page Markdown alternates
- A
llms.txtroute handler factory (and optionalllms-full.txt)
- Only these requests are rewritten to the internal Markdown endpoint:
- URLs ending in
.md(example:/docs.md) - Requests whose
Acceptheader containstext/markdown(example:Accept: text/markdown, text/html;q=0.9)
- URLs ending in
- Other dot-paths are never rewritten (assets). Example:
/logo.pngis not rewritten. - Root mapping is intentional:
/is normalized to/index, so the home page Markdown twin is/index.md.
npm i next-ai-discovery
# or
pnpm add next-ai-discovery
# or
bun add next-ai-discoveryCreate proxy.ts at the project root:
import { createMarkdownProxy } from 'next-ai-discovery';
export default createMarkdownProxy();
export const config = {
// Recommended matcher:
// - runs on "normal" routes (no dot)
// - also runs on explicit `.md` routes
matcher: ['/((?!_next/|api/|.*\\..*).*)', '/(.*\\.md)'],
};Notes:
- Your
matchercontrols where Next runs the proxy. The proxy itself still has its own rewrite rules. - Internal endpoint default is
DEFAULT_ENDPOINT_PATH = '/__aid/md'. - The internal endpoint is automatically excluded to avoid rewrite loops.
Create app/__aid/md/route.ts:
import { createMarkdownRoute } from 'next-ai-discovery';
import type { NextRequest } from 'next/server';
const handler = createMarkdownRoute({
async getMarkdown(pathname, request: NextRequest) {
// IMPORTANT: `getMarkdown()` is your policy boundary.
// Enforce the same auth/policy as your HTML routes.
// Root is normalized to `/index`.
if (pathname === '/index') {
return {
frontmatter: {
title: 'home',
canonical: 'https://example.com/',
},
body: '# home\n\nhello.',
};
}
return null;
},
});
export const GET = handler;
export const HEAD = handler;Notes:
- Responses include
Content-Type: text/markdown; charset=utf-8andVary: Accept. HEADis supported; the handler returns the same status/headers but no body.
/llms.txt is a proposed convention for publishing a short, curated Markdown index to help LLMs and agents understand your site.
Reference: https://llmstxt.org/
Create app/llms.txt/route.ts:
import { createLlmsTxtRoute } from 'next-ai-discovery';
export const GET = createLlmsTxtRoute({
config: {
site: {
name: 'Example.com',
description: 'This site publishes articles about X.',
url: 'https://example.com',
},
sections: [{ title: 'Key sections', items: ['/blog', '/docs', '/about'] }],
markdown: {
appendDotMd: true,
acceptNegotiation: true,
fullIndexPath: '/llms-full.txt',
},
},
});Optional full variant:
- Create
app/llms-full.txt/route.tswithvariant: 'full'. - “full” is not auto-generated by this package; it just selects a variant so you can return a larger inventory if you want.
Advertising llms.txt:
- This package does not auto-inject a global
<link ... href="/llms.txt">. - If you want it, add it in your root
app/layout.tsxmetadata manually.
# content negotiation
curl -i -H 'Accept: text/markdown' https://example.com/docs
# explicit .md
curl -i https://example.com/docs.mdExpected headers (both):
Content-Type: text/markdown; charset=utf-8Vary: Accept
To advertise a Markdown twin from HTML using the Next.js Metadata API, use withMarkdownAlternate().
import { withMarkdownAlternate } from 'next-ai-discovery';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
return withMarkdownAlternate({ title: 'Docs' }, '/docs');
}This emits:
<link rel="alternate" type="text/markdown" href="/docs.md" />Home page note:
pathnameToMd('/')yields/index.md(because/is normalized to/index).
endpointPath(default:/__aid/md)enableDotMd(default:true)enableAcceptNegotiation(default:true)acceptHeader(default:text/markdown)exclude(pathname): boolean(optional)excludePrefixes(default:["/_next", "/api"])excludeExact(default:["/robots.txt", "/sitemap.xml"])onRewrite({ type: 'accept' | 'dotmd', pathname })(optional)
Precedence / terminology:
config.matcherdecides which requests execute the proxy at all.exclude*decides which requests the proxy will rewrite.
Content negotiation semantics:
- Any
Acceptheader value that contains a comma-separated entry starting withtext/markdowntriggers Markdown (q-values are ignored).
Dot-path behavior:
- For
Acceptnegotiation, the proxy will not rewrite “asset-like” URLs containing a dot after the last slash (example:/logo.png). - Explicit
.mdURLs are always eligible (example:/docs.md).
getMarkdown(pathname, request)returns{ body, frontmatter? }ornullincludeFrontmatter(default:true)onServed({ pathname, status })(optional)
Internally, paths are normalized to make matching predictable:
/->/index- Trailing slash is removed (
/docs/->/docs) .mdsuffix is removed (/docs.md->/docs)
- Next.js: App Router only
- Proxy (
proxy.ts): Edge runtime (Next.js proxy) - Route handlers: can be Edge or Node depending on how you configure your Next route file; this library itself does not require Node-only APIs.
This package avoids HTML rewriting/conversion on purpose: it is hard to do reliably, and it risks leaking content. Instead, you provide an explicit Markdown representation via getMarkdown().
This package intentionally makes getMarkdown() your policy boundary.
import { createMarkdownRoute } from 'next-ai-discovery';
import type { NextRequest } from 'next/server';
async function canViewPath(request: NextRequest, pathname: string) {
// Example only. Wire this to your auth/session logic.
// Return false for protected routes.
return !pathname.startsWith('/admin');
}
const handler = createMarkdownRoute({
async getMarkdown(pathname, request) {
if (!(await canViewPath(request, pathname))) {
// Recommended default: return null (404) to avoid leaking existence.
return null;
}
return { body: `# ${pathname}\n` };
},
});
export const GET = handler;
export const HEAD = handler;If your HTML route would redirect unauthenticated users, decide whether your Markdown variant should:
- Return
404(recommended for private areas) - Return
401/403
Because Next.js route handlers return Response, you can also wrap the handler and map outcomes.
- frontmatter format options (yaml vs json vs none)
- llms-full helpers (sitemap-driven inventory)
- observability hooks (
onServed,onRewrite) docs + examples - codemod/snippets for adding
withMarkdownAlternate()
MIT