nuxt-llms-txt
v0.0.2
Published
Make your Nuxt site readable by AI. Markdown mirrors of every page, plus a properly sectioned llms.txt — zero config, source-aware.
Maintainers
Readme
nuxt-llms-txt
Make your Nuxt site readable by AI. A Markdown mirror of every page, plus a
properly sectioned llms.txt index — zero config, and source-aware.
npx nuxi module add nuxt-llms-txtexport default defineNuxtConfig({
modules: ['nuxt-llms-txt'],
})That's it. Build the site and you get:
| Route | What it is |
| --- | --- |
| /about.md | Clean Markdown mirror of /about, with title/description frontmatter |
| /about + Accept: text/markdown | The same Markdown, via content negotiation (Vary: Accept) |
| /llms.txt | A sectioned index of the whole site, per llmstxt.org |
| /llms-full.txt | Every page inlined, for one-shot ingestion (opt in with full: true) |
Every HTML page also advertises its mirror with <link rel="alternate" type="text/markdown">
and a matching Link: response header.
Why another one of these
There are two existing options, and neither does the whole job:
| | nuxt-llms-txt | nuxt-llms | @mdream/nuxt |
| --- | :---: | :---: | :---: |
| llms.txt | ✅ | ✅ | ✅ |
| Per-page .md mirrors | ✅ | ❌ | ✅ |
| Routes discovered automatically | ✅ | ❌ hand-written config | ✅ |
| Publishes your original Markdown when a page has a source file | ✅ | n/a | ❌ HTML round-trip |
| Sectioned index with real descriptions | ✅ | manual | ❌ flat list |
| Per-locale llms.txt | ✅ | ❌ | ❌ |
| Works on a plain Vue-pages app (no @nuxt/content) | ✅ | ❌ | ✅ |
The differences that matter in practice:
It publishes your source, not a round-trip. If a page came from a Markdown file, that file's Markdown is what gets served — MDC components, fence labels, tables and all. Converting rendered HTML back to Markdown loses every one of those. Pages that aren't Markdown-backed fall back to HTML conversion automatically, so a mixed site works either way.
::alert{type="warning"}
Keep your key on the server.
::
```bash [npm]
npm install @acme/sdk
```Keep your key on the server.
```
npm install @acme/sdk
```::alert{type="warning"}
Keep your key on the server.
::
```bash [npm]
npm install @acme/sdk
```The index is organised. Sections are derived from your route tree, and a
top-level segment only becomes its own heading once it has children — so a flat
marketing site gets one clean list, and a docs site gets ## Docs / ## Blog:
# Acme Inc
> Acme builds tools for building tools.
## Pages
- [Acme Inc](https://acme.test/index.md): Acme builds developer tools that get out of your way.
- [Pricing](https://acme.test/pricing.md): Simple, transparent pricing for teams of every size.
## Docs
- [Documentation](https://acme.test/docs.md): Guides, API reference and recipes.
- [Getting started](https://acme.test/docs/getting-started.md): Install the SDK and make your first request.
## Optional
- [Terms of service](https://acme.test/legal/terms.md): The legal agreement between you and Acme Inc.Descriptions are real. They come from useSeoMeta / og:description /
frontmatter, falling back to the first substantial paragraph — and optionally
from an LLM (see below).
How it works
.md files and llms.txt are written during prerendering, so a static build
(nuxi generate) needs no server at all. For SSR routes that are never
prerendered, a Nitro middleware renders and converts on demand — so
/team/ada-lovelace.md works even though that page only exists at request time.
Content is isolated before conversion: <nav>, <header>, <footer>,
<aside>, <script> and anything marked data-ai-ignore are stripped, so the
Markdown is the page, not the chrome.
<template>
<main>
<h1>Pricing</h1>
<div data-ai-ignore>
<CookieBanner />
</div>
</main>
</template>Content negotiation
Markdown is served from the HTML route only when the client explicitly asks for it and does not ask for HTML:
| Accept | Response |
| --- | --- |
| text/html,... (any browser) | HTML |
| text/markdown | Markdown |
| */* (curl, most HTTP libraries) | HTML |
The */* case deliberately keeps returning HTML — guessing otherwise would
break ordinary API consumers. Disable the whole behaviour with
negotiate: false.
Configuration
Everything is optional. Defaults shown.
export default defineNuxtConfig({
modules: ['nuxt-llms-txt'],
llmsTxt: {
// Absolute site URL, used for absolute links in llms.txt.
// Inferred from `site.url` or NUXT_PUBLIC_SITE_URL when omitted.
domain: 'https://example.com',
// llms.txt header. `title` falls back to `site.name`.
title: 'Example',
description: 'What this site is.',
notes: 'Free-form prose under the summary.',
// Which routes to map. `exclude` is *merged* with the built-in defaults
// (/api/**, /_**, /200, /404), so you never have to re-list them.
include: ['/**'],
exclude: [],
// Routes listed under the conventional `## Optional` heading.
optional: ['/legal/**'],
// Override the derived sections entirely.
sections: [
{ title: 'Documentation', match: '/docs/**', description: 'Guides and API reference.' },
{ title: 'Blog', match: '/blog/**' },
],
// Content isolation.
mainSelector: ['main', '[role="main"]', 'article', '#__nuxt', 'body'],
stripSelector: ['nav', 'header', 'footer', 'aside', '[aria-hidden="true"]', '[data-ai-ignore]'],
// Outputs.
markdownFiles: true, // write /foo.md next to every prerendered page
full: false, // also emit /llms-full.txt — or { maxChars: 400_000 }
// Runtime behaviour.
runtime: true, // serve .md for SSR routes
negotiate: true, // honour `Accept: text/markdown`
linkAlternate: true, // <link rel="alternate"> + Link: header
// Prefer original Markdown sources. Auto-detected with @nuxt/content;
// point it somewhere else with { dir: 'docs' }, or turn it off.
source: true,
},
})AI-written descriptions (opt in)
Pages with no description can have one written for them at build time.
llmsTxt: {
ai: {
provider: 'anthropic', // or 'openai'
model: 'claude-opus-5', // default; 'gpt-4o-mini' for openai
apiKey: process.env.ANTHROPIC_API_KEY,
only: 'missing', // or 'all' to rewrite every description
concurrency: 5,
},
}Three things make this safe to leave in a shared repo:
- No key, no calls. Without
apiKeythe step is skipped entirely, so contributors and CI build the site without an account. - Results are cached in
node_modules/.cache/nuxt-llms-txt/keyed by a content hash, so a rebuild costs nothing and only edited pages are re-billed. - Failures degrade. A rate limit or refusal leaves the existing description in place and logs a warning; it never fails your build.
Install whichever SDK you use — they are optional peer dependencies:
pnpm add -D @anthropic-ai/sdk # or: openaiOr bypass both providers entirely:
llmsTxt: {
ai: {
summarize: async page => myOwnSummarizer(page.markdown),
},
}i18n
With @nuxtjs/i18n installed, locales are detected automatically. Each gets
its own index at /{locale}/llms.txt listing only that locale's pages, and the
primary index cross-links the others under ## Optional. Locale prefixes are
stripped before sections are derived, so /fr/docs/intro lands under ## Docs
alongside its English twin — not in a section called "Fr".
Hooks
Both run at build time, after prerendering.
export default defineNuxtConfig({
hooks: {
// Mutate the page corpus: drop pages, rewrite Markdown, fix titles.
'llms-txt:pages'(pages) {
for (const page of pages) {
if (page.origin === 'html') page.markdown = tweak(page.markdown)
}
},
// Mutate, add or drop output files just before they are written.
'llms-txt:files'(files) {
files.push({ path: '/llms-extra.txt', contents: '...', kind: 'llms.txt' })
},
},
})Using the core without Nuxt
The conversion and llms.txt machinery is framework-free and published
separately, for scripts, CI checks or another framework's adapter:
import { buildLlmsTxt, htmlToMarkdown } from 'nuxt-llms-txt/core'
const { title, description, markdown } = htmlToMarkdown(html, {
domain: 'https://example.com',
})
const index = buildLlmsTxt(pages, { title: 'Example', domain: 'https://example.com' })Notes and limits
llms.txtneeds prerendering. The index is built from prerendered pages, so a pure-SSR build with no prerendered routes produces none. Setnitro.prerender.crawlLinks: true(or list routes) to get one. Per-page.mdmirrors do not need this — the runtime middleware covers them.- Content source mapping follows
@nuxt/content's default path rules (numeric1.prefixes stripped,indexfolded into its parent). Customsourceprefixes may not map; override individual routes withsourceMap, or setsource: falseto always convert from HTML. - Pages marked
noindexare excluded from both the index and the mirrors.
Contributing
pnpm install
pnpm dev:prepare # generate module + playground types
pnpm dev # playground at localhost:3000
pnpm verify # lint + types + tests + buildTwo playgrounds cover both paths: playground/ is a plain Vue-pages app,
playground-content/ is a @nuxt/content site.
