How to build an Astro integration
By Flavio Copes
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:
- 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.jsonroute - 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:
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:
- a globally unique
name - a
hooksobject
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:
- publish a normal npm package
- use the
astro-integrationkeyword - 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:
'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.
Related posts about astro: