Astro Pages 404 After Deploy

Pages work in dev but 404 in prod — almost always a build.format, trailingSlash, or output-mode mismatch with the host. Copy-paste fix path.

npm run dev works fine, but after deploying to Vercel, Netlify, or Cloudflare Pages, /about returns 404 — or only the homepage loads and every other route is dead. This is the single most common Astro deploy gotcha. It is almost never an Astro bug; it is that build.format, trailingSlash, or output is out of sync with the host’s defaults.

Fastest fix: run npm run build && npm run preview locally and open a non-homepage route. If it 404s there too, the problem is your astro.config.mjs — set build.format: 'directory' and pick one trailingSlash value (most hosts are happy with 'ignore'). If local preview works but production 404s, the problem is the host’s redirect or rewrite layer, not Astro.

First, which bucket are you in?

Run this one command before changing anything — it splits every case into two:

npm run build && npm run preview
# open http://localhost:4321/about (any non-homepage route)
Local preview resultProduction resultWhere the bug livesJump to
404404Astro config (build.format / output / base)Steps 2, 4, 5 below
200404Host redirect / rewrite / cache layerSteps 3, 6 below
200200Already fixed, or a stale CDN cache — purge itStep 6 below

npm run preview serves the real dist/ output the same way most static hosts do, so it reproduces production far more faithfully than npm run dev (which uses Vite’s dev server and resolves routes differently).

Common causes

Ordered by hit rate.

1. build.format doesn’t match the host’s trailing-slash behavior

Astro’s build.format decides whether each page is emitted as about/index.html (directory) or about.html (file). Vercel and Netlify both serve /about cleanly from either form, but they expect directory by default; Cloudflare Pages serves about.html for /about automatically (so file is fine there); self-hosted nginx without try_files usually needs the file form.

Symptom: dist/ only contains about.html, but the host is looking for about/index.html at /about and 404s (or vice versa).

How to spot it: ls dist/ to see whether you got about.html or about/index.html, then compare against the URL the host is actually fetching.

2. Trailing-slash redirect fights Astro’s routing

The host adds a /about/ to /about 301 (or the reverse), but Astro only emitted one form. The request gets redirected to a URL that doesn’t exist, and you land on the 404 page. Astro 6.1.x (the trailing-slash handling was reworked through early 2026, with fixes shipping in 6.1.3 on April 1, 2026) honors your trailingSlash config more strictly than older versions, so a host redirect that disagrees with it now surfaces as a hard 404 more often.

How to spot it: curl -I https://yoursite.com/about and watch for 200, 301, or 404. If you see a 301, follow it with curl -IL https://yoursite.com/about and check whether the final hop is 200 or 404.

3. output: "server" but the host serves static only

astro.config.mjs has output: "server", so the build produces an SSR bundle that needs a running server (or serverless function). If the host only serves static files (GitHub Pages, plain S3, a bare object store), every route 404s and only index.html may resolve.

Note: as of Astro v5 (late 2024) the old output: "hybrid" value was removed. The default output: "static" already lets individual pages opt out of prerendering with export const prerender = false, which is what hybrid used to do. If your config still says output: "hybrid", the build will error — change it to "static".

How to spot it: look in dist/ for _worker.js, a .vercel/ directory, or a server/ directory. If they exist, you shipped a server bundle and need a matching adapter plus a host that runs it.

4. Missing SPA fallback (and the catch-all that breaks everything)

A real Astro static site does not need an SPA fallback — each route is a real HTML file. You only need a fallback if you hand-wrote a client-side router on top of Astro. The classic mistake here is the opposite: people paste a catch-all rewrite that captures every request and serves index.html for everything, which silently breaks all the other pages and even CSS/JS.

How to spot it: visit /anything-that-doesnt-exist. A static Astro site should serve your 404 page. If instead every URL renders the homepage, you have an over-broad catch-all rewrite (see Step 3 for the fix).

5. Wrong base path

When you deploy to a subpath (e.g. https://user.github.io/repo/), you must set base: "/repo" in astro.config.mjs, or every internal link and asset URL points to the wrong place. The page HTML may load while every link and stylesheet 404s.

How to spot it: open DevTools, go to the Network tab, reload, and check that asset paths start with the correct /repo/ prefix.

Shortest path to fix

Step 1: Reproduce locally with npm run build && npm run preview

Reproduce before touching the host so you don’t conflate Astro config with host rewrite rules:

npm run build
npm run preview
# then open http://localhost:4321/about

If local preview already 404s, it’s an Astro config problem (continue to Step 2). If local works but prod 404s, it’s the host layer (skip to Step 3).

Step 2: Inspect the dist/ output

ls dist/
# Expect: about/index.html (directory mode)
# Or:     about.html (file mode)

Cross-check astro.config.mjs:

// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  site: 'https://yoursite.com',
  trailingSlash: 'ignore', // 'always' | 'never' | 'ignore'
  build: {
    format: 'directory', // 'directory' | 'file'
  },
  output: 'static', // 'static' | 'server'  (no 'hybrid' since Astro v5)
});
HostRecommended build.formattrailingSlash
Verceldirectoryignore
Netlifydirectoryignore
Cloudflare Pagesdirectoryignore
GitHub Pagesdirectoryalways
Plain S3 + CloudFrontfilenever

trailingSlash: 'ignore' is the most forgiving choice for the big three managed hosts because it lets Astro serve a route with or without the slash instead of forcing a redirect that the host might disagree with. Reach for 'always' or 'never' only when you have a specific reason (canonical-URL consistency, GitHub Pages quirks) and have set the host to match.

Step 3: Align (or remove) the host’s rewrite layer

The fix here depends on the host. The common failure is a catch-all rewrite copied from an SPA tutorial.

Vercel — control trailing-slash in vercel.json and let the framework preset handle routing. Do not add a catch-all rewrite for a static site:

{
  "trailingSlash": false
}

Netlify — for a static multi-page Astro site, do NOT add /* -> /index.html 200. That rule captures every request (including JS and CSS), serves them the homepage HTML, and 404s every other page. Just publish dist/; Netlify automatically serves your generated 404.html for unmatched paths:

# netlify.toml
[build]
  publish = "dist"
  command = "npm run build"

# No catch-all redirect for a static MPA.
# Add redirects ONLY for genuine route changes, e.g.:
# [[redirects]]
#   from = "/old-page"
#   to = "/new-page"
#   status = 301

The /* -> /index.html 200 rewrite is only correct for a true single-page app where one HTML file boots a client-side router.

Cloudflare Pages — it serves about.html for /about automatically, so a static Astro site usually needs no _redirects at all. If you ship a 404.html (Astro does this for a custom 404 page), Pages serves it for unmatched paths. If you do NOT ship a 404.html, Pages assumes the project is an SPA and serves the root for every path, which can mask real routing problems.

After editing, make sure the host’s trailing-slash setting and Astro’s trailingSlash agree. Mixing always on one side with never on the other is the classic redirect-to-a-404 loop.

Step 4: If you set output: "server", install the matching adapter

# Vercel
npm install @astrojs/vercel

# Netlify
npm install @astrojs/netlify

# Cloudflare
npm install @astrojs/cloudflare
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel';

export default defineConfig({
  output: 'server',
  adapter: vercel(),
});

Two things changed and trip people up: the import is now @astrojs/vercel, not the old @astrojs/vercel/serverless subpath (that export was removed; importing it now throws). And deploying output: "server" without an adapter means the route table is never generated and every dynamic path 404s.

Step 5: Check base if you deploy to a subpath

Only relevant for GitHub Pages project sites and similar subpath deploys:

// astro.config.mjs
export default defineConfig({
  site: 'https://user.github.io',
  base: '/repo', // must match the subpath; no trailing slash
});

After setting base, all your internal links should use the import.meta.env.BASE_URL prefix (or Astro’s <a href={...}> helpers) so they resolve under /repo/.

Step 6: Clear the host’s deploy cache and redeploy

After config changes, the host often reuses the previous build or a stale CDN copy:

  • Vercel: dashboard, Deployments, latest deploy, Redeploy, uncheck “Use existing Build Cache”.
  • Cloudflare Pages: Workers & Pages, your project, then trigger a fresh deployment; purge the CDN cache from Caching, Configuration if an old route is still cached.
  • Netlify: Deploys, Trigger deploy, “Clear cache and deploy site”.

How to confirm it’s fixed

After redeploy, run a quick smoke test against production from your terminal:

for p in / /about /blog /this-should-404; do
  echo -n "$p -> "; curl -s -o /dev/null -w "%{http_code}\n" "https://yoursite.com$p"
done

You want 200 for your real routes and 404 (not 200, and not an endless 301 chain) for the nonexistent path. If /this-should-404 returns 200, you still have an over-broad catch-all rewrite. If a real route returns 301, follow it with curl -IL and verify the final hop is 200.

Prevention

  • Document build.format, trailingSlash, output, and the host’s rewrite rules in the README so new contributors can cross-check them at a glance.
  • Run npm run preview locally before every deploy and hit at least three non-homepage routes (a nested route, a dynamic route, and a deliberately missing path to confirm the 404 page).
  • Run npx astro check in CI to catch routing and config errors early.
  • Add a post-deploy smoke test (the curl loop above) and alert if any status isn’t what you expect.
  • Before flipping to output: "server", confirm the host adapter is installed and the build emits a server bundle (.vercel/, _worker.js, or server/).

FAQ

Why does /about work in npm run dev but 404 after deploy? The dev server uses Vite and resolves routes on the fly, so it never exercises the static file layout. npm run build && npm run preview serves the real dist/ output and reproduces production. If preview 404s too, it’s your Astro config; if only production 404s, it’s the host.

Should I use trailingSlash: 'always', 'never', or 'ignore'? For Vercel, Netlify, and Cloudflare Pages, 'ignore' is the safest default — it avoids forcing a redirect the host might disagree with. Use 'always' or 'never' only when you need canonical-URL consistency and have configured the host to match exactly.

My config says output: "hybrid" and the build now errors. What changed? output: "hybrid" was removed in Astro v5. Change it to output: "static". Static is now the mode that prerenders by default while letting individual pages opt out with export const prerender = false, which is exactly what hybrid used to do.

I get an error importing @astrojs/vercel/serverless. How do I fix it? That subpath export was removed. Import the adapter as import vercel from '@astrojs/vercel' and use adapter: vercel(). Configuration that used to live in the subpath is now passed as options to vercel({ ... }).

Every URL on my Netlify site shows the homepage. Why? You almost certainly have a /* -> /index.html 200 rewrite in netlify.toml or public/_redirects. That catch-all is for single-page apps; on a static multi-page Astro site it serves the homepage for everything and even breaks asset requests. Remove it and let Netlify serve your generated 404.html for unmatched paths.

Cloudflare Pages serves the homepage for routes that should 404. Why? If your build doesn’t emit a top-level 404.html, Cloudflare Pages assumes the project is a single-page app and serves the root for every unmatched path. Add a custom 404 page (Astro outputs 404.html from src/pages/404.astro) so Pages serves real 404s.

Tags: #Hosting #Debug #Troubleshooting #Astro #Route 404