How Astro is built
By Flavio Copes
I wanted to know what Astro does when we run astro build. Let's follow one page from its .astro file to the final HTML in dist.
I use Astro every day, but I usually don’t think about what it does under the hood.
I write a page, run astro build, and get a dist directory. Done.
But I got curious. What happens between the .astro file and the final HTML?
So I opened the Astro source code and followed a simple page with one interactive React component.
If Astro is new to you, start with my free Astro course. Here I’ll skip the basics and focus on what happens during the build.
Astro does more than compile .astro files
I thought Astro would compile the page and pass the result to Vite.
It does that, but it also finds routes, loads content, renders pages, writes static files, and prepares server builds.
The Rust compiler works on one .astro file at a time. Vite then connects that file to its components, styles, scripts, and assets.
Astro also checks which components need JavaScript in the browser. A framework component becomes plain HTML unless you add a client:* directive.
A quick overview
Here is the short version:
flowchart TD
A["astro build"] --> B["Config, integrations, routes, and content"]
B --> C["Vite prerender and server environments"]
C --> D["Astro Vite transform"]
D --> E["Rust .astro compiler"]
E --> F["Server JavaScript and metadata"]
F --> C
C --> G["Discovered client entry points"]
G --> H["Vite client environment"]
C --> I["Execute bundled page with a Request"]
I --> J["Astro server renderer returns a Response"]
J --> K["Write static files"]
J --> L["Package a server through an adapter"]
K --> M["Hydrate selected client islands"]
L --> M
The Rust compiler does not produce a complete website.
It produces a server module and metadata for one .astro file. Vite connects that module to the rest of the project.
Astro then runs the server bundle to produce the HTML.
This surprised me: a static build uses the same renderer as a server-rendered page. It just runs during the build and saves the response.
Where the code lives
The main Astro repository is a pnpm monorepo.
The important package groups are:
packages/
astro/ CLI, config, routing, build, and runtime
create-astro/ project creation command
integrations/ React, Vue, Svelte, adapters, and other integrations
markdown/ unified and Satteri Markdown processors
language-tools/ editor, language server, and astro check
internal-helpers/ shared private utilities
telemetry/ anonymous telemetry client
The packages/astro package contains the CLI, build code, router, and production runtime.
Framework support lives outside that core package. React, Preact, Vue, Svelte, and Solid each provide renderer integrations.
Deployment support also lives in integration packages. Node, Cloudflare, Netlify, Vercel, and Deno can package the same core application differently.
Astro 7 uses the Rust-based Satteri processor for Markdown by default.
The @astrojs/markdown-satteri package adapts Satteri to Astro’s Markdown interface.
The .astro compiler lives in the separate withastro/compiler-rs repository.
This repository contains the Rust parser integration, code generator, Node-API binding, and JavaScript wrapper.
Vite is another external system. Astro 7 uses Vite 8.
Vite 8 uses Rolldown for production builds. Astro adds its own plugins and tells Vite what it needs to build.
If you want a quick introduction first, read my Vite tutorial.
The page we’ll use
Here is a small Astro page with one React island:
If .astro files are new to you, my guide to Astro components explains their basic structure.
---
import Counter from '../components/Counter.jsx'
const title = 'How Astro works'
---
<html lang="en">
<head>
<title>{title}</title>
</head>
<body>
<h1>{title}</h1>
<Counter start={1} client:visible />
</body>
</html>
<style>
h1 {
color: rebeccapurple;
}
</style>
The React component is also small:
import { useState } from 'react'
export default function Counter({ start }) {
const [count, setCount] = useState(start)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}
The React integration must already exist in the project.
I have a short guide that shows how to add a React component to an Astro project.
Without client:visible, Astro renders the React component to HTML and sends no component JavaScript.
With client:visible, Astro still renders its initial HTML. It also creates a browser entry point for later hydration.
Let us follow this page from the command line to the browser.
astro build starts in the CLI
The astro executable starts in packages/astro/bin/astro.mjs.
This small file first checks that the installed Node.js version is supported.
It also normalizes lowercase Windows drive letters. Vite cannot process those paths correctly.
The executable then imports the compiled CLI module and passes process.argv to it.
The CLI dispatcher parses arguments with yargs-parser. It selects dev, build, preview, sync, or another command.
For astro build, it imports the build command only when needed. This keeps unrelated command code out of startup.
The command converts CLI flags into inline Astro configuration. It then calls the core build function.
The CLI stays small. It reads the options and prints messages, while the build code lives elsewhere.
Astro loads the configuration
The core builder starts in core/build/index.ts.
It first sets the process environment to production. The --devOutput option changes that runtime mode for debugging.
The resolveConfig() function resolves the project root. It then searches for these files in order:
astro.config.mjs
astro.config.js
astro.config.ts
astro.config.mts
Astro first tries a native import for JavaScript config. If that fails, it uses a temporary Vite server.
TypeScript config goes through that Vite path. This path also supplies normal Vite module resolution.
Astro merges file configuration with CLI configuration. Inline values have the highest priority.
It then validates the merged object with Zod. A schema failure stops the build before route scanning or compilation.
Next, Astro creates internal settings and runs integration hooks. Integrations can update settings during astro:config:setup.
You can use these hooks in your own packages. I show the complete process in how to build an Astro integration.
The builder also decides its main output mode.
The default output: 'static' makes prerendering the default. output: 'server' makes on-demand rendering the default.
Individual routes can reverse that default with export const prerender.
If any server output exists without an adapter, Astro throws NoAdapterInstalled during setup.
Astro finds the pages
Astro now scans src/pages through createRoutesList().
The scanner walks directories and recognizes .astro, Markdown, MDX, and configured page extensions. JavaScript and TypeScript files become endpoints.
Files beginning with _ do not become routes. Hidden files also stay outside the route manifest, except .well-known.
The scanner converts each file name into route segments, parameters, a regular expression, and output information.
For our src/pages/index.astro, it creates the / page route.
For src/pages/products/[id].astro, it records an id parameter and a dynamic pattern.
Astro also adds injected routes and configured redirects. It sorts all routes by matching priority.
The scanner reads each route source to find its prerender option. It then checks route collisions before bundling starts.
Dynamic static routes need getStaticPaths(). Astro evaluates that function later from the built server module.
Content collections load before rendering
Our example does not use content collections, but many Astro sites do. Astro loads them before it starts rendering pages.
During build setup, Astro runs its internal sync process. The syncInternal() function creates a temporary Vite server.
That server loads src/content.config.ts and any imports used by its loaders. Astro also generates collection types in the .astro directory.
The ContentLayer runs collection loaders in parallel. Each loader receives a store scoped to its collection.
The loader can replace the collection or update selected entries. It can save metadata for later synchronization.
Astro validates entries through the collection schema. It then writes the resulting data store to its cache.
Pages query that fixed snapshot through getCollection() or getEntry().
This snapshot can serve both prerendered and on-demand pages. Build-time collections do not update inside a deployed server.
Astro also supports live collections for request-time data. Those use a different runtime path.
Astro configures Vite
Astro creates its Vite configuration in create-vite.ts.
This file shows how much coordination Astro performs.
The built-in plugin list covers routes, renderers, .astro files, scripts, styles, Markdown, content, middleware, assets, actions, sessions, and islands.
Astro starts with its required Vite configuration. It then merges four layers in order:
- Astro’s common Vite configuration.
- The user’s
viteconfiguration. - Vite changes from Astro integrations.
- Configuration for the current command.
Later layers can override earlier layers.
Astro sets Vite’s application type to custom. Astro owns request handling instead of using Vite’s HTML application fallback.
Vite now has enough information to create module graphs for the project.
The Rust compiler turns .astro into JavaScript
Vite asks plugins to transform every module that enters a graph.
The astro:build Vite plugin handles files ending in .astro.
It runs before normal transforms. In the client environment, it replaces .astro server code with a small safe stub.
The plugin calls compileAstro(). That function delegates to the core compiler wrapper.
The core wrapper first preprocesses style blocks. Sass or another configured preprocessor can run during this stage.
The wrapper then calls transform() from @astrojs/compiler-rs.
The JavaScript compiler package loads a native binary for supported platforms. It can use a WebAssembly System Interface fallback when the native binary is unavailable.
Inside Rust, the Node-API binding creates an Oxc allocator. It parses the file with Oxc’s Astro source type.
The parser produces an Astro abstract syntax tree. An abstract syntax tree, or AST, represents source code as structured nodes.
Parse errors return diagnostics with source labels. The compiler does not continue with a broken tree.
The Rust code generator then performs two main passes.
The scanner finds components, directives, scripts, styles, transitions, and head information. The printer turns the tree into server JavaScript.
Our page becomes a module with a component factory. Its factory returns calls to Astro’s server rendering helpers.
The generated module imports helpers such as createComponent, render, and renderComponent from astro/compiler-runtime.
Frontmatter imports stay module imports. Other frontmatter statements run inside the component factory for each render.
HTML becomes tagged render-template data. JavaScript expressions remain expressions inside that template.
The style becomes extracted CSS with a stable scope hash. The generated HTML receives the matching scope marker.
The Counter call receives compiler-only properties for its hydration directive, module path, and export name.
The compiler also returns structured metadata:
- extracted CSS blocks
- hoisted scripts
- hydrated components
- client-only components
- server-deferred components
- source maps
- head and propagation flags
- diagnostics
Astro caches this metadata by file name. Virtual CSS and script module requests read from that cache.
The compiler does not resolve the complete client bundle. It reports what it found, and Vite handles the graph.
Vite builds the server and browser code
Astro 7 uses Vite’s environment API through static-build.ts.
The production build has three relevant environments:
- The prerender environment.
- The server-side rendering environment, when needed.
- The client environment.
Astro builds them in that order because they are not independent.
The prerender and server builds visit page modules first. Their .astro transforms reveal hydrated components and browser scripts.
Astro records those discoveries in its build internals. Only then can it create the correct client entry list.
For our page, client inputs include the React Counter and React’s hydration runtime. The manifest also carries prebuilt visible directive code.
The client build uses that list as Rolldown input. A page without browser scripts gets no application bundle.
Astro still performs a no-operation client build when the list is empty. Vite must copy the public directory during that environment.
Astro sorts client inputs before building them. Asynchronous discovery order cannot change output chunk names between identical builds.
Vite now performs its normal jobs. It resolves imports, applies transforms, splits chunks, processes CSS, hashes assets, and reports outputs.
Astro plugins collect page dependencies and asset metadata from those graphs. Adapters can also add Vite plugins and build hooks.
Astro renders the page
Bundling does not create the final HTML.
Astro imports the prerender entry bundle into Node.js. The default prerenderer receives a BuildApp.
For each static path, Astro creates a standard Request. It then calls the application’s render() method with the matching route.
Astro uses the same rendering code for static and server-rendered pages.
That code also handles trailing slashes, redirects, cache state, sessions, middleware, actions, routes, and internationalization.
The page handler loads the built component module. It creates the Astro request context and resolves route props.
It then calls renderPage().
The page factory runs its frontmatter. In our example, it creates the title value.
The generated render template writes static strings directly to a destination. Dynamic expressions pass through specialized rendering functions.
Astro 7’s RenderTemplateResult has a fast path for synchronous content.
It writes synchronous HTML without allocating buffers. When it finds an asynchronous expression, it starts buffered work for later expressions.
Those later expressions can resolve in parallel. Astro still flushes their buffers in source order.
This queue-based approach avoids deep recursive waiting while preserving correct HTML order.
The React Counter goes through renderComponent().
Astro asks installed renderer integrations which renderer accepts the component. The React integration renders it to static HTML.
Astro serializes the props and wraps the result in an <astro-island> custom element. It also records render instructions for hydration scripts.
Finally, renderPage() returns a standard Response. Static generation consumes that response as bytes.
Astro writes the HTML to dist
The generatePages() function obtains every static path from the prerenderer.
For a simple route, the manifest already provides the path. A dynamic route adds paths from getStaticPaths().
Astro removes duplicate paths and respects route priority. It can render paths concurrently through the configured build concurrency.
For each path, renderPath() creates the request URL and asks the prerenderer for a response.
Redirect responses become small redirect documents. Normal response bodies become byte buffers.
Astro computes the output folder and file from build.format, the route, and trailing-slash settings.
The default directory format writes our / route to:
dist/index.html
Astro creates the destination directory and writes the response body. It later generates optimized images and moves built assets into place.
The final static directory contains no Astro server requirement. Any correct static file server can host it.
Adapters package server-rendered sites
Static output is only one branch.
An on-demand route keeps the built server application. Astro needs an adapter because each host expects a different entry point and output layout.
The AstroAdapter type lists what every adapter must provide.
An adapter declares its name, supported features, server entry point, preview entry point, and client configuration.
It can also change build output requirements. For example, it can request server output or preserve separate client and server directories.
The adapter receives a serialized route and asset manifest. It packages Astro’s application for its target runtime.
The core application still accepts a Request and returns a Response. The adapter translates platform events around that interface.
Astro still handles routing and rendering. The adapter only prepares the output for the hosting platform.
The browser loads the interactive component
The generated page arrives with React’s initial button HTML inside <astro-island>.
The wrapper contains serialized props, the component chunk URL, the renderer URL, and the visible directive.
Astro’s AstroIsland custom element runs when the browser connects the element.
It waits for streamed children when necessary. It then starts the selected client directive.
The visible directive observes the island’s children with IntersectionObserver.
The component code does not load until one child becomes visible.
At that time, the island dynamically imports two modules in parallel:
- the compiled React component
- the React hydration runtime
The island revives serialized props and gathers named slots. It then calls the framework hydrator with the existing server HTML.
React attaches behavior to that HTML. The button can now update its count.
Each island hydrates independently. A parent island completes before a nested child island starts.
Astro removes the ssr marker after successful hydration. It then emits an astro:hydrate event.
The server renders the whole page, but the browser activates only this component.
What can go wrong?
An Astro build can fail at different stages. The error usually tells you which stage had the problem.
Starting the build
An unsupported Node.js version stops in the executable. A missing or invalid config stops during config loading.
Integration setup also runs before compilation. An integration can reject unsupported configuration early.
Routes and content
Invalid route segments fail while Astro builds the manifest. Missing static paths fail before Astro writes that route.
A content loader or schema error fails content synchronization. Pages never receive a partial content snapshot.
Compiling and bundling
The Rust parser returns precise diagnostics for malformed .astro syntax. The wrapper turns the first compiler error into an AstroError with a file location.
Style preprocessing can return several errors. Astro groups multiple style failures into an aggregate error.
Vite owns module resolution and bundle failures. A missing import never reaches the page renderer.
Rendering a static page
Astro logs the failing pathname when prerendering throws. It attaches the route component when the error lacks an identifier.
The build then fails. Astro does not publish a knowingly incomplete static result.
Inside page rendering, Astro marks the render result as cancelled. Pending component work can stop instead of starting more output.
Handling a server request
The on-demand handler catches request-time errors and routes them through Astro’s error renderer. A custom 500.astro page can produce the response.
The failure affects that request. The server process can continue handling later requests.
Loading an island in the browser
An island import gets one retry with a cache-busting query parameter. This can recover from a stale failed module request.
If hydration still fails, the element emits astro:hydration-error. It logs the error unless application code cancels the event.
Other islands can still hydrate. The server-rendered HTML also remains visible.
What can we self-host?
You can self-host the complete open-source path described here.
For a static site, run the build and serve dist from any static server. No Astro process runs in production.
For on-demand rendering, use the official Node adapter.
Standalone mode builds a server at dist/server/entry.mjs. Running that module starts the HTTP server and serves client assets.
Middleware mode exports a handler. You can mount it inside Express, Fastify, or another compatible Node.js server.
For a working example, see the Dockerfile I use to run Astro Node SSR.
You can also use another official adapter for its supported runtime. That choice changes packaging, not your .astro source model.
The native compiler only runs during development and build. A static deployment does not need its Rust binary or WebAssembly fallback.
An on-demand deployment needs the built JavaScript runtime. It does not compile .astro files for every request.
What I like about this design
A few ideas from Astro are worth borrowing.
Reuse what the compiler already found
The compiler returns code and metadata together. Later plugins reuse its lists of styles, scripts, and hydrated components.
Astro does not parse the same source again for every virtual module. The Vite plugin stores compile metadata by file name.
Find the browser code while building the server code
Astro cannot know the client entries from file extensions alone. A component becomes browser code only through a directive at its use site.
Server compilation sees that use site. It therefore discovers the exact client work.
Use the same renderer everywhere
Static generation sends a synthetic Request through the built application. On-demand rendering sends a real request through the same application.
This reduces differences between output modes. Middleware, routing, and page rendering keep one main model.
Let adapters deal with hosting
The renderer returns a Response. The generator or adapter decides what that response becomes.
One branch writes a file. Another branch returns data through a server, function, or edge worker.
Choose interactivity per component
The client:visible directive sits beside the component that needs it. It does not enable hydration for the complete page.
This local choice becomes build metadata, a client entry, an HTML wrapper, and one browser scheduling rule.
Keep the HTML when JavaScript fails
The initial framework component usually renders on the server. A hydration failure does not need to erase its HTML.
The page can still show useful content when the JavaScript fails.
Let’s build a tiny version
We can reproduce the central shape without building a parser or bundler.
Our small version will render a page during a build. It will activate one island in the browser.
First, create src/page.mjs:
export function render() {
return `<!doctype html>
<html lang="en">
<body>
<h1>Mini Astro</h1>
<mini-island data-component="/counter.js">
<button>Count: 0</button>
</mini-island>
<script type="module" src="/mini-island.js"></script>
</body>
</html>`
}
The page exports a server render function. Astro’s compiler generates a more capable version of this function.
Now create src/mini-island.js:
class MiniIsland extends HTMLElement {
async connectedCallback() {
const module = await import(this.dataset.component)
module.hydrate(this)
}
}
customElements.define('mini-island', MiniIsland)
This custom element loads its component module after the browser connects it.
Create src/counter.js:
export function hydrate(root) {
const button = root.querySelector('button')
let count = 0
button.addEventListener('click', () => {
count += 1
button.textContent = `Count: ${count}`
})
}
Finally, create build.mjs:
import { copyFile, mkdir, writeFile } from 'node:fs/promises'
import { render } from './src/page.mjs'
await mkdir('dist', { recursive: true })
await Promise.all([
writeFile('dist/index.html', render()),
copyFile('src/mini-island.js', 'dist/mini-island.js'),
copyFile('src/counter.js', 'dist/counter.js')
])
Run the build:
node build.mjs
Serve dist through a static HTTP server. Opening the page shows server-generated HTML and a working client island.
This small system keeps Astro’s central separation:
page.mjsrenders server HTMLbuild.mjssaves the rendered pagemini-island.jscontrols client loadingcounter.jsowns component behavior
It skips the difficult parts. There is no .astro parser, Vite graph, CSS processing, adapter, streaming renderer, or error reporting system.
Those omitted parts explain why Astro needs its real architecture.
That is the basic idea behind Astro.
Render as much as possible on the server. Write HTML when you can. Load JavaScript only for the parts that need it.
Related posts about astro: