> For the complete Lynkow documentation index in agent-friendly format, see [llms.txt](/llms.txt).

# LLM-Ready Content

**Publié le** : 2026-08-14
**Catégorie** : Advanced

Make your website discoverable by AI assistants like ChatGPT, Claude, and Perplexity. This guide covers the llms.txt standard, Lynkow's automatic Markdown generation, and a complete Next.js 15 implementation that exposes your content in machine-readable formats.

> **Reference** - This guide is a tutorial. For exhaustive, always-current signatures and field lists, see the auto-generated reference:
> 
> - SDK: [`SeoService`](/docs/sdk/services/sdk-seo-service)
> - API: [Storefront: SEO](/docs/storefront-api/storefront-seo)

---

## 1. What is llms.txt and why it matters

### The problem

Large language models struggle with HTML-heavy websites. When an AI assistant tries to answer a question using your site, it has to parse navigation bars, footers, JavaScript bundles, and ad blocks just to find the actual content. Most of that HTML is noise. The result: your content is either missed entirely or poorly understood.

### The solution

**llms.txt** is an emerging standard -- like `robots.txt`, but for AI. It is a plain-text Markdown file served at `/llms.txt` that gives language models a structured index of your site's content. Instead of crawling and parsing HTML, an AI can read your llms.txt and immediately understand what your site offers.

Lynkow auto-generates three complementary endpoints from your published content:

| Endpoint | Purpose | Size |
| --- | --- | --- |
| `/llms.txt` | Summary index with links to individual articles as Markdown URLs | Small (a few KB) |
| `/llms-full.txt` | Every published article and page concatenated into one Markdown document | Can be large (hundreds of KB) |
| `/{path}.md` | Any individual article or page served as clean Markdown | One article at a time |


A typical `llms.txt` looks like this:

```markdown
# My Tech Blog

> A blog about web development, AI, and developer tools.

## Sitemap
- [Sitemap XML](https://example.com/sitemap.xml)

## Blog
### Tutorials
- [Getting Started with Next.js](https://example.com/tutorials/getting-started-nextjs.md): Learn how to build your first Next.js application from scratch.
- [Advanced TypeScript Patterns](https://example.com/tutorials/advanced-typescript.md): Deep dive into conditional types, mapped types, and template literals.

### News
- [Product Launch 2026](https://example.com/news/product-launch-2026.md): Announcing our new developer platform.

## Pages
- [About Us](https://example.com/about.md): Learn about our team and mission.
- [Pricing](https://example.com/pricing.md): Plans and pricing for all team sizes.
```

### Why this matters for SEO

AI-powered search is growing fast. When someone asks ChatGPT or Perplexity a question, these systems look for structured, machine-readable content to cite. Sites that serve `llms.txt` get cited more often and more accurately. This is the equivalent of adding a sitemap for Google -- except it is for the AI search engines that are gaining market share every quarter.

---

## 2. Serving llms.txt

Create a route handler that returns the summary index of your site's content.

```typescript
// app/llms.txt/route.ts
import { lynkow } from '@/lib/lynkow'

export async function GET() {
  const content = await lynkow.seo.llmsTxt()

  return new Response(content, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'public, max-age=86400, s-maxage=86400',
    },
  })
}
```

The response contains a Markdown document with your site name, description, and links to every published article and page as `.md` URLs. Lynkow groups articles by category and includes excerpt descriptions when available.

Cache aggressively -- content changes are infrequent, and a 24-hour TTL (`86400` seconds) keeps the load minimal while ensuring updates propagate within a day.

---

## 3. Serving llms-full.txt

The full version concatenates every published article and page into a single Markdown document. AI systems that want to ingest your entire site in one request use this endpoint.

```typescript
// app/llms-full.txt/route.ts
import { lynkow } from '@/lib/lynkow'

export async function GET() {
  const content = await lynkow.seo.llmsFullTxt()

  return new Response(content, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'public, max-age=86400, s-maxage=86400',
    },
  })
}
```

For content-heavy sites, this response can be large (hundreds of KB or more). Caching is essential here. If your site has hundreds of articles, consider increasing the cache duration or using on-demand revalidation via webhooks (see the [Webhooks & Revalidation guide](/docs/sdk/advanced/webhooks-revalidation)).

---

## 4. Serving individual articles as Markdown

Each article and page can be served as standalone Markdown at its public URL path with a `.md` extension. For example, an article at `/blog/getting-started` becomes available at `/blog/getting-started.md`.

The SDK's `seo.getMarkdown()` method takes the content path and returns clean Markdown. The SDK appends `.md` to the path automatically -- you pass the path without the extension.

### How paths work

- **Blog articles** use `Content.path` directly. This path already includes the locale prefix for multi-language sites (e.g., `/fr/guides/getting-started`).
- **Pages** use `Page.path`. For multi-language sites, prepend the locale: `/${page.locale}${page.path}`.

### Route handler

Create a catch-all route that intercepts any URL ending with `.md`:

```typescript
// app/[...path]/route.ts
import { NextRequest } from 'next/server'
import { lynkow } from '@/lib/lynkow'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ path: string[] }> }
) {
  const { path } = await params
  const fullPath = '/' + path.join('/')

  // Only handle .md requests
  if (!fullPath.endsWith('.md')) {
    return new Response('Not Found', { status: 404 })
  }

  // Strip the .md extension -- the SDK adds it back internally
  const contentPath = fullPath.slice(0, -3)

  try {
    const markdown = await lynkow.seo.getMarkdown(contentPath)

    return new Response(markdown, {
      headers: {
        'Content-Type': 'text/markdown; charset=utf-8',
        'Cache-Control': 'public, max-age=3600, s-maxage=3600',
      },
    })
  } catch {
    return new Response('Not Found', { status: 404 })
  }
}
```

With this in place, every published article and page on your site is accessible as Markdown. A request to `https://example.com/blog/my-article.md` returns clean Markdown with the article title, metadata, and body content.

### Separating the .md route from page routes

If the catch-all above conflicts with your existing `[...slug]` page routes, place the `.md` handler in a middleware instead:

```typescript
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Rewrite .md requests to a dedicated API route
  if (pathname.endsWith('.md') && !pathname.startsWith('/api/')) {
    const url = request.nextUrl.clone()
    url.pathname = '/api/markdown' + pathname
    return NextResponse.rewrite(url)
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!_next|favicon.ico).*)'],
}
```

Then create the API route:

```typescript
// app/api/markdown/[...path]/route.ts
import { NextRequest } from 'next/server'
import { lynkow } from '@/lib/lynkow'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ path: string[] }> }
) {
  const { path } = await params
  const fullPath = '/' + path.join('/')

  if (!fullPath.endsWith('.md')) {
    return new Response('Not Found', { status: 404 })
  }

  const contentPath = fullPath.slice(0, -3)

  try {
    const markdown = await lynkow.seo.getMarkdown(contentPath)

    return new Response(markdown, {
      headers: {
        'Content-Type': 'text/markdown; charset=utf-8',
        'Cache-Control': 'public, max-age=3600, s-maxage=3600',
      },
    })
  } catch {
    return new Response('Not Found', { status: 404 })
  }
}
```

---

## 5. Multi-language support

Lynkow generates locale-specific versions of `llms.txt` and `llms-full.txt`. Each locale gets its own index containing only content published in that language.

### Locale-specific llms.txt

Pass the `locale` option to get content for a specific language:

```typescript
// app/[locale]/llms.txt/route.ts
import { NextRequest } from 'next/server'
import { lynkow } from '@/lib/lynkow'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ locale: string }> }
) {
  const { locale } = await params

  try {
    const content = await lynkow.seo.llmsTxt({ locale })

    return new Response(content, {
      headers: {
        'Content-Type': 'text/plain; charset=utf-8',
        'Cache-Control': 'public, max-age=86400, s-maxage=86400',
      },
    })
  } catch {
    return new Response('Not Found', { status: 404 })
  }
}
```

This serves:

- `/en/llms.txt` -- English content index
- `/fr/llms.txt` -- French content index
- `/es/llms.txt` -- Spanish content index

### Locale-specific llms-full.txt

Same pattern for the full document:

```typescript
// app/[locale]/llms-full.txt/route.ts
import { NextRequest } from 'next/server'
import { lynkow } from '@/lib/lynkow'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ locale: string }> }
) {
  const { locale } = await params

  try {
    const content = await lynkow.seo.llmsFullTxt({ locale })

    return new Response(content, {
      headers: {
        'Content-Type': 'text/plain; charset=utf-8',
        'Cache-Control': 'public, max-age=86400, s-maxage=86400',
      },
    })
  } catch {
    return new Response('Not Found', { status: 404 })
  }
}
```

### Default locale fallback

When no locale is specified (the routes from sections 2 and 3), the SDK returns content in the site's default locale. You do not need to pass a locale for single-language sites.

### Individual Markdown articles in multi-language sites

For blog articles, the `Content.path` returned by the SDK already includes the locale prefix. Pass it directly to `seo.getMarkdown()`:

```typescript
// Content.path = '/fr/guides/getting-started'
const markdown = await lynkow.seo.getMarkdown(content.path)
// Fetches: /fr/guides/getting-started.md
```

For pages, prepend the locale to the page path:

```typescript
// page.path = '/about', page.locale = 'fr'
const pagePath = `/${page.locale}${page.path}`
const markdown = await lynkow.seo.getMarkdown(pagePath)
// Fetches: /fr/about.md
```

---

## 6. Linking from robots.txt

Tell AI crawlers where to find your `llms.txt` by referencing it in `robots.txt`. This is the convention that emerging LLM crawlers look for.

If you already serve `robots.txt` through Lynkow (see the SEO & Analytics guide), you can enhance it:

```typescript
// app/robots.txt/route.ts
import { lynkow } from '@/lib/lynkow'

const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com'

export async function GET() {
  const robotsTxt = await lynkow.seo.robots()

  // Append llms.txt references
  const enhanced = [
    robotsTxt.trim(),
    '',
    `# LLM-readable content`,
    `# See https://llmstxt.org for the specification`,
    `Sitemap: ${SITE_URL}/llms.txt`,
  ].join('\n')

  return new Response(enhanced, {
    headers: {
      'Content-Type': 'text/plain',
      'Cache-Control': 'public, max-age=86400, s-maxage=86400',
    },
  })
}
```

The output will look like:

```
User-agent: *
Allow: /

Sitemap: https://example.com/sitemap.xml

# LLM-readable content
# See https://llmstxt.org for the specification
Sitemap: https://example.com/llms.txt
```

---

## 7. Linking from HTML head

Add a `<link>` tag to your root layout so browsers and crawlers can discover your `llms.txt` from any page on your site.

```typescript
// app/layout.tsx
import { ReactNode } from 'react'

const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com'

export const metadata = {
  metadataBase: new URL(SITE_URL),
  other: {
    'llms.txt': `${SITE_URL}/llms.txt`,
  },
}

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <head>
        <link
          rel="alternate"
          type="text/plain"
          href="/llms.txt"
          title="LLM-readable content"
        />
        <link
          rel="alternate"
          type="text/plain"
          href="/llms-full.txt"
          title="LLM-readable content (full)"
        />
      </head>
      <body>{children}</body>
    </html>
  )
}
```

For multi-language sites, include locale-specific links:

```typescript
// app/[locale]/layout.tsx
import { ReactNode } from 'react'

interface Props {
  children: ReactNode
  params: Promise<{ locale: string }>
}

export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params

  return (
    <html lang={locale}>
      <head>
        <link
          rel="alternate"
          type="text/plain"
          href={`/${locale}/llms.txt`}
          title={`LLM-readable content (${locale})`}
        />
        <link
          rel="alternate"
          type="text/plain"
          href={`/${locale}/llms-full.txt`}
          title={`LLM-readable content - full (${locale})`}
        />
      </head>
      <body>{children}</body>
    </html>
  )
}
```

---

## 8. Testing

### Verify llms.txt

Use `curl` to check each endpoint:

```bash
# Summary index
curl -s https://example.com/llms.txt

# Full content
curl -s https://example.com/llms-full.txt

# Individual article (note the .md extension)
curl -s https://example.com/blog/my-article.md

# Locale-specific
curl -s https://example.com/fr/llms.txt
```

### What to check

1. **llms.txt** should contain your site name as an H1, a description blockquote, and a list of links to `.md` URLs grouped by category.
2. **llms-full.txt** should contain the full Markdown body of every published article and page, separated by horizontal rules (`---`).
3. **Individual .md endpoints** should return the article title as an H1, publication date, category, tags, and the full body as Markdown.
4. **Response headers** should include `Content-Type: text/plain; charset=utf-8` (or `text/markdown; charset=utf-8`) and appropriate `Cache-Control` values.

### Check with AI tools

Test that your llms.txt is actually discoverable:

- **Perplexity**: Ask it a question about your site content. Sites with llms.txt tend to get cited.
- **ChatGPT with browsing**: Ask it to summarize your website. It should find and use the llms.txt.
- **llmstxt.org**: The specification site maintains a directory and validator.

### Local development

During development, test against your local server:

```bash
# Start the dev server
npm run dev

# Test endpoints
curl -s http://localhost:3000/llms.txt
curl -s http://localhost:3000/llms-full.txt
curl -s http://localhost:3000/blog/my-first-post.md
```

---

## 9. Complete implementation

Here is every file you need, consolidated for easy reference.

### SDK client

```typescript
// lib/lynkow.ts
import { createClient } from 'lynkow'

export const lynkow = createClient({
  siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!,
  fetchOptions: {
    next: { revalidate: 60 },
  },
})
```

### Route handlers

```typescript
// app/llms.txt/route.ts
import { lynkow } from '@/lib/lynkow'

export async function GET() {
  const content = await lynkow.seo.llmsTxt()

  return new Response(content, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'public, max-age=86400, s-maxage=86400',
    },
  })
}
```

```typescript
// app/llms-full.txt/route.ts
import { lynkow } from '@/lib/lynkow'

export async function GET() {
  const content = await lynkow.seo.llmsFullTxt()

  return new Response(content, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'public, max-age=86400, s-maxage=86400',
    },
  })
}
```

```typescript
// app/[locale]/llms.txt/route.ts
import { NextRequest } from 'next/server'
import { lynkow } from '@/lib/lynkow'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ locale: string }> }
) {
  const { locale } = await params

  try {
    const content = await lynkow.seo.llmsTxt({ locale })

    return new Response(content, {
      headers: {
        'Content-Type': 'text/plain; charset=utf-8',
        'Cache-Control': 'public, max-age=86400, s-maxage=86400',
      },
    })
  } catch {
    return new Response('Not Found', { status: 404 })
  }
}
```

```typescript
// app/[locale]/llms-full.txt/route.ts
import { NextRequest } from 'next/server'
import { lynkow } from '@/lib/lynkow'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ locale: string }> }
) {
  const { locale } = await params

  try {
    const content = await lynkow.seo.llmsFullTxt({ locale })

    return new Response(content, {
      headers: {
        'Content-Type': 'text/plain; charset=utf-8',
        'Cache-Control': 'public, max-age=86400, s-maxage=86400',
      },
    })
  } catch {
    return new Response('Not Found', { status: 404 })
  }
}
```

### Markdown catch-all with middleware

```typescript
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Rewrite .md requests to the markdown API route
  if (pathname.endsWith('.md') && !pathname.startsWith('/api/')) {
    const url = request.nextUrl.clone()
    url.pathname = '/api/markdown' + pathname
    return NextResponse.rewrite(url)
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!_next|favicon.ico).*)'],
}
```

```typescript
// app/api/markdown/[...path]/route.ts
import { NextRequest } from 'next/server'
import { lynkow } from '@/lib/lynkow'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ path: string[] }> }
) {
  const { path } = await params
  const fullPath = '/' + path.join('/')

  if (!fullPath.endsWith('.md')) {
    return new Response('Not Found', { status: 404 })
  }

  const contentPath = fullPath.slice(0, -3)

  try {
    const markdown = await lynkow.seo.getMarkdown(contentPath)

    return new Response(markdown, {
      headers: {
        'Content-Type': 'text/markdown; charset=utf-8',
        'Cache-Control': 'public, max-age=3600, s-maxage=3600',
      },
    })
  } catch {
    return new Response('Not Found', { status: 404 })
  }
}
```

### Enhanced robots.txt

```typescript
// app/robots.txt/route.ts
import { lynkow } from '@/lib/lynkow'

const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com'

export async function GET() {
  const robotsTxt = await lynkow.seo.robots()

  const enhanced = [
    robotsTxt.trim(),
    '',
    '# LLM-readable content',
    '# See https://llmstxt.org for the specification',
    `Sitemap: ${SITE_URL}/llms.txt`,
  ].join('\n')

  return new Response(enhanced, {
    headers: {
      'Content-Type': 'text/plain',
      'Cache-Control': 'public, max-age=86400, s-maxage=86400',
    },
  })
}
```

### Root layout with link tags

```typescript
// app/layout.tsx
import { ReactNode } from 'react'

const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com'

export const metadata = {
  metadataBase: new URL(SITE_URL),
}

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <head>
        <link
          rel="alternate"
          type="text/plain"
          href="/llms.txt"
          title="LLM-readable content"
        />
        <link
          rel="alternate"
          type="text/plain"
          href="/llms-full.txt"
          title="LLM-readable content (full)"
        />
      </head>
      <body>{children}</body>
    </html>
  )
}
```

---

## 10. Content negotiation with Accept and Vary

The route handler from section 4 serves Markdown at `/blog/my-article.md`. AI
crawlers also negotiate the response format on the canonical URL via the
`Accept` header: a request to `/blog/my-article` with
`Accept: text/markdown` should return Markdown, and the same URL with
`Accept: text/html` should return HTML.

This is two URLs in HTTP land (one canonical URL, two representations selected
by the request). Done correctly, both representations live behind a single
URL and intermediate caches cooperate via the `Vary: Accept` response header.

### The pattern

Two pieces wired together at the edge:

1. A proxy/middleware step inspects the `Accept` header. If the client
prefers Markdown, the request is rewritten to the Markdown route handler.
2. The Markdown response includes `Vary: Accept` so a CDN serves the right
variant on cache hits.

### Using the SDK helper (recommended)

The SDK ships a one-liner helper for this. It parses the `Accept` header
correctly (q-values, type wildcards), rewrites the URL only when the client
prefers Markdown over HTML, and sets `Vary: Accept` on the rewritten
response.

```typescript
// proxy.ts (Next 16)
import { NextResponse, type NextRequest } from 'next/server'
import { markdownProxy } from 'lynkow/middleware/next'

export function proxy(request: NextRequest) {
  const md = markdownProxy(request)
  if (md) return md
  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!_next|favicon.ico|md|api).*)'],
}
```

For Next 14 and Next 15 the file name is `middleware.ts` and the export is
`middleware` instead of `proxy`. Same body.

```typescript
// middleware.ts (Next 14, 15)
import { NextResponse, type NextRequest } from 'next/server'
import { markdownProxy } from 'lynkow/middleware/next'

export function middleware(request: NextRequest) {
  const md = markdownProxy(request)
  if (md) return md
  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!_next|favicon.ico|md|api).*)'],
}
```

### Companion route handler

The proxy rewrites to `/md/<original-path>`. Wire the route handler at
`app/md/[...path]/route.ts`:

```typescript
// app/md/[...path]/route.ts
import { NextRequest } from 'next/server'
import { lynkow } from '@/lib/lynkow'

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ path: string[] }> }
) {
  const { path } = await params
  const fullPath = '/' + path.join('/')

  // The proxy passes the original path with no `.md` suffix.
  // The SDK appends `.md` internally.
  const contentPath = fullPath.endsWith('.md') ? fullPath.slice(0, -3) : fullPath

  try {
    const markdown = await lynkow.seo.getMarkdown(contentPath)
    return new Response(markdown, {
      headers: {
        'Content-Type': 'text/markdown; charset=utf-8',
        'Cache-Control': 'public, max-age=3600, s-maxage=3600',
        'Vary': 'Accept',
      },
    })
  } catch {
    return new Response('Not Found', { status: 404 })
  }
}
```

Setting `Vary: Accept` on both the proxy rewrite and the route handler is
intentional defense in depth.

### Customizing the helper

If your Markdown route handler is mounted at a different path
(`/markdown` instead of `/md`), tell the helper:

```typescript
const md = markdownProxy(request, { mdPathPrefix: '/markdown' })
```

Other options:

- `setVary: false` if your CDN sets the `Vary` header for you.
- `setForwardedAccept: false` to skip the `X-Forwarded-Accept: text/markdown`
header that the helper adds to the rewritten request by default. The
forwarded header lets your route handler confirm the rewrite came from a
Markdown negotiation rather than a direct `.md` URL hit.

### Testing

Use `curl` to hit the same URL twice with different `Accept` headers and
verify the `Content-Type` switches:

```bash
# Asks for Markdown; should get text/markdown back
curl -i -H 'Accept: text/markdown' https://example.com/blog/my-article

# Asks for HTML (browser default); should get text/html back
curl -i -H 'Accept: text/html, application/xhtml+xml' https://example.com/blog/my-article
```

Both responses should include `Vary: Accept` (or in the HTML case, just be
aware that intermediate caches need that header on at least one variant to
keep representations separate).

### Doing it without the SDK helper

If you want to roll your own (minimal control flow):

```typescript
// proxy.ts (Next 16, manual implementation)
import { NextResponse, type NextRequest } from 'next/server'

function prefersMarkdown(accept: string | null): boolean {
  if (!accept) return false
  const md = parseQ(accept, 'text/markdown')
  if (md === 0) return false
  const html = parseQ(accept, 'text/html')
  return md > html
}

function parseQ(header: string, target: string): number {
  // ... a real implementation parses RFC 9110 q-values, type wildcards, etc.
  // Get this wrong and the negotiation breaks in subtle ways. Use the SDK helper.
  return header.includes(target) ? 1 : 0
}

export function proxy(request: NextRequest) {
  if (prefersMarkdown(request.headers.get('accept'))) {
    const url = request.nextUrl.clone()
    url.pathname = `/md${url.pathname}`
    const response = NextResponse.rewrite(url)
    response.headers.set('vary', 'accept')
    return response
  }
  return NextResponse.next()
}
```

The SDK helper exists to handle the parser correctly. Rolling your own is
fine for a quick prototype; for production, the helper is a one-line import
that takes care of the edge cases (q=0 means "do not send", `*\/*` wildcards,
malformed headers).

---

## 11. Next 16: proxy.ts and the App Router Vary quirk

Two Next-specific things matter for content negotiation. Both have caused
people to lose afternoons.

### Next 16 renamed `middleware.ts` to `proxy.ts`

In Next 16, the file at the project root has a new name and the exported
function has a new name:

| Next.js version | File name | Exported function |
| --- | --- | --- |
| 14, 15 | `middleware.ts` | `middleware` |
| 16 and later | `proxy.ts` | `proxy` |


The matcher config (`export const config = { matcher: [...] }`) and the
behavior are otherwise identical. If you upgrade a project from 15 to 16,
rename the file and rename the export. The SDK helper `markdownProxy` works
in both cases (its body does not change between Next versions).

### The App Router page route Vary clobbering

Setting `Vary: Accept` from `next.config.ts` `headers()` does not work for
App Router page routes. Next.js attaches its own `Vary` value
(`rsc, next-router-state-tree, next-router-prefetch, ...`) for RSC payloads
and overrides any value you tried to add. The end result: your page route
goes out without `Vary: Accept`, regardless of what your config says.

This is not a bug, it is by design. Workarounds:

1. **Set ****`Vary: Accept`**** from a route handler** (not a page). Route handlers
preserve the headers you set verbatim. The Markdown route handler from
section 10 is a route handler, so it works.
2. **Set ****`Vary: Accept`**** from the proxy/middleware response** (what the SDK
helper does on rewrites). Again, this works because the rewrite's
destination is a route handler.
3. Do NOT try to add `Vary: Accept` from `next.config.ts` for a page route
(URL like `/blog/my-article` rendered by `app/blog/[slug]/page.tsx`).
The header will be silently overwritten.

In practice, the SDK helper covers both cases: when a request prefers
Markdown, the rewrite-plus-route-handler flow sets `Vary: Accept`. When a
request prefers HTML, the page route returns its normal Next-managed
`Vary` (which is fine: the HTML representation is the same regardless of
the `Accept` value, so the cache key on `Vary: Accept` would not buy
anything).

---

## Summary

| Feature | SDK Method | Route | Content-Type |
| --- | --- | --- | --- |
| Summary index | `lynkow.seo.llmsTxt()` | `app/llms.txt/route.ts` | `text/plain` |
| Full content | `lynkow.seo.llmsFullTxt()` | `app/llms-full.txt/route.ts` | `text/plain` |
| Locale summary | `lynkow.seo.llmsTxt({ locale })` | `app/[locale]/llms.txt/route.ts` | `text/plain` |
| Locale full | `lynkow.seo.llmsFullTxt({ locale })` | `app/[locale]/llms-full.txt/route.ts` | `text/plain` |
| Single article | `lynkow.seo.getMarkdown(path)` | `app/api/markdown/[...path]/route.ts` | `text/markdown` |
| Robots.txt | `lynkow.seo.robots()` | `app/robots.txt/route.ts` | `text/plain` |
| HTML discovery | `<link rel="alternate">` | `app/layout.tsx` | -- |
| Content negotiation | `markdownProxy` (Next.js helper) | `proxy.ts` / `middleware.ts` | `text/markdown` |