Skip to content

Deploy to Netlify

Publish a Reed build on Netlify with netlify.toml, _headers and _redirects at the publish root, and forced redirects.

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.

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.

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 for the Node version and cache details. If you already build in your own CI, deploy the finished directory instead and leave command out.

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.

Reed writes this _headers file into every build:

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

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.

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.

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

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 describes.