
How to build a custom Astro integration?
To build a custom Astro integration, write a function that returns an object with a name and a hooks object, then add a call to that function in the integrations array of your Astro config. Each hook is a function that Astro calls at a specific point in its lifecycle, such as astro:config:setup before the config resolves or astro:build:done after a production build. Inside a hook you get helpers like updateConfig(), injectRoute(), injectScript() and a logger, which let you add Vite plugins, pages, scripts and build steps without the people using your integration writing any of that code themselves.
Integrations are the same mechanism the official packages like @astrojs/sitemap and @astrojs/react use, so anything they can do, yours can too. In this article you'll build a working integration from scratch that exposes a virtual module, injects an endpoint and a script, generates TypeScript types and writes a report after each build. Along the way you'll learn the main hooks and when they run, how to accept options, how to add dev-server behaviour, and how to package and publish the result so others can install it with astro add.
The Shape of an Integration
The smallest possible integration is an object with a name and an empty set of hooks:
// integrations/hello.ts
import type { AstroIntegration } from 'astro';
export default function hello(): AstroIntegration {
return {
name: 'hello',
hooks: {
'astro:build:done': ({ logger }) => {
logger.info('Build finished!');
},
},
};
}
There are three conventions worth following from the start:
- Export a factory function: returning the object from a function lets users pass options, and it's what
astro addexpects. - Type it with
AstroIntegration: the type from theastropackage gives you autocomplete for every hook and its parameters. - Use a unique
name: it labels your log messages and names the folder Astro creates for your generated files. Theastro:prefix is reserved for Astro's own hooks.
You add it to your project like any other integration:
// astro.config.ts
import { defineConfig } from 'astro/config';
import hello from './integrations/hello';
export default defineConfig({
integrations: [hello()],
});
Using astro.config.ts means you can import a TypeScript integration directly, because Astro loads its config through Vite.
The Integration Lifecycle
Astro calls hooks in a predictable order. These are the ones you'll use most:
| Hook | Runs | Typical use |
|---|---|---|
astro:config:setup | First, before the Astro and Vite configs resolve | Add Vite plugins, inject routes and scripts, add renderers or middleware |
astro:route:setup | For each route, before bundling | Change a route's prerender setting |
astro:routes:resolved | After routes are known, and again in dev when route files change | Read the list of routes and their metadata |
astro:config:done | After every integration has run its setup hook | Read the final config, inject types, set an adapter |
astro:server:setup | In astro dev, after the Vite server is created | Add dev-only middleware, watch external content |
astro:server:start | In astro dev, once the server is listening | Read the port and address |
astro:build:start | Before a production build | Set up clients or global objects for the build |
astro:build:setup | Before Vite bundles | Adjust the Vite build config |
astro:build:done | After the build completes | Read generated pages and write extra files |
Every hook receives a logger, and its messages are prefixed with your integration's name, which makes output easy to trace.
A Real Example: A Build Info Integration
Let's build something useful: an integration called build-info that:
- Provides a virtual module,
virtual:build-info, exporting the build time. - Injects a JSON endpoint at
/build-info.json. - Optionally adds a console banner to every page.
- Generates TypeScript types for the virtual module.
- Writes a
build-report.jsonfile listing every route and page after each build.
Here's the full integration:
// integrations/build-info/index.ts
import type { AstroIntegration } from 'astro';
import { writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
export interface BuildInfoOptions {
/** URL where the JSON endpoint is served. */
route?: string;
/** Message logged in the browser console on every page. */
banner?: string | false;
}
const VIRTUAL_ID = 'virtual:build-info';
const RESOLVED_ID = '\0' + VIRTUAL_ID;
export default function buildInfo(options: BuildInfoOptions = {}): AstroIntegration {
const { route = '/build-info.json', banner = false } = options;
const builtAt = new Date().toISOString();
let projectRoutes: string[] = [];
return {
name: 'build-info',
hooks: {
'astro:config:setup': ({ command, updateConfig, injectRoute, injectScript, logger }) => {
logger.info(`Setting up for "${command}"`);
// 1. Provide a virtual module through a Vite plugin
updateConfig({
vite: {
plugins: [
{
name: 'build-info:virtual',
resolveId(id) {
if (id === VIRTUAL_ID) return RESOLVED_ID;
},
load(id) {
if (id === RESOLVED_ID) {
return `export const builtAt = ${JSON.stringify(builtAt)};\n` +
`export const mode = ${JSON.stringify(command)};`;
}
},
},
],
},
});
// 2. Add a route that ships with the integration
injectRoute({
pattern: route,
entrypoint: new URL('./route.ts', import.meta.url),
});
// 3. Optionally add a tiny inline script to every page
if (banner) {
injectScript('head-inline', `console.log(${JSON.stringify(banner)});`);
}
},
'astro:routes:resolved': ({ routes }) => {
projectRoutes = routes
.filter((r) => r.origin === 'project')
.map((r) => r.pattern);
},
'astro:config:done': ({ injectTypes }) => {
injectTypes({
filename: 'types.d.ts',
content: `declare module '${VIRTUAL_ID}' {
export const builtAt: string;
export const mode: 'dev' | 'build' | 'preview' | 'sync';
}`,
});
},
'astro:build:done': async ({ dir, pages, logger }) => {
const report = {
builtAt,
routes: projectRoutes,
pages: pages.map((p) => '/' + p.pathname),
};
const outFile = fileURLToPath(new URL('./build-report.json', dir));
await writeFile(outFile, JSON.stringify(report, null, 2));
logger.info(`Wrote build-report.json with ${pages.length} pages`);
},
},
};
}
And the endpoint it injects:
// integrations/build-info/route.ts
import type { APIRoute } from 'astro';
import { builtAt, mode } from 'virtual:build-info';
export const GET: APIRoute = () => {
return Response.json({ builtAt, mode });
};
Let's walk through each piece.
Adding a Vite Plugin with updateConfig()
updateConfig() merges whatever you pass into the user's config, so you only supply the keys you need. Here it adds a small inline Vite plugin. The plugin's resolveId() claims the virtual:build-info import and returns an ID prefixed with \0, which is the Vite convention for virtual modules. load() then returns the module's source code as a string.
Any page or component can now import from it:
---
// src/pages/index.astro
import { builtAt } from 'virtual:build-info';
---
<html lang="en">
<head><meta charset="utf-8" /><title>Home</title></head>
<body>
<h1>Home</h1>
<p>Last built: {builtAt}</p>
</body>
</html>
Virtual modules are a common way for an integration to hand configuration or computed data to user code.
Injecting a Route
injectRoute() takes a pattern, which uses the same syntax as file-based routing (so /docs/[slug] works), and an entrypoint pointing at an .astro page or a .ts/.js endpoint. Passing a URL built from import.meta.url resolves the file relative to the integration itself, which works both in a local folder and inside a published package. If you'd rather use a string, it must be a package specifier your package.json exports, such as my-package/route.ts.
Injecting a Script
injectScript() takes a stage and a string of code:
- head-inline: added as a
<script>in the<head>of every page, exactly as written, with no bundling. - page: bundled by Vite with the page's other scripts and loaded as a module.
- before-hydration: imported on the client before island hydration starts.
- page-ssr: imported in the frontmatter of every page at render time, mainly used to import global CSS, for example
injectScript('page-ssr', 'import "my-package/styles.css";').
Generating Types with injectTypes()
injectTypes() writes a .d.ts file to .astro/integrations/<name>/ and includes it in the project's types automatically. Without it, import { builtAt } from 'virtual:build-info' would be a type error in the user's editor. After running a build, the generated file looks like this:
// .astro/integrations/build-info/types.d.ts
declare module 'virtual:build-info' {
export const builtAt: string;
export const mode: 'dev' | 'build' | 'preview' | 'sync';
}
With this in place, astro check reported no errors across the project.
Reading Routes and Writing Files
astro:routes:resolved receives every route with metadata. The origin property is 'project' for routes from src/pages, 'external' for routes added by integrations and 'internal' for Astro's own, so the filter keeps only the user's pages.
astro:build:done receives dir, the URL of the client output directory, and pages, a list of generated pathnames. Converting the URL with fileURLToPath() gives a path that works on every operating system.
Using the Integration
Add it to the config with an option:
// astro.config.ts
import { defineConfig } from 'astro/config';
import buildInfo from './integrations/build-info';
export default defineConfig({
integrations: [
buildInfo({ banner: 'Built with Astro' }),
],
});
With three pages in src/pages, running npx astro build produces:
11:07:36 [build-info] Setting up for "build"
...
generating static routes
11:07:36 ├─ /about/index.html (+16ms)
11:07:36 ├─ /blog/first-post/index.html (+5ms)
11:07:36 ├─ /build-info.json (+3ms)
11:07:36 ├─ /index.html (+7ms)
11:07:36 ✓ Completed in 48ms.
11:07:36 [build] ✓ Completed in 240ms.
11:07:36 [build-info] Wrote build-report.json with 3 pages
11:07:36 [build] 3 page(s) built in 549ms
11:07:36 [build] Complete!
The injected endpoint was prerendered to dist/build-info.json like any other static endpoint, and the report contains the user's routes:
{
"builtAt": "2026-10-24T05:07:36.037Z",
"routes": ["/about", "/blog/first-post", "/"],
"pages": ["/about/", "/blog/first-post/", "/"]
}
The built index.html has the banner script in its head:
<head><meta charset="utf-8"><title>Home</title><script>console.log("Built with Astro");</script></head>
In astro dev, the same endpoint responds live, and mode reflects the command:
curl -s http://localhost:4321/build-info.json
{"builtAt":"2026-10-24T05:07:57.423Z","mode":"dev"}
Adding Dev Server Behaviour
The astro:server:setup hook gives you the Vite dev server, so you can add Connect-style middleware that exists only during development. This is useful for health checks, webhooks from a CMS or debugging endpoints:
// integrations/dev-health.ts
import type { AstroIntegration } from 'astro';
export default function devHealth(): AstroIntegration {
return {
name: 'dev-health',
hooks: {
'astro:server:setup': ({ server, logger }) => {
server.middlewares.use('/__health', (_req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ ok: true }));
});
logger.info('Health check available at /__health');
},
'astro:server:start': ({ address, logger }) => {
logger.info(`Dev server listening on port ${address.port}`);
},
},
};
}
Requesting /__health on the dev server returned {"ok":true}, and both messages appeared in the log with the dev-health label. The same hook also provides refreshContent(), which lets a CMS integration tell the content layer to reload when a webhook arrives.
For request-time logic that should also run in production, use addMiddleware() in astro:config:setup instead. It registers a normal Astro middleware file with an order of 'pre' or 'post', as described in the guide to Astro middleware.
Other Useful Helpers
The astro:config:setup hook offers several more helpers:
- addWatchFile(path): restart the dev server when a config file your integration reads changes. Pass an absolute path or a
URL, such asnew URL('./my.config.json', config.root). - addRenderer(): register a UI framework renderer, which is how
@astrojs/reactand friends work. - addClientDirective(): add a custom
client:*directive, such as hydrating on the first click. - addDevToolbarApp(): add an app to the dev toolbar.
- createCodegenDir(): get a dedicated folder at
.astro/integrations/<name>/for generated files. - config and command: read the user's config and whether Astro is running
dev,build,previeworsync. - isRestart:
truewhen the dev server is reloading, so you can avoid repeating one-time work.
Vite Environments in Astro 6 and Later
Since Astro 6, builds use Vite's Environment API. If you configure the build in astro:build:setup, the hook is called once and you target a specific environment through vite.environments, for example vite.environments.client.build.minify = false. The old target parameter no longer exists. Similarly, code that sends HMR events should use server.environments.client.hot.send(). If you're updating an older integration, the upgrade guide summarises these changes.
Packaging and Publishing
Once the integration works locally, moving it to a package takes a few steps:
- Create a package with
"type": "module"and anexportsmap that includes any files you reference by specifier. - Declare Astro as a peer dependency so users supply their own version.
- Add the
astro-integrationkeyword so thatastro addcan install it and the Astro integrations directory can list it.
{
"name": "astro-build-info",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./index.ts",
"./route.ts": "./route.ts"
},
"files": ["index.ts", "route.ts"],
"keywords": ["astro-integration", "withastro"],
"peerDependencies": {
"astro": "^7.0.0"
}
}
Astro can load .ts and .astro files from packages directly, so a build step is optional. Many authors still compile to JavaScript and ship type declarations, which works with more tooling. Whatever you choose, astro add expects the package's default export to be the factory function.
For testing, use a demo Astro project in the same repository (npm workspaces work well), build it with your integration, and compare the files in dist/ with what you expect.
FAQ: Building Astro Integrations
Yes. Any object with a name and hooks works in the integrations array, so you can write a small integration inline. Move it to its own file once it grows beyond a few lines.
astro:config:setup runs first and is where you change things, such as adding Vite plugins, routes and scripts. astro:config:done runs after every integration has finished setting up, so it gives you the final resolved config and is where you inject types or set an adapter.
Create a virtual module with a Vite plugin added through updateConfig(), then import it in your pages and components. Use injectTypes() to declare the module so TypeScript understands it.
Yes. A factory function can return an array of integrations instead of a single object. Astro calls this a preset, and users add it exactly like any other integration.
Integrations run in the order they appear in the integrations array. Try to write yours so that order doesn't matter, and document it clearly if it must run first or last.
Publish it to npm with the astro-integration keyword in package.json and make sure its default export is the factory function. Users can then run npx astro add followed by your package name.
Conclusion
A custom Astro integration is a factory function returning a name and a set of hooks. astro:config:setup is where most of the work happens: updateConfig() adds Vite plugins and virtual modules, injectRoute() adds pages and endpoints, and injectScript() puts code on every page. Later hooks let you inject types, add dev-only middleware and process the finished build, all with a logger that labels output with your integration's name.
Start with a local file in your project, type it with AstroIntegration, and test it with real builds and the dev server. When it's useful beyond one site, package it with Astro as a peer dependency and the astro-integration keyword, and anyone can add it with a single command, as described in the guide to Astro integrations.


