---
title: "Serving Markdown alongside your HTML"
description: "A short, practical guide to giving every page a plain-text twin, and why that beats hoping a crawler parses your markup correctly."
author: "Ali Demir"
published: 2026-07-19
tags: ["astro", "the web", "code"]
canonical: https://alideemir.com/blog/serving-markdown-alongside-html/
source: https://alideemir.com/blog/serving-markdown-alongside-html.md
---

# Serving Markdown alongside your HTML

If you want a machine to understand your writing, the most effective thing you can do is stop making it read HTML. Hand it the source instead.

The pattern is simple: for every page at `/blog/some-post/`, also serve the original Markdown at `/blog/some-post.md`. No navigation, no header, no footer, no class attributes — just the text as written. This post covers how to do it in Astro, and the three details that are easy to get wrong.

## The route

Astro decides a route from the filename minus its final extension, so a file named `[...slug].md.ts` produces routes ending in `.md`. It sits happily beside the `.astro` route that renders the same content as a page.

```ts
// src/pages/blog/[...slug].md.ts
import type { APIRoute, GetStaticPaths } from 'astro';
import { getCollection } from 'astro:content';

export const getStaticPaths: GetStaticPaths = async () => {
  const posts = await getCollection('posts');
  return posts.map((post) => ({
    params: { slug: post.id },
    props: { post },
  }));
};

export const GET: APIRoute = ({ props }) => {
  const { post } = props;

  return new Response(post.body, {
    headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
  });
};
```

That is the whole mechanism. `post.body` is the raw file content with the frontmatter already stripped, which is exactly what you want to hand over.

## Detail one: put the metadata back

Stripping frontmatter is right for rendering and wrong for this. A consumer fetching the `.md` file has no page around it to tell them who wrote the thing or when. Put a small block back on the way out:

```ts
const frontmatter = [
  '---',
  `title: ${JSON.stringify(data.title)}`,
  `published: ${data.publishedAt.toISOString().slice(0, 10)}`,
  `canonical: https://example.com/blog/${post.id}/`,
  '---',
].join('\n');

return new Response(`${frontmatter}\n\n# ${data.title}\n\n${post.body}`);
```

Use `JSON.stringify` for any string that reaches YAML. Titles contain colons and quotation marks more often than you would guess, and a title like `Astro: a review` will produce invalid YAML if you interpolate it bare.

The `canonical` line matters more than it looks. It is what stops the Markdown twin from being treated as a competing copy of the page rather than a representation of it.

## Detail two: advertise it

A file nobody knows about is not an interface. Link it from the HTML page so it can be discovered rather than guessed at:

```html
<link
  rel="alternate"
  type="text/markdown"
  href="https://example.com/blog/some-post.md"
  title="Markdown source"
/>
```

I also link it visibly in the post footer. There is no reason to hide it from people — the plain-text version is genuinely nicer to read on a bad connection, and the kind of reader who notices it is the kind of reader worth having.

## Detail three: the content type is not automatic

In a static build the `.md` files land on disk, and from that point the `Content-Type` header is your host's decision, not Astro's. Many hosts serve unknown extensions as `application/octet-stream`, which makes browsers download the file instead of displaying it.

On Netlify:

```toml
# netlify.toml
[[headers]]
  for = "/*.md"
  [headers.values]
    Content-Type = "text/markdown; charset=utf-8"
```

On Vercel:

```json
{
  "headers": [
    {
      "source": "/(.*).md",
      "headers": [
        { "key": "Content-Type", "value": "text/markdown; charset=utf-8" }
      ]
    }
  ]
}
```

Check it after your first deploy. `curl -sI https://example.com/blog/some-post.md | grep -i content-type` takes five seconds and will tell you immediately whether the whole exercise is working.

## Then do the same thing for the site

Once every post has a text twin, an index over them costs almost nothing, and the `llms.txt` convention gives you a shape to put it in: a title, a one-line summary, and a list of links that each carry a description. Point the links at the `.md` files rather than the pages.

```
# Your Site

> One sentence about what this site is.

## Writing

- [Post title](https://example.com/blog/post.md): What it argues, in one line.
```

A model arriving at that file can see the entire site at once and fetch only what it needs, instead of crawling the navigation and inferring the rest. The whole thing is about forty lines of code and it replaces a great deal of guessing.
