# How to build an Astro integration

> Build an Astro integration from scratch using hooks, configuration updates, injected routes and scripts, package metadata, and local testing.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-07-27 | Topics: [Astro](https://flaviocopes.com/tags/astro/) | Canonical: https://flaviocopes.com/build-astro-integration/

An Astro integration is a package that changes or extends Astro through documented lifecycle hooks.

Integrations can:

- update Astro configuration
- inject routes
- add scripts to pages
- configure Vite plugins
- run code before or after a build

In this tutorial we'll build `astro-build-info`.

It will add:

- a virtual module containing build information
- a `/build-info.json` route
- a tiny console message in development

This is small enough to understand, but it uses the same integration API as larger packages.

## Create the integration package

Create a directory:

```bash
mkdir astro-build-info
cd astro-build-info
npm init -y
npm install --save-dev astro typescript
```

Use this `package.json`:

```json
{
  "name": "astro-build-info",
  "version": "0.1.0",
  "type": "module",
  "exports": {
    ".": "./index.js"
  },
  "files": [
    "index.js",
    "virtual.js",
    "build-info-route.js"
  ],
  "keywords": [
    "astro-integration"
  ],
  "peerDependencies": {
    "astro": "^7.0.0"
  }
}
```

The `astro-integration` keyword helps Astro and package directories identify the package.

Astro is a peer dependency because the consuming project provides it.

## Return an integration object

Create `index.js`:

```js
export default function buildInfoIntegration(options = {}) {
  const route = options.route || '/build-info.json'

  return {
    name: 'astro-build-info',
    hooks: {
      'astro:config:setup': ({ injectRoute, injectScript }) => {
        injectRoute({
          pattern: route,
          entrypoint: new URL(
            './build-info-route.js',
            import.meta.url
          ),
          prerender: true,
        })

        injectScript(
          'page',
          `console.debug('[astro-build-info] page loaded')`
        )
      },
    },
  }
}
```

The default export is a factory function. It receives user options and returns an object with:

- a globally unique `name`
- a `hooks` object

Hook names describe when Astro calls them.

`astro:config:setup` runs while Astro is resolving configuration. It is the main place to add routes, scripts, renderers, and Vite plugins.

## Add the route

Create `build-info-route.js`:

```js
const builtAt = new Date().toISOString()

export function GET() {
  return new Response(
    JSON.stringify({
      builtAt,
      commit: process.env.COMMIT_SHA || null,
    }),
    {
      headers: {
        'content-type': 'application/json',
      },
    }
  )
}
```

The route is prerendered, so Astro writes a static JSON file during the build.

The timestamp is created when the route module is evaluated during that build. `COMMIT_SHA` can come from the deployment platform.

Because we pass a `URL` relative to `import.meta.url`, the entrypoint resolves inside the installed integration package rather than inside the user's project.

## Add a virtual module

A virtual module gives project code data without writing a real source file.

Add a small Vite plugin inside `index.js`:

```js
function buildInfoPlugin() {
  const publicId = 'virtual:astro-build-info'
  const resolvedId = `\0${publicId}`

  return {
    name: 'astro-build-info:virtual',

    resolveId(id) {
      if (id === publicId) {
        return resolvedId
      }
    },

    load(id) {
      if (id === resolvedId) {
        return `
          export const commit = ${JSON.stringify(
            process.env.COMMIT_SHA || null
          )}
        `
      }
    },
  }
}
```

Accept `updateConfig` in the setup hook and add the plugin:

```js
'astro:config:setup': ({
  injectRoute,
  injectScript,
  updateConfig,
}) => {
  updateConfig({
    vite: {
      plugins: [buildInfoPlugin()],
    },
  })

  // injectRoute() and injectScript() stay here
}
```

The consuming project can now use:

```astro
---
import { commit } from 'virtual:astro-build-info'
---

<p>Build: {commit || 'local'}</p>
```

TypeScript does not know this module yet. Export a declaration file from the package:

```ts
declare module 'virtual:astro-build-info' {
  export const commit: string | null
}
```

Add it to `files` and expose it through the package's documented setup, or have the integration use Astro's code-generation facilities for more advanced typed virtual modules.

## Only inject development code in development

Our console message currently ships on every page.

The setup hook receives the command:

```js
'astro:config:setup': ({
  command,
  injectRoute,
  injectScript,
  updateConfig,
}) => {
  if (command === 'dev') {
    injectScript(
      'page',
      `console.debug('[astro-build-info] page loaded')`
    )
  }

  // ...
}
```

Possible script stages include `head-inline`, `before-hydration`, `page`, and `page-ssr`. Choose the latest stage that satisfies the feature.

Do not inject browser JavaScript when build-time work is enough.

## Log through Astro

Hooks receive an integration logger:

```js
'astro:config:setup': ({ logger }) => {
  logger.info('Build info enabled')
}
```

Use it instead of decorating `console.log()` yourself. The output remains consistent with Astro's logger and verbosity settings.

Avoid logging on every page or module transformation. An integration should not drown out the application build.

## Test it in a real Astro project

Create a sibling Astro project:

```bash
npm create astro@latest integration-demo
cd integration-demo
npm install
npm install ../astro-build-info
```

Add the integration to `astro.config.mjs`:

```js
import { defineConfig } from 'astro/config'
import buildInfo from 'astro-build-info'

export default defineConfig({
  integrations: [
    buildInfo({
      route: '/meta/build.json',
    }),
  ],
})
```

Run:

```bash
npm run dev
```

Check the injected route:

```bash
curl http://localhost:4321/meta/build.json
```

Then run a production build and inspect `dist/meta/build.json`.

Test both `astro dev` and `astro build`. They exercise different paths, and an integration can work in development while failing during static generation.

## Make `astro add` work

Astro can install integrations with:

```bash
npx astro add astro-build-info
```

For the smooth path:

- publish a normal npm package
- use the `astro-integration` keyword
- provide a default integration factory export
- document any required environment variables
- keep setup safe to run more than once

`astro add` installs the package and updates the user's Astro configuration. It cannot guess unusual manual setup, so keep the default useful without many steps.

## Use later lifecycle hooks

Integrations can observe the completed build:

```js
'astro:build:done': ({ dir, pages, logger }) => {
  logger.info(
    `Built ${pages.length} pages into ${dir.pathname}`
  )
}
```

This is useful for generating a search index, uploading build artifacts, or reporting output.

Do not mutate Astro internals or depend on undocumented build directories when a hook provides the information.

## Integration design rules

A good integration should:

- have a unique, stable name
- validate options early with clear errors
- use documented hooks
- make the default setup small and predictable
- avoid changing unrelated user configuration
- work when multiple integrations update Vite
- cleanly support development and production builds
- declare supported Astro versions

Use `updateConfig()` instead of mutating the config object. Astro merges integration updates in order.

If order matters, document it. Better yet, design the integration so normal ordering does not change its behavior.

We now have an integration that injects a route, adds browser code only in development, and exposes build data through a virtual module.

The central idea is that an Astro integration is a function returning named lifecycle hooks. Once that shape is clear, the rest is deciding which small, documented part of Astro's lifecycle your package needs to extend.
