Once an Astro content site crosses 1,000 pages, fixing a single typo can trigger a multi-minute full rebuild and a CDN purge that touches every URL. Astro has no single “incremental build” flag, but as of June 2026 the combination of Astro 5’s Content Layer cache, on-demand routes for the handful of pages that change daily, and per-file CDN purging gets you about 90% of the way there. This guide is the exact workflow I run on a bilingual site of ~3,000 pages per language.
TL;DR
- Under 500 pages: just rebuild. The fastest path is no extra machinery.
- 500-5,000 pages: upgrade to Astro 5 so Content Layer caches parsing between builds (up to 5x faster Markdown, 2x faster MDX, 25-50% less memory), gate CI on a content hash, and let your host purge per-file.
- Past 5,000 pages: move the daily-churn pages (homepage, latest list, RSS, sitemap) to on-demand rendering so a content edit invalidates one static page plus a few server routes, not the whole site.
- The one real gotcha: Astro 5 removed the
output: 'hybrid'mode. Useoutput: 'static'(the default) and opt individual routes in withexport const prerender = false;.
Why Astro rebuilds everything
Astro’s build is deterministic by design: in output: 'static' mode it regenerates every page from scratch so the output is reproducible from source. That is great for correctness and terrible for hot edits, because changing one word still walks all 3,000 pages. There is no diff step that says “only these two pages depend on this file.” The fix is not a magic flag; it is build-time caching, on-demand rendering for the few pages you genuinely do not want to rebuild, and CDN-level partial invalidation.
Symptoms that you have outgrown plain rebuilds
npm run buildtakes more than 90 seconds and is climbing with every new article.- You batch small content fixes for “the weekend” instead of shipping them immediately.
- Your CDN purges all URLs on every deploy and you are burning free-tier purge quota.
- CI minutes are dominated by Astro page generation, not by tests or lint.
- You have started writing skip-build hacks into pre-deploy scripts.
If none of these are true, stop here. Complexity you do not need is its own kind of debt.
Step 1: get the Content Layer cache working (Astro 5+)
The single biggest free win in 2026 is being on Astro 5 with content collections backed by the Content Layer API. Per Astro’s own numbers, content collections build up to 5x faster for Markdown and up to 2x faster for MDX on content-heavy sites, with memory use cut 25-50%. The Content Layer keeps a local data store that persists between builds (under .astro/), and its loaders use a digest check so an entry that has not changed does not get re-processed.
Define collections with a glob loader so file changes are tracked by the store:
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const articles = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/articles' }),
schema: z.object({
title: z.string(),
description: z.string(),
publishedAt: z.date(),
draft: z.boolean().default(false),
}),
});
export const collections = { articles };
Then persist .astro/ across CI runs. The cache is what makes the parsing speedup show up in CI, not just on your laptop:
# .github/workflows/deploy.yml (excerpt)
- uses: actions/cache@v4
with:
path: |
node_modules/.astro
.astro
key: astro-store-${{ hashFiles('src/content/**/*.mdx') }}
restore-keys: astro-store-
This caches the parse step. It does not cache the static HTML generation step itself, so it shaves the front half of the build, not the back half. That distinction matters for the next steps.
Step 2: gate CI on a content hash
The cheapest skip is detecting that nothing relevant changed and short-circuiting the whole build. Hash every content file (frontmatter included, because frontmatter-only edits still change the rendered HTML) plus your layout files, and exit early when the manifest matches:
// scripts/build-if-changed.mjs
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
const inputs = [
...readdirSync('src/content', { recursive: true }).map(f => `src/content/${f}`),
...readdirSync('src/layouts', { recursive: true }).map(f => `src/layouts/${f}`),
].filter(f => /\.(mdx|md|astro|ts)$/.test(f));
const hash = createHash('sha256');
for (const f of inputs.sort()) hash.update(readFileSync(f));
const digest = hash.digest('hex');
const last = existsSync('.build-hash') ? readFileSync('.build-hash', 'utf8') : '';
if (digest === last) {
console.log('No content or layout changes; skipping build.');
process.exit(0);
}
writeFileSync('.build-hash', digest);
process.exit(1); // non-zero signals CI to continue to the build step
This will not save your single-typo deploy, but it catches the large share of commits that are docs-only, dependency bumps, or workflow edits. Persist .build-hash the same way you persist .astro/.
Step 3: move daily-churn pages to on-demand rendering
Static generation is the right default for an article that changes once a month. It is the wrong default for the homepage, the “latest” list, RSS, and the sitemap, which change every time you publish. Render those on demand so editing an article invalidates the one static article page plus those few server routes, instead of forcing a full rebuild to refresh a list.
Astro 5 removed the old output: 'hybrid' mode. Keep the default output: 'static' and opt the dynamic routes in per file:
// astro.config.mjs
import { defineConfig } from 'astro';
import vercel from '@astrojs/vercel';
export default defineConfig({
output: 'static', // default; pages are prerendered unless opted out
adapter: vercel(),
prefetch: { defaultStrategy: 'viewport' },
});
---
// src/pages/index.astro — render this route on demand
export const prerender = false;
const latest = (await getCollection('articles'))
.sort((a, b) => +b.data.publishedAt - +a.data.publishedAt)
.slice(0, 20);
---
Now your 3,000 article pages stay static and cacheable forever, while the handful of index pages reflect new content the instant it is published, no rebuild required.
Step 4: purge only the paths that changed
After a build, diff the generated dist/ against the last deploy and purge only the changed HTML files instead of the whole zone:
# diff dist/ between deploys, then purge only changed paths
git -C dist diff --name-only HEAD~1 HEAD -- '**/*.html' \
| sed 's#^#https://yourdomain.com/#' \
| jq -R -s -c 'split("\n")[:-1] | {files: .}' \
| curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
--data @-
Most managed hosts already do per-file invalidation by content hash, so you rarely need this on them:
| Host | Per-file purge on deploy | Plan note (June 2026) |
|---|---|---|
| Vercel | Automatic (immutable, hash-keyed) | Hobby free, non-commercial; Pro $20/seat |
| Cloudflare Pages | Automatic | Generous free tier |
| Netlify | Automatic (atomic deploy) | Free tier; on-demand purge via API |
| Firebase Hosting | No automatic per-file purge | Spark free, commercial OK; you drive purge yourself |
| Raw S3 + CloudFront | No | You must script the invalidation |
The script above is only worth writing on Firebase Hosting, S3/CloudFront, or any setup where you control the cache layer directly.
Common mistakes
- Reaching for on-demand rendering on a 200-page site. The cold-start and config overhead is not worth it; just rebuild.
- Using
output: 'hybrid'from an old tutorial. That mode no longer exists in Astro 5; the build will error. - Purging the entire CDN cache on every deploy out of habit when your host already does per-file invalidation.
- Writing a custom incremental builder that re-implements Astro’s internals. It breaks on the next minor release.
- Hashing only the article body and skipping frontmatter or layouts. Both change the rendered HTML.
- Treating dev-mode HMR as a stand-in for production incremental builds. Dev mode serves pages on demand and never runs the full static generation path.
FAQ
- Does Astro have an official one-flag incremental build?: Not as of June 2026. Astro 5’s Content Layer caches data loading and parsing between builds, but static HTML generation still walks every page. The production answer is the combination of caching, on-demand routes, and partial purge above.
- How much does the Content Layer actually speed up a content-heavy build?: Astro reports up to 5x faster Markdown collection builds and up to 2x for MDX, with 25-50% lower memory. The gain is in parsing and data loading; the HTML render step is unchanged, so expect the front half of your build to shrink, not the whole thing.
- What replaced
output: 'hybrid'?: Astro 5 unified the modes. Useoutput: 'static'(default, everything prerendered) and addexport const prerender = false;to the routes you want server-rendered. Useoutput: 'server'only if most of your site is dynamic. - Is caching
.astro/andnode_modulesin CI enough?: It caches dependency install and parsing, saving seconds to a minute on large sites, but not the page-generation step that dominates a 3,000-page build. Combine it with the content-hash gate for the biggest CI win. - Can I run on-demand pages from Firebase Hosting?: Yes, via Cloud Functions or Cloud Run, but the cold-start latency often defeats the purpose for low-traffic index pages. On-demand rendering pairs best with Vercel, Netlify, or Cloudflare.
- Do I need to invalidate the sitemap on every edit?: Only if accurate
lastmodmatters to your SEO. Making the sitemap an on-demand route keeps it current without any rebuild.