diff --git a/README.md b/README.md
index a0579a5..73b9301 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ Nuxt LLMs automatically generates [`llms.txt` markdown documentation](https://ll
- Generates & prerenders `/llms.txt` automatically
- Generate & prerenders `/llms-full.txt` when enabled
- Customizable sections directly from your `nuxt.config.ts`
+- `useLlmsAlternate` composable to advertise per-page markdown alternates (RFC 8288)
- Integrates with Nuxt modules and your application via the runtime hooks system
## Quick Setup
@@ -120,6 +121,32 @@ export default defineNuxtConfig({
})
```
+## Per-page markdown discovery
+
+`/llms.txt` advertises your site at the global level, but agents crawling individual pages have no standard way to know that a markdown counterpart exists. The `useLlmsAlternate` composable solves this by emitting two discovery hints per [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288):
+
+- `` in the document `
`
+- `Link: <...>; rel="alternate"; type="text/markdown"` HTTP response header
+
+Agents can read the `Link` header from a `HEAD` request — without parsing HTML — and switch to the markdown URL for ingestion.
+
+```vue
+
+
+```
+
+The composable accepts a string, a `ref`, or a getter. Falsy values are ignored, so it is safe to call before async data resolves:
+
+```ts
+const { data: post } = await useAsyncData(/* ... */)
+useLlmsAlternate(() => post.value?.markdownUrl)
+```
+
+This pairs naturally with `@nuxt/content`'s `/raw/.md` endpoint, but works with any markdown URL — including endpoints you serve yourself.
+
## Extending the documentation using hooks
The module provides a hooks system that allows you to dynamically extend both documentation formats. There are two main hooks:
diff --git a/playground/pages/[...slug].vue b/playground/pages/[...slug].vue
index 8923059..3175ce3 100644
--- a/playground/pages/[...slug].vue
+++ b/playground/pages/[...slug].vue
@@ -7,6 +7,11 @@ const { data } = await useAsyncData(
return await queryCollection('content').path(route.path).first()
},
)
+
+useLlmsAlternate(() => {
+ const path = data.value?.path
+ return path && path !== '/' ? `/raw${path}.md` : null
+})
diff --git a/src/module.ts b/src/module.ts
index 5f6666c..a0b2fde 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -4,6 +4,7 @@ import {
addServerHandler,
addPrerenderRoutes,
addServerImports,
+ addImports,
useLogger,
} from '@nuxt/kit'
import { version } from '../package.json'
@@ -23,6 +24,11 @@ export default defineNuxtModule({
const logger = useLogger('nuxt-llms')
const { resolve } = createResolver(import.meta.url)
+ addImports({
+ name: 'useLlmsAlternate',
+ from: resolve('./runtime/composables/useLlmsAlternate'),
+ })
+
const llmsConfig = (nuxt.options.runtimeConfig.llms = {
domain: options.domain,
title: options.title,
diff --git a/src/runtime/composables/useLlmsAlternate.ts b/src/runtime/composables/useLlmsAlternate.ts
new file mode 100644
index 0000000..f46e380
--- /dev/null
+++ b/src/runtime/composables/useLlmsAlternate.ts
@@ -0,0 +1,39 @@
+import { computed, toValue, type MaybeRefOrGetter } from 'vue'
+import { setResponseHeader } from 'h3'
+import { useHead, useRequestEvent } from '#imports'
+
+/**
+ * Advertise a markdown alternate of the current page so AI agents can
+ * discover and fetch the markdown version instead of parsing HTML.
+ *
+ * Emits two discovery hints (RFC 8288):
+ * - `` in the document head
+ * - `Link: <...>; rel="alternate"; type="text/markdown"` HTTP response header (server only)
+ *
+ * @example
+ * ```ts
+ * // app/pages/blog/[slug].vue
+ * useLlmsAlternate(`/raw/blog/${route.params.slug}.md`)
+ * ```
+ *
+ * Accepts a string, ref, or getter. Falsy values are ignored, so it is safe to
+ * call before async data has resolved.
+ */
+export function useLlmsAlternate(href: MaybeRefOrGetter): void {
+ const resolved = computed(() => toValue(href) || null)
+
+ useHead({
+ link: () => {
+ const value = resolved.value
+ return value ? [{ rel: 'alternate', type: 'text/markdown', href: value }] : []
+ },
+ })
+
+ if (import.meta.server) {
+ const value = resolved.value
+ if (!value) return
+ const event = useRequestEvent()
+ if (!event) return
+ setResponseHeader(event, 'Link', `<${value}>; rel="alternate"; type="text/markdown"`)
+ }
+}
diff --git a/test/alternate.test.ts b/test/alternate.test.ts
new file mode 100644
index 0000000..c4bd7e1
--- /dev/null
+++ b/test/alternate.test.ts
@@ -0,0 +1,61 @@
+import { fileURLToPath } from 'node:url'
+import { describe, it, expect } from 'vitest'
+import { setup, $fetch, fetch } from '@nuxt/test-utils/e2e'
+
+describe('useLlmsAlternate', async () => {
+ await setup({
+ rootDir: fileURLToPath(new URL('./fixtures/alternate', import.meta.url)),
+ })
+
+ describe('with a literal href', () => {
+ it('emits a in the document head', async () => {
+ const html = await $fetch('/')
+ expect(html).toContain('rel="alternate"')
+ expect(html).toContain('type="text/markdown"')
+ expect(html).toContain('href="/raw/index.md"')
+ })
+
+ it('emits a Link HTTP response header', async () => {
+ const response = await fetch('/')
+ expect(response.headers.get('link')).toBe(
+ '; rel="alternate"; type="text/markdown"',
+ )
+ })
+ })
+
+ describe('with a getter', () => {
+ it('resolves the getter and emits both hints', async () => {
+ const html = await $fetch('/getter')
+ expect(html).toContain('href="/raw/post-from-getter.md"')
+
+ const response = await fetch('/getter')
+ expect(response.headers.get('link')).toBe(
+ '; rel="alternate"; type="text/markdown"',
+ )
+ })
+ })
+
+ describe('with falsy values (null, empty string, getter returning undefined)', () => {
+ it('does not emit a tag', async () => {
+ const html = await $fetch('/empty')
+ expect(html).not.toContain('rel="alternate"')
+ })
+
+ it('does not emit a Link header', async () => {
+ const response = await fetch('/empty')
+ expect(response.headers.get('link')).toBeNull()
+ })
+ })
+
+ describe('on a page that does not call the composable', () => {
+ it('does not emit a tag', async () => {
+ const html = await $fetch('/none')
+ expect(html).not.toContain('rel="alternate"')
+ })
+
+ it('does not emit a Link header', async () => {
+ const response = await fetch('/none')
+ expect(response.headers.get('link')).toBeNull()
+ })
+ })
+})
diff --git a/test/fixtures/alternate/app.vue b/test/fixtures/alternate/app.vue
new file mode 100644
index 0000000..8f62b8b
--- /dev/null
+++ b/test/fixtures/alternate/app.vue
@@ -0,0 +1,3 @@
+
+
+
diff --git a/test/fixtures/alternate/nuxt.config.ts b/test/fixtures/alternate/nuxt.config.ts
new file mode 100644
index 0000000..84d83f6
--- /dev/null
+++ b/test/fixtures/alternate/nuxt.config.ts
@@ -0,0 +1,8 @@
+export default defineNuxtConfig({
+ modules: ['../../../src/module'],
+ compatibilityDate: '2025-06-06',
+ llms: {
+ domain: 'https://llms.nuxt.com',
+ title: 'Nuxt LLMs module',
+ },
+})
diff --git a/test/fixtures/alternate/package.json b/test/fixtures/alternate/package.json
new file mode 100644
index 0000000..3dcd06d
--- /dev/null
+++ b/test/fixtures/alternate/package.json
@@ -0,0 +1,5 @@
+{
+ "private": true,
+ "name": "alternate",
+ "type": "module"
+}
diff --git a/test/fixtures/alternate/pages/empty.vue b/test/fixtures/alternate/pages/empty.vue
new file mode 100644
index 0000000..7692cc5
--- /dev/null
+++ b/test/fixtures/alternate/pages/empty.vue
@@ -0,0 +1,10 @@
+
+
+
+