---
title: A URL That Speaks Markdown
date: '2026-07-11'
description: 'The article you are reading is also a plain-markdown file, a .md away. This is the content negotiation that serves the same writing to people and to machines from one source, and why it matters now that some of your readers are not people.'
author: Pablo Fernandez
tags:
  - LLMs
  - Next.js
  - Content Negotiation
  - Markdown
  - SEO
source: 'https://www.pablofr.com/blog/a-url-that-speaks-markdown'
---
Paste one of these articles into a chat with an assistant and look at what it actually receives. Not the page you see. A few kilobytes of navigation, a theme script that runs before anything paints, hydration markers, three dialects of metadata, and then, somewhere past all of it, the sentences. The writing is in there. It is just wearing a costume.

Your browser knows how to take the costume off. It runs the scripts, lays out the page, and hands you something to read. A language model fetching the same URL gets the raw HTML and has to work out which fraction of it is the article. Usually it manages. But "usually" was carrying more and more weight as I started leaning on assistants to read, summarise and act on my own writing, and the gap between what I publish and what a machine receives began to nag.

So the question here is narrow. How do you serve the same article twice, once as a page for a person and once as clean text for a machine, without keeping two copies that quietly drift apart? The answer is one of the oldest ideas on the web, and about forty lines of code.

## The markdown was always the source

Start with the thing that makes this easier than it sounds: on this site the article is not a database row poured into a template. It is a markdown file. Every post lives as an `.mdx` file on disk, frontmatter on top and prose below, and the HTML page is something I generate from it at request time. I wrote about that machinery in [its own post](/blog/more-backend-than-a-blog-needs); the short version is that markdown is the original and the web page is a render of it.

That reframes the whole problem. Serving markdown to a machine is not building a second copy. It is handing back the thing the page was made from. The only question left is plumbing: how does a request ask for the source instead of the render?

## Two representations, one resource

[Content negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation) is the web's word for this, and it is about as old as HTTP. One resource, several representations, and the client says which one it wants. The textbook mechanism is the `Accept` header: ask `/blog/slug` for `text/html` and you get the page, ask the same URL for `text/markdown` and you get the source.

The header is the pure version, and it is invisible. You cannot type it into an address bar, you cannot paste it into a chat, you cannot see it in a link. So I used the visible cousin instead: a suffix on the URL. `/blog/slug` is the page, `/blog/slug.md` is the markdown. It is the same instinct behind a `.json` endpoint, and it has quietly become a convention for sites that want to be read by models.

```
              posts/slug.mdx
                     │
          ┌──────────┴──────────┐
          │                     │
          ▼                     ▼
     /blog/slug           /blog/slug.md
  ┌───────────────┐     ┌───────────────┐
  │     HTML      │     │   Markdown    │
  │ for a reader  │     │  for an LLM   │
  └───────────────┘     └───────────────┘
```

*One file, two representations. The suffix asks for the other one.*

Both come from the one file. Neither is a copy. The suffix is just a way of saying "give me the other representation" that a person can type and a link can carry. There were other ways to get here, and it is worth seeing why the suffix won.

| Approach | Single source | Works for a bare fetch | Easy to point at |
|---|---|---|---|
| A hand-written `.md` twin | No, it drifts | Yes | Yes, if you link it |
| Convert the DOM in the browser | Yes | No, it needs a JS runtime | No |
| `Accept: text/markdown` header | Yes | Yes | No, it is invisible |
| A `.md` suffix on the URL | Yes | Yes | Yes, you can guess it |

A hand-written twin drifts the first time you edit one and forget the other. Converting the rendered page to markdown in the browser is clever until you remember that the readers you are doing this for are the ones that do not run a browser. The `Accept` header is the correct answer that nobody can find. The suffix is guessable, linkable, and served straight from the source.

## Catching the suffix

[Next.js](https://nextjs.org) gives you an obvious place to route this: a [rewrite](https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites) in `next.config`. I tried it first, and it does not hold. The trouble is the dot. Rewrite sources are compiled by [path-to-regexp](https://github.com/pillarjs/path-to-regexp), where a dot is a delimiter, so a pattern like `/blog/:slug.md` does not bind the slug the way you would hope. It matched inconsistently and dropped the parameter, and I did not want to fight the matcher over a two-character suffix.

So I catch it one layer up, in [`proxy.ts`](https://nextjs.org/docs/app/api-reference/file-conventions/proxy) (Next.js 16 renamed the old `middleware` file to `proxy`). The proxy sees every `/blog/*` request, and a plain regular expression, not a route pattern, decides whether this one ends in `.md`. If it does, it rewrites the request to a route handler and gets out of the way.

```typescript:src/proxy.ts {5}
export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl

  // /blog/<slug>.md  ->  serve the markdown, not the page
  const md = pathname.match(/^\/blog\/(.+)\.md$/)
  if (md) {
    const url = request.nextUrl.clone()
    url.pathname = `/api/posts/${md[1]}/markdown`
    return NextResponse.rewrite(url)
  }
  return NextResponse.next()
}

export const config = { matcher: ["/blog/:path*"] }
```

The matcher is deliberately broad. It runs on every blog request, does one regex test, and for an ordinary page view returns `next()` immediately. That is the whole cost: a string match on the way in.

```
       GET /blog/slug.md
               │
               ▼
      ┌─────────────────┐
      │    proxy.ts     │
      │   match .md ?   │
      └────────┬────────┘
               │ rewrite
               ▼
   ┌───────────────────────┐
   │    markdown route     │
   │  read the .mdx file   │
   │  serialize and send   │
   └───────────┬───────────┘
               │
               ▼
         text/markdown
```

*A .md request is caught by the proxy and rewritten to the route that reads the source.*

## One builder, two readers

The rewrite lands on a [route handler](https://nextjs.org/docs/app/api-reference/file-conventions/route), a small `GET` that reads the same `.mdx` the page reads and returns it as `text/markdown`.

```typescript:src/app/api/posts/[slug]/markdown/route.ts
export async function GET(
  _req: Request,
  { params }: { params: Promise<{ slug: string }> },
) {
  const { slug } = await params
  const post = await getPostBySlug(slug)
  if (!post) return new Response("Not found\n", { status: 404 })

  const md = buildPostMarkdown(post, `${getPublicSiteUrl()}/blog/${slug}`)
  return new Response(md, {
    headers: { "content-type": "text/markdown; charset=utf-8" },
  })
}
```

The interesting line is `buildPostMarkdown`. It is the single place that turns a post into a markdown document, and it does the boring work carefully: it re-emits the frontmatter with [gray-matter](https://github.com/jonschlinkert/gray-matter), normalises the date to a plain `YYYY-MM-DD`, and adds a `source` field pointing back at the canonical page, so a model that swallows the file still knows where it came from.

```typescript:src/lib/article-markdown.ts {10}
export function buildPostMarkdown(post: Post, source: string): string {
  const data = {
    title: post.title,
    date: toISODate(post.date),
    description: post.description,
    tags: post.tags,
    source,
  }
  // lineWidth: -1 stops YAML folding long fields into its ">-" block form.
  return matter.stringify(post.content.trim() + "\n", data, { lineWidth: -1 })
}
```

That last argument is a small mercy. Without `lineWidth: -1` the YAML serialiser folds a long description into its `>-` block form, which is valid and reads like a bug to anyone who has not memorised the spec. The output is for reading, so it should read.

The reason this function stands on its own, instead of living inline in the route, is that one source has to reach two readers.

```
                     posts/slug.mdx
                            │
             ┌──────────────┴──────────────┐
             ▼                             ▼
     buildPostMarkdown              render pipeline
   ┌───────────────────┐             ┌───────────┐
   │   .md endpoint    │             │   HTML    │
   │  View as Markdown │             │  (page)   │
   └───────────────────┘             └───────────┘
```

*One builder makes the Markdown; the endpoint serves it and the page's View as Markdown opens it, so they never disagree.*

One reader is the machine that fetches the `.md` straight. The other is a person on the page: the menu beside "Copy page" has a "View as Markdown" that opens the very same endpoint, alongside "Open in Claude", "Open in ChatGPT" and "Open in Perplexity", each of which hands that `.md` URL to an assistant. The page keeps no second copy of its own. Every door opens the one the builder feeds.

## What the machine gets now

Fetch the `.md` and there is no costume. Frontmatter with the title, date, tags and canonical URL, then the prose, in the order I wrote it.

```markdown:/blog/how-reactions-work.md
---
title: A Reaction Bar That Remembers You Without Knowing You
date: '2026-07-06'
description: An emoji reaction bar with no login has a trust problem...
tags:
  - Reactions
  - Postgres
source: 'https://www.pablofr.com/blog/how-reactions-work'
---

At the bottom of every post now there is a little row of emojis...
```

An assistant can quote that without stripping tags, summarise it without guessing where the article starts, and cite the `source` link when it does. It is the difference between handing someone a document and handing them the box the document shipped in.

This is a small local move inside a larger shift. Conventions are forming for exactly this problem: [llms.txt](https://llmstxt.org) proposes a site-level map for models, and the [Model Context Protocol](https://modelcontextprotocol.io) gives assistants a way to pull content in directly. A per-article `.md` is the smallest brick in that wall. It costs almost nothing, and it means that when a model does reach for my writing, it gets the writing.

## Where the seams were

The first version of this admitted three seams, and closing them turned out to be the interesting part.

- **The body is real Markdown now.** The `.md` used to hand back the MDX as authored, components and all. A single transform now converts the vocabulary the posts are built from: an `AsciiDiagram` becomes a fenced code block with its caption, an `EmojiChip` becomes the emoji, a `References` block becomes a list of links, a `Checklist` becomes checkboxes. Code samples are shielded before any of this runs, so a component shown as an example is never rewritten. Anything outside the known set is left as raw JSX rather than mangled, which is the honest fallback: a component I have not taught it about still shows through.
- **The header negotiates now, not only the suffix.** Send `Accept: text/markdown` to `/blog/slug` and you no longer get HTML. The proxy reads the header, and when Markdown outranks HTML it redirects to the `.md`. A redirect rather than the same URL answering twice, and on purpose: two representations keep two cache keys, so nothing in front can hand a browser the Markdown or an agent the page. The suffix is still the version you can type; the header is the version a machine can find without being told.
- **Discovery has a few doors now.** Every page carries a `Link` header pointing at its `.md` (`rel="alternate"; type="text/markdown"`) and a matching `<link>` in the head, so a bare fetch or a `HEAD` sees the source without reading a line of HTML, and [llms.txt](https://llmstxt.org) lists the Markdown URL for every post. None of it makes a model reach for the source. It just means the sign is up before anyone thinks to look, which is the part of the convention I can build on my own side.

## The plain render

The cheapest way to be legible to a machine, it turns out, is to stop pretending your writing only exists as a web page. The markdown was always the source. The HTML is one render of it, the pretty one, the one with the costume. Content negotiation just says that out loud and hands the plain render to whoever asks for it.

Add `.md` to the address of this page and you will see the version I actually write. The one you are reading now is the one wearing clothes.
