---
title: Deploy to Netlify
description: Publish a Reed build on Netlify with netlify.toml, _headers and _redirects at the publish root, and forced redirects.
---

<Note>
This recipe is written from Netlify's documentation and Reed's output layout. It has not
been run as a live deploy. After your first deploy, check two things: a `.md` mirror comes
back as `text/markdown`, and a nested page URL such as `/docs/ship/deploy/` resolves. The
commands are in the last section.
</Note>

Netlify serves a **publish directory**. It reads `_headers` and `_redirects` from that
directory, serves `404.html` for paths that match no file, and redirects `/about` to
`/about/` through its Pretty URLs setting, which is on by default. Reed's output works with
all four.

## 1. Point Netlify at the build

Put the build settings in a `netlify.toml` at the root of your repository:

```toml
[build]
  command = "reed prefetch && reed build --out dist"
  publish = "dist/"
```

`publish` is the directory Netlify serves. Paths in `netlify.toml` are relative to the base
directory, which is the repository root unless you set `base`. Netlify's `base` key is a
different thing from Reed's `base` in `docs.json`.

The command has to find a `reed` CLI in Netlify's build image. `reed prefetch` needs the
network and a Corepack-resolvable `pnpm`; see [Offline builds in CI](/docs/ship/ci-offline)
for the Node version and cache details. If you already build in your own CI, deploy the
finished directory instead and leave `command` out.

## 2. Stage the tree if you set `base`

A Netlify site is served at the root of its domain. With `base: "/docs"` in `docs.json`,
every link Reed emits starts with `/docs/`, but the files sit at the root of `dist/`.
Publish `dist/` directly and `/docs/` is empty. The deploy succeeds and every page 404s.

Publish a staged directory with a real `docs/` segment instead:

```toml
[build]
  command = "sh scripts/stage.sh"
  publish = "stage/"
```

`scripts/stage.sh` builds, then copies the output one level down:

```sh
#!/bin/sh
set -eu

reed prefetch
reed build --out dist

rm -rf stage
mkdir -p stage/docs
cp -R dist/. stage/docs/

# Netlify reads _headers and _redirects from the publish directory's root.
mv stage/docs/_headers stage/_headers
# _redirects exists only when docs.json declares redirects.
if [ -f stage/docs/_redirects ]; then mv stage/docs/_redirects stage/_redirects; fi
```

`cp -R dist/. stage/docs/` copies the contents of `dist/`, not the directory. Rebuild
`stage/` from empty on every run so removed pages do not linger.

The general rule, for every host, is on [Deploy](/docs/ship/deploy).

## 3. `_headers` belongs at the publish root

Reed writes this `_headers` file into every build:

```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
```

Netlify applies `_headers` from the root of the publish directory. Its rules match served
URL paths, which is why the staging script moves the file out of `stage/docs/`. On a site
without `base`, it is already in the right place in `dist/`.

On a based site, `/*/llms.txt` is the rule that matches `/docs/llms.txt`. The bare
`/llms.txt` rule only covers a site at the root.

You can also write headers in `netlify.toml` as `[[headers]]` tables. You do not need to
here. Reed regenerates `_headers` on every build, and a hand-copied rule in
`netlify.toml` is one more thing to keep in step. What each rule protects is on
[Response headers](/docs/ship/headers).

<Warning>
Netlify's documentation describes `*` in header paths as matching any character, but it
also says wildcards work "inside of a path segment". It does not say whether `/*.md`
matches a `.md` file two directories deep. Check a nested mirror, not only a top-level
one. The command is in the last section.
</Warning>

## 4. The 404 page on a based site

Netlify serves a `404.html` automatically for paths that match no file. On a based site
Reed's 404 page is at `stage/docs/404.html`, not at the root. Tell Netlify to use it for
unknown paths under the mount:

```toml
[[redirects]]
  from = "/docs/*"
  to = "/docs/404.html"
  status = 404
```

This does not hide real pages. By default Netlify does not apply a redirect when a file
exists at the requested path, so it only fires for paths that would 404 anyway.

## Real redirects need a forced rule

When `docs.json` declares `redirects`, `reed build` writes two things for each entry: a
line in `_redirects`, with the `base` prefix already applied, and a small meta-refresh
page at the old path. See [Redirects](/docs/site/redirects) for what is emitted.

On Netlify the page wins. Netlify skips a redirect rule when a file exists at the source
path, which it calls shadowing, and Reed's meta-refresh page is that file. Reed's
`_redirects` lines carry no `!` force flag, because Cloudflare reads the same file and
drops any line whose status is not a plain number. So out of the box, Netlify serves the
meta-refresh page with a 200, whatever `permanent` says.

For a real 301, add a forced rule in `netlify.toml`:

```toml
[[redirects]]
  from = "/docs/old-guide"
  to = "/docs/guides/getting-started/"
  status = 301
  force = true
```

`force = true` makes the rule win over the file at that path. Keep it in `netlify.toml`
rather than adding `!` to the generated `_redirects`: `reed build` rewrites that file on
every run, and Cloudflare would reject the edited line.

Other rules from Netlify's documentation that affect this:

- The default status is 301 if you leave it out.
- Rules in `_redirects` run before rules in `netlify.toml`. Within a file, the first
  matching rule wins.
- Netlify matches paths with and without a trailing slash as the same path. Do not write
  a rule that only adds or removes a slash. It loops.
- Write the base prefix yourself. In `docs.json` Reed adds it for you; in `netlify.toml`
  nothing does.

Check each forced rule after the first deploy, with the command in the next section. If
the old path still answers with a 200, the meta-refresh page is being served.

## Verify after the first deploy

Check that a nested Markdown mirror has the right content type:

```sh
curl -s -o /dev/null -w "%{content_type}\n" https://example.netlify.app/docs/ship/deploy.md
```

Expect `text/markdown; charset=utf-8`. If you get anything else, `_headers` is not at the
publish root, or the `/*.md` rule is not matching nested paths. In the second case, add a
rule per directory depth, or use a `[[headers]]` table and test again.

Check that a nested page resolves:

```sh
curl -s -o /dev/null -w "%{http_code}\n" https://example.netlify.app/docs/ship/deploy/
```

Expect `200`. A 404 on a based site means `publish` points at `dist/` instead of `stage/`.

If you added forced redirect rules, check one:

```sh
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" https://example.netlify.app/docs/old-guide
```

Expect `301` and the destination URL. A `200` means the meta-refresh page is still
winning: the rule is missing `force = true`, or its `from` lacks the base prefix.

Also request an unknown URL under `/docs/`. You should get a 404 status and Reed's page.
Then grep a live page for a string from this build, as [Deploy](/docs/ship/deploy)
describes.

## Related

- [Cloudflare Pages](/docs/ship/hosts/cloudflare-pages) reads the same two files, but its
  redirects win over existing files without a `!`.
- [Vercel](/docs/ship/hosts/vercel) for a host that does not read `_headers`.
- [site and base](/docs/site/addressing) for the two link-prefix rules.
- [What you actually own](/docs/ship/self-host) for why any static host works.
- [llms.txt](/docs/derived/llms-txt) for what the `text/plain` rule protects.
