---
title: Fix llms.txt or .md pages downloading instead of displaying
description: The host serves .md and .txt as a download. Put Reed's _headers at the served root, or set the types on the host.
---

## Symptom

Opening `https://example.com/docs/llms.txt` or a page's markdown mirror such as
`https://example.com/docs/quickstart.md` makes the browser download a file instead of
showing text. Assistant tools that fetch the URL — "Open in ChatGPT", "Open in Claude", an
agent reading `llms.txt` — fail or return nothing useful, even though the URL answers 200.

The response header gives it away:

```sh
curl -sI https://example.com/docs/llms.txt | grep -i content-type
```

prints `content-type: application/octet-stream`.

A bare `text/markdown` with no `charset` is a milder form of the same problem: some
fetchers reject a `text/*` response that does not declare its encoding.

## Cause

Static hosts pick a content type from the file extension, and many have no mapping for
`.md` — they fall back to `application/octet-stream`, which browsers treat as a download.

Reed writes a `_headers` file into every build to fix exactly this:

```txt
/*.md
  Content-Type: text/markdown; charset=utf-8

/llms.txt
  Content-Type: text/plain; charset=utf-8

/*/llms.txt
  Content-Type: text/plain; charset=utf-8
```

It only helps when the host reads it, and three things stop that:

- **The host does not use `_headers` at all.** It is a convention of Cloudflare Pages,
  Cloudflare Workers static assets, and Netlify. Vercel, nginx, S3 and most others ignore
  the file.
- **`_headers` is in the wrong place on a `base` mount.** Hosts read it only at the root
  of the served directory, and its rules match served URL paths. If you staged the build
  into `docs/` for a `base: "/docs"` site, `_headers` came along into `docs/_headers`,
  where it is ignored.
- **You are testing `reed dev`.** The dev server serves the `.md` mirrors but writes no
  `llms.txt` and no `_headers`. Check a real build.

## Fix

### Cloudflare and Netlify: move `_headers` to the served root

On a `base` mount, move it to the top of the tree you deploy, beside the mount directory,
not inside it:

```txt
stage/
  docs/          <- the contents of dist/
    llms.txt
    quickstart.md
  _headers       <- here
```

Reed's own documentation does this in its build script, right after staging the site into
`stage/docs/`:

```sh
mv stage/docs/_headers stage/_headers
```

The file is the one Reed emitted, so there is nothing to keep in sync by hand. On
Cloudflare, the `*` in `/*.md` is verified to match across path segments, so `/docs/a/b.md`
is covered from the root, and `/*/llms.txt` covers the mounted `/docs/llms.txt`. Netlify's
documentation does not say whether `*` crosses segments, so on Netlify check a nested
mirror such as `/docs/a/b.md` after you deploy, not only a top-level one. Without a
`base`, deploy `dist/` as it is — `_headers` is already at its root.

### Vercel: add headers to `vercel.json`

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

### nginx: map the extension

Add a `types` entry inside the `server` or `location` that serves the site:

```txt
types {
    text/markdown md;
    text/plain    txt;
}
charset utf-8;
charset_types text/markdown text/plain;
```

A `types` block replaces the inherited MIME map for that context, so include
`mime.types` again or scope the block to a location that serves only `.md` and `.txt`.
Reload nginx after editing.

### Other hosts

Set `Content-Type` per object at upload (S3 and similar), or use the host's own header
rules. Translate the three rules above; the values are what matter.

## Verify

Check the served header, not the file's presence:

```sh
curl -sI https://example.com/docs/llms.txt | grep -i content-type
curl -sI https://example.com/docs/quickstart.md | grep -i content-type
```

Expect `text/plain; charset=utf-8` and `text/markdown; charset=utf-8`. Still getting
`application/octet-stream` on Cloudflare or Netlify almost always means `_headers` is
inside the mount directory. If the values look right but have not changed, you may be
reading a cached response — purge the CDN cache or add a query string and retry.

## Related

- [Response headers](/docs/ship/headers) — the `_headers` file and where it lives
- [llms.txt and markdown mirrors](/docs/derived/llms-txt)
- Host setup: [Cloudflare Workers](/docs/ship/hosts/cloudflare-workers),
  [Cloudflare Pages](/docs/ship/hosts/cloudflare-pages),
  [Netlify](/docs/ship/hosts/netlify), [Vercel](/docs/ship/hosts/vercel),
  [nginx](/docs/ship/hosts/nginx)
- [Fix a whole site that 404s after a successful deploy](/docs/troubleshooting/base-mount-404)
