# How Astro is built

> 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.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-08 | Topics: [Astro](https://flaviocopes.com/tags/astro/) | Canonical: https://flaviocopes.com/how-astro-is-built/

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](https://github.com/withastro/astro) and followed a simple page with one interactive React component.

If Astro is new to you, start with my [free Astro course](https://flaviocopes.com/courses/astro/). 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](https://github.com/withastro/compiler-rs/tree/v0.3.2) 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:

```mermaid
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](https://github.com/withastro/astro/tree/astro%407.2.0/packages) is a pnpm monorepo.

The important package groups are:

```text
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`](https://github.com/withastro/astro/tree/astro%407.2.0/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`](https://github.com/withastro/astro/tree/astro%407.2.0/packages/markdown/satteri) package adapts Satteri to Astro's Markdown interface.

The `.astro` compiler lives in the separate [`withastro/compiler-rs`](https://github.com/withastro/compiler-rs/tree/v0.3.2) 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](https://flaviocopes.com/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](https://flaviocopes.com/astro-components/) explains their basic structure.

```astro
---
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:

```jsx
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](https://flaviocopes.com/use-react-component-in-astro/).

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`](https://github.com/withastro/astro/blob/astro%407.2.0/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](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/cli/index.ts) 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`](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/core/build/index.ts).

It first sets the process environment to production. The `--devOutput` option changes that runtime mode for debugging.

The [`resolveConfig()` function](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/core/config/config.ts) resolves the project root. It then searches for these files in order:

```text
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](https://flaviocopes.com/build-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()`](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/core/routing/create-manifest.ts).

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](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/core/sync/index.ts) 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`](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/content/content-layer.ts) 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`](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/core/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:

1. Astro's common Vite configuration.
2. The user's `vite` configuration.
3. Vite changes from Astro integrations.
4. 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](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/vite-plugin-astro/index.ts) 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()`](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/vite-plugin-astro/compile.ts). That function delegates to the core compiler wrapper.

The [core wrapper](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/core/compile/compile.ts) 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](https://github.com/withastro/compiler-rs/blob/v0.3.2/crates/astro_napi/src/lib.rs) 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](https://github.com/withastro/compiler-rs/blob/v0.3.2/crates/astro_codegen/src/lib.rs) 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`](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/core/build/static-build.ts).

The production build has three relevant environments:

1. The prerender environment.
2. The server-side rendering environment, when needed.
3. 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`](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/core/build/default-prerenderer.ts).

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()`](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/runtime/server/render/page.ts).

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`](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/runtime/server/render/astro/render-template.ts) 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()`](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/runtime/server/render/component.ts).

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](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/core/build/generate.ts) 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:

```text
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](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/types/public/integrations.ts) 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](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/runtime/server/astro-island.ts) runs when the browser connects the element.

It waits for streamed children when necessary. It then starts the selected client directive.

The [`visible` directive](https://github.com/withastro/astro/blob/astro%407.2.0/packages/astro/src/runtime/client/visible.ts) 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](https://docs.astro.build/en/guides/integrations-guide/node/).

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](https://flaviocopes.com/dockerfile-to-run-astro-node-ssr-on-flyio/).

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`:

```js
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`:

```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`:

```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`:

```js
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:

```bash
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.mjs` renders server HTML
- `build.mjs` saves the rendered page
- `mini-island.js` controls client loading
- `counter.js` owns 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.
