Build Pipeline
Every earlier module used astro build as a black box: run it, get a dist/ folder. This lesson opens the box — what actually happens, in what order, backed by real log output and real dist/ trees from this exact repo’s installed [email protected], not a remembered diagram.
The real order, from a real build log
Section titled “The real order, from a real build log”Running astro build against this course’s own probe project (@astrojs/node, standalone mode) produces this real, only-lightly-trimmed log:
$ npx astro build[content] Syncing content[content] Synced content[types] Generated 237ms[build] output: "static"[build] mode: "server"[build] adapter: @astrojs/node[build] Collecting build info...[build] ✓ Completed in 261ms.[build] Building server entrypoints...[vite] ✓ built in 103ms[vite] ✓ built in 58ms[vite] ✓ built in 12ms
prerendering static routes ├─ /index.html (+4ms)[build] ✓ Completed in 12ms.
[build] Rearranging server assets...[build] ✓ Completed in 201ms.[build] Server built in 464ms[build] Complete!In order, what each line is actually doing (astro/dist/core/build/index.js’s build(), which calls into astro/dist/core/build/static-build.js):
astro syncruns first, unconditionally — every loader’sload()runs (see Content Layer Internals), and.astro/’s generated types are written before a single page renders.Collecting build info...—collectPagesData()walks the routes manifest and figures out which pages areprerender: truevs. on-demand, before any Vite build starts.Building server entrypoints...— this is Vite (8.3.1 in this repo, withrolldown@~1.2.9as a direct dependency — the bundler Vite 8 uses in place of Rollup) building the SSR bundle: every page/component compiled to JavaScript, but not yet rendered to HTML.prerendering static routes— pages markedprerender: true(the default underoutput: 'static', or explicitly per-page under a server output) are actually rendered, here — this is where your page’s own top-level code (including anyconsole.login its frontmatter) really runs, once, at build time.Rearranging server assets...— the built output is moved into its finaldist/shape (dist/client/+dist/server/for an adapter build).
Real dist/ trees: static output vs. an adapter build
Section titled “Real dist/ trees: static output vs. an adapter build”The exact same source, built two different ways, produces genuinely different dist/ shapes. With output: 'static' and no adapter (this course’s own site):
dist/├── 404.html├── _astro/│ ├── Quiz.DfRUMXlD.js│ ├── ProgressTracker.Bz4GQ3Zm.js│ ├── client.E2o2D-C-.js│ ├── common.DRolbLKG.css│ └── ...├── en/│ ├── index.html│ ├── islands/│ └── ...└── th/With @astrojs/node in standalone mode (this lesson’s probe project):
dist/├── client/│ ├── _astro/│ ├── favicon.ico│ └── index.html└── server/ ├── entry.mjs ├── virtual_astro_middleware.mjs └── chunks/The static build’s output is the site — every route is a real .html file, ready to serve from any static host. An adapter build splits into dist/client/ (exactly what a static build would have produced — the browser-facing assets, images, and any prerendered pages) and dist/server/entry.mjs (a real Node module — handler/startServer exports, proven directly in Request Pipeline) that has to actually run to serve any on-demand route.
Chunk naming: _astro/<name>.<hash>.<ext>
Section titled “Chunk naming: _astro/<name>.<hash>.<ext>”Every chunk in _astro/ gets its content hash baked into the filename (Quiz.DfRUMXlD.js, common.DRolbLKG.css) — this is Vite/Rolldown’s own content-hashing, the same mechanism that lets a static host cache these files forever (Cache-Control: immutable) without ever serving stale JavaScript after a deploy: the URL itself changes the moment the content does.
build.inlineStylesheets: 'auto' really means “under 4096 bytes”
Section titled “build.inlineStylesheets: 'auto' really means “under 4096 bytes””The real config default, from the installed schema (astro/dist/core/config/schemas/defaults.js):
build: { // ... inlineStylesheets: "auto", concurrency: 1}'auto'’s actual threshold is not a made-up round number — it’s Vite’s own assetsInlineLimit, checked by the real, installed inlining plugin:
// astro/dist/core/build/plugins/util.js (trimmed)function shouldInlineAsset(assetContent, assetPath, assetsInlineLimit) { if (typeof assetsInlineLimit === "function") { const result = assetsInlineLimit(assetPath, Buffer.from(assetContent)); if (result != null) return result; } return Buffer.byteLength(assetContent) < 4096;}// astro/dist/core/build/plugins/plugin-css.js (trimmed)const toBeInlined = inlineConfig === "always" ? true : inlineConfig === "never" ? false : shouldInlineAsset(stylesheet.source, stylesheet.fileName, assetsInlineLimit);So 'auto' (the default) inlines a page’s CSS directly into its HTML (as a <style> tag, saving a render-blocking request) when that stylesheet is under 4096 bytes, and emits a real, separate, cacheable _astro/*.css file otherwise. 'always'/'never' skip the size check entirely. This is a real, provable threshold — not “small stylesheets get inlined, roughly.”
build.concurrency: sequential by default, pages and images treated differently
Section titled “build.concurrency: sequential by default, pages and images treated differently”build.concurrency (default 1, per the schema above) controls how many pages are rendered concurrently during the prerender pass — proven directly in the real generator (astro/dist/core/build/generate.js):
// astro/dist/core/build/generate.js (trimmed)if (config.build.concurrency > 1) { const limit = PLimit(config.build.concurrency); // ...paths.map(({ pathname, route, cacheKey }) => limit(() => generatePage(...)))}At the default of 1, pages render one at a time — deliberately, since two pages rendering concurrently could race on shared, mutable build state (a global CSS-collection map, for instance). Image generation is a genuinely separate concurrency knob, always concurrent regardless of build.concurrency, sized to the real CPU count:
// astro/dist/core/build/generate.js (trimmed)const cpuCount = os.availableParallelism();const queue = new PQueue({ concurrency: Math.max(cpuCount, 1) });Raising build.concurrency speeds up a build with many pages that don’t share mutable state across renders; it does nothing at all for image optimization, which was already running as concurrently as your machine allows.
--force: clears the content-layer cache specifically
Section titled “--force: clears the content-layer cache specifically”Content Layer Internals proved that an unchanged content entry is skipped, not reparsed, on a normal astro sync/astro build. --force exists specifically to disable that: it deletes the on-disk data store before syncing, so every loader treats every entry as new. The real, installed source (astro/dist/core/sync/index.js):
// astro/dist/core/sync/index.js (trimmed)async function clearContentLayerCache({ settings, logger, fs, isDev }) { // ... await fs.promises.rm(dataStore, { force: true, recursive: true });}// called when the CLI's --force flag is setThis is a targeted invalidation, not a full clean: --force doesn’t touch Vite’s own build cache or node_modules at all — it specifically forces the content layer to re-load everything, which is exactly the tool to reach for if a loader’s own logic changed (not the content it reads) and old, cached entries need to be recomputed even though their source content digest hasn’t changed.
The content layer makes an unchanged second build cheaper — not (only) faster
Section titled “The content layer makes an unchanged second build cheaper — not (only) faster”A wall-clock “second build is faster” claim is noisy in practice (caches, disk, OS scheduling all vary run to run) — the real, provable claim is narrower and stronger: Content Layer Internals’s digest proof (a real glob() loader, instrumented with one log line, run three times) showed directly that an unchanged entry’s parseData/renderMarkdown never re-run on a second sync — and astro build always calls sync first. A build with a large, mostly-unchanged content collection genuinely skips real parsing/rendering work on every build after the first; whether that nets out to a faster wall-clock build depends on how much of the total build time that parsing work was in the first place, next to Vite’s own bundling and the page-render pass above.
flowchart TD sync["astro sync (every loader's load() — Content Layer Internals)"] --> info["Collecting build info... collectPagesData(): prerender vs on-demand"] info --> vite["Building server entrypoints... Vite 8 + Rolldown: SSR bundle"] vite --> prerender["prerendering static routes pages actually render to HTML here"] prerender --> rearrange["Rearranging server assets... dist/client/ + dist/server/ (adapter) or a flat dist/ (static)"] rearrange --> done["Complete!"] force["--force flag"] -.clears data store before.-> sync concurrency["build.concurrency (default 1)"] -.-> prerender cpu["os.availableParallelism()"] -.always concurrent.-> images["image optimization queue"]
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a comment claim
build.concurrencyspeeds up image optimization? Proven above: image generation uses its own, always-concurrent queue sized to the CPU count —build.concurrencyonly affects page rendering. - Does troubleshooting advice for “stale content after editing a loader’s transform logic” suggest deleting
node_modulesordist? The targeted, real tool is--force(proven above to specifically clear the content-layer’s data store) — deletingnode_modulesthrows away Vite’s own cache too, for no benefit to the actual problem. - Does a claim about
inlineStylesheets: 'auto'describe it as “small stylesheets get inlined” without a number? Proven above: the real threshold is a specific, checkable one — under 4096 bytes (Vite’sassetsInlineLimit), not a fuzzy heuristic. - Does an explanation assume
dist/always looks like a flat folder of HTML files? Proven above: an adapter build genuinely splits intodist/client/anddist/server/entry.mjs— a real Node module that must run, not something you can serve as static files alone.
🐛 Spot the bug
# CI script — "makes builds faster by warming everything from scratch"rm -rf node_modules/.astro dist node_modulesnpm cinpx astro build --forceShow the answer
This does work, but it throws away far more than it needs to, and does the opposite of what the comment claims: deleting node_modules (and reinstalling with npm ci) discards Vite’s own dependency pre-bundling cache along with everything else, and --force on top of a fully fresh install is a no-op for freshness (there was no content-layer cache to clear — node_modules/.astro/data-store.json, proven in Content Layer Internals to live there, was just deleted along with the rest of node_modules) but still adds its own overhead: forcing every loader to reparse every entry from scratch, on a build that was already going to do that anyway. If the goal is “guarantee a clean content-layer state,” --force alone (without touching node_modules/dist at all) is the real, targeted tool — proven above to only clear the data store, leaving Vite’s caches and installed dependencies untouched for genuinely faster subsequent installs and builds.
Mental Model
Section titled “Mental Model”astro build is five real phases in a fixed order: sync content (loaders, digest-checked), collect routing info (prerender vs. on-demand, decided before any rendering), build the SSR bundle with Vite/Rolldown, actually render prerendered pages to HTML, then rearrange the output into its final shape. Every one of the “extra” build options in this lesson (concurrency, --force, inlineStylesheets) is a real, specific knob on one of those five phases — not a vague global “make the build behave differently” switch.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
output: 'static' (no adapter) |
dist/ is the whole site — serve from any static host, no runtime |
Any page needing true per-request behavior must be a separate concern (an island, an edge function elsewhere) |
An adapter (@astrojs/node, etc.) |
On-demand routes, actions, server islands all work for real | dist/server/entry.mjs must actually run somewhere — dist/ alone isn’t deployable to a plain static host |
build.concurrency > 1 |
Faster prerendering on a machine with many cores, for pages that don’t share mutable render-time state | Real risk of races in code that assumes single-threaded, one-page-at-a-time rendering (the actual reason the default is 1) |
inlineStylesheets: 'auto' (vs. 'always'/'never') |
Small stylesheets skip a request; large ones stay cacheable and don’t bloat every page’s HTML | The 4096-byte line is a real cliff — a stylesheet that grows past it changes from “in every page’s HTML” to “a separate cached request,” a meaningfully different loading characteristic for a small content change |
--force |
Targeted content-layer cache invalidation, without a full clean | Reparsing everything is real, avoidable work if only one loader (or one entry) actually needed it |
Common Misconceptions
Section titled “Common Misconceptions”- “
astro buildrenders pages, then figures out routing.” Proven false above by the real log order:Collecting build info...(routing/prerender decisions) happens beforeBuilding server entrypoints...and the actual prerender pass. - “
build.concurrencymakes the whole build (images included) more parallel.” Proven false above: image optimization already runs on its own, CPU-count-sized queue regardless of this setting;build.concurrencyonly affects page rendering, and defaults to fully sequential. - “A fresh
dist/folder is always just static files you can serve directly.” Proven false above: an adapter build’sdist/server/entry.mjsis a real Node module that must run — onlydist/client/is static, servable assets. - “
inlineStylesheets: 'auto'is a fuzzy heuristic, not a real number.” Proven false above: it’s Vite’s ownassetsInlineLimit, checked against a specific, real 4096-byte threshold in the installed source.