Skip to content

Fix a whole site that 404s after a successful deploy

With base set, files sit at the root of dist/ but every URL starts with the base. Serve them from a matching directory.

The deploy command exits zero and reports success. Every page on the live site returns 404 — the home page, every docs page, the CSS. Typically:

  • docs.json sets base, for example "base": "/docs";
  • the host’s asset or publish directory points straight at your --out directory, such as dist/;
  • https://example.com/docs/ is a 404, while https://example.com/index.html (no /docs) loads an unstyled page whose links all 404.

If a local static server pointed at dist/ shows the same thing, you are looking at this problem, not a CDN or DNS issue.

base changes the URLs inside the HTML. It does not move files. A build with base: "/docs" emits dist/index.html with every internal link and asset reference prefixed /docs/..., but dist/index.html itself sits at the root of dist/.

Most static hosts serve their asset directory’s file tree verbatim as URL paths: <directory>/a/b.html is served at /a/b.html. Nothing adds or strips a path segment. Point that directory at dist/ and the tree contains no docs/ segment at all, so /docs/ maps to nothing. The whole site 404s while the deploy reports success — the host uploaded every file correctly, just at the wrong paths.

Routing does not rescue you. On a Cloudflare Worker, for example, routes decides which requests reach the Worker; it never rewrites the path.

Make the mount path physically exist in the tree you serve. Build to dist/, then stage it one level down, inside a directory named after your base:

txt
stage/
docs/ <- the contents of dist/
index.html
404.html
llms.txt
_headers <- stays at the root

Then point the host at stage/, not dist/.

This is the shape of the script Reed’s own documentation uses (scripts/build.sh):

sh
set -euo pipefail
MOUNT_SEGMENT="docs" # must match docs.json's base
rm -rf dist
reed build --out dist
test -f dist/index.html
rm -rf stage
mkdir -p "stage/$MOUNT_SEGMENT"
cp -R dist/. "stage/$MOUNT_SEGMENT/"
mv "stage/$MOUNT_SEGMENT/_headers" stage/_headers

Three details matter:

  • dist/. as the copy source copies the contents of dist/, not a dist folder.
  • stage/ is deleted and rebuilt on every run, so a page removed from a later build cannot survive as a stale file.
  • _headers goes to the root of stage/, not inside docs/. Hosts read it only at the top of the served directory, and its rules match served URL paths — see Response headers. Move it rather than copy it: a copy left in docs/ is served as a public file at /docs/_headers.

For a Cloudflare static-assets Worker, set the asset directory in wrangler.jsonc:

jsonc
{
"assets": {
"directory": "./stage",
"html_handling": "auto-trailing-slash",
"not_found_handling": "404-page"
}
}

not_found_handling: "404-page" is worth setting while you are here: the default returns a bare 404 with an empty body instead of serving the 404.html Reed builds.

For other hosts, set the equivalent publish or output directory to stage — or, for nginx, put the files in a docs/ directory under the server root. Keep the segment in sync in three places: base in docs.json, the staging directory name, and any route pattern your host uses.

Deploying a stale or absent stage/ is the other way to ship nothing. Run the build script immediately before every deploy, not once.

Check the tree before you upload it:

sh
test -f stage/docs/index.html && test -f stage/_headers && echo "layout ok"

After deploying, fetch the live site and check for a string from this build, not just the status code:

sh
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/docs/
curl -s https://example.com/docs/ | grep -c "a phrase you just added"

Expect 200 and a count above zero. A deploy command exiting zero is not evidence that the site is being served.