--- title: "How I built my portfolio for LLMs" description: "Markdown pages, Content Signals, link headers, and a lot of deliberate plumbing." canonical_url: "https://aidanhibbard.dev/posts/how-i-built-my-portfolio-for-llms" last_updated: "2026-07-03T02:52:06.709Z" --- ## Background This site is a portfolio and blog. Humans read it in a browser. Increasingly, other things read it too: crawlers, agents, answer engines, whatever you want to call the thing that hit your `/posts/` route with an odd `Accept` header at 2am. HTML is a bad wire format for that. Layout chrome, hydration markers, nested divs, and a sidebar nobody asked for. Markdown is the format agents actually want. So I built the site twice, once for people and once for machines, without maintaining two copies of every article by hand. Most of the stack is [Nuxt](https://nuxt.com/) modules. A fair chunk of the behavior is custom Nitro code that serves markdown, sets the headers agents expect, and wires up discovery metadata. ## The module stack These are the pieces that matter for discoverability and machine-readable output: - [`@nuxtjs/seo`](https://nuxtseo.com/docs/nuxt-seo/getting-started/introduction) — sitemap, robots, canonical URLs, OG helpers, and the rest of the boring SEO baseline - [`nuxt-ai-ready`](https://nuxtseo.com/docs/ai-ready/getting-started/introduction) — `llms.txt`, Content Signals in `robots.txt`, IndexNow hooks - [`@nuxt/content`](https://content.nuxt.com/) — markdown source of truth under `content/` - [`nuxt-schema-org`](https://nuxtseo.com/docs/schema-org/getting-started/introduction) (via the SEO module) — JSON-LD for Person, BlogPosting, AboutPage - [`nuxt-og-image`](https://nuxtseo.com/docs/og-image/getting-started/installation) — generated social images from page metadata - [`nuxt-security`](https://nuxt-security.vercel.app/) — CSP, SRI, security headers on prerendered output Humans still get a normal Vue app. The modules mostly handle metadata and static artifacts. The interesting part is how markdown escapes the content layer. ## The tooling and specs A lot of the agent-readiness landscape right now comes from [Cloudflare](https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/). I do not use their edge conversion product. I use their *standards* and validate against their scanner. **Content Signals** extend what `robots.txt` can express. Not just "can you crawl this," but what you are allowed to do after you fetch it: index it for search, feed it to an answer engine, train on it. Responses can also carry `Content-Signal: search=yes, ai-train=yes, ai-input=yes`. **Markdown for Agents** defines how that should work on the wire: `Accept: text/markdown`, markdown responses, `x-markdown-tokens`, discovery `Link` headers, Content Signals on the way out. Cloudflare can do the conversion on their network. I implement the same contract in Nitro with source files from `@nuxt/content`. That means YAML frontmatter stays intact, no HTML-to-markdown guesswork, and headers that match what the spec documents. Agents learn one shape. Your app either speaks it or it does not. ## Markdown routes and content negotiation Every supported page has a markdown alternate. - `/about` → `/about.md` - `/posts/building-durable-chats` → `/posts/building-durable-chats.md` Two ways in: 1. **Explicit .md URL** — what you see in "View on GitHub" links and what I put in `llms.txt` notes 2. **Accept: text/markdown** on the HTML path — same body, negotiated response A Nitro handler runs early in the stack and checks whether the request wants markdown. If yes, it resolves the page path, queries `@nuxt/content`'s raw endpoint, and returns `text/markdown` with the right headers. The `/raw/:slug.md` route handles title, description, and link injection automatically. ```typescript [server/api/content/markdown.get.ts] const { path } = await getValidatedQuery(event, query => contentMarkdownQuerySchema.parse(query)) const rawPath = path === '/' ? '/index.md' : `${path}.md` const markdown = await $fetch(`/raw${rawPath}`, { signal: event.request.signal }) setHeader(event, 'content-type', 'text/markdown; charset=utf-8') return markdown ``` Route rules no longer serve `*.md` paths dynamically — `@nuxt/content` handles `/raw/:slug.md` natively. ## Link headers and agent discovery HTML responses advertise the markdown alternate with HTTP `Link` headers. That is what the [Markdown for Agents](https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/) docs describe and what [Is Your Site Agent-Ready?](https://isitagentready.com/) checks for. On the homepage: ```http Link: ; rel="service-desc", ; rel="describedby", ; rel="alternate"; type="text/markdown" ``` On every other supported page, a `beforeResponse` hook adds something like: ```http Link: ; rel="alternate"; type="text/html", ; rel="alternate"; type="text/markdown" ``` `rel="service-desc"` points at `llms.txt`. `rel="describedby"` points at the full-text aggregate. `rel="alternate"; type="text/markdown"` is the per-page source. Humans never see these headers. Agents do. Markdown responses also set `Vary: Accept, Sec-Fetch-Dest` and `x-markdown-tokens` with a rough token estimate so clients can budget context windows without parsing the body first. ## @nuxt/content raw markdown endpoints Starting with v3.12, `@nuxt/content` serves raw markdown natively at `/raw/:slug.md`. The module queries the content collection, prepends a title `h1` and description `blockquote` when the source lacks one, and appends a `