Skip to content
FLAVIO COPES
flaviocopes.com
2026

How to build an Astro integration

By

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

~~~

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

Integrations can:

In this tutorial we’ll build astro-build-info.

It will add:

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

Create the integration package

Create a directory:

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

Use this package.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:

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:

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:

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:

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:

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

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

The consuming project can now use:

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

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:

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

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

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

Add the integration to astro.config.mjs:

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

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

Run:

npm run dev

Check the injected route:

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:

npx astro add astro-build-info

For the smooth path:

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:

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

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.

Tagged: Astro · All topics
~~~

Related posts about astro: