Type something to search...
How to build a custom Astro integration?

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 add expects.
  • Type it with AstroIntegration: the type from the astro package 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. The astro: 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:

HookRunsTypical use
astro:config:setupFirst, before the Astro and Vite configs resolveAdd Vite plugins, inject routes and scripts, add renderers or middleware
astro:route:setupFor each route, before bundlingChange a route's prerender setting
astro:routes:resolvedAfter routes are known, and again in dev when route files changeRead the list of routes and their metadata
astro:config:doneAfter every integration has run its setup hookRead the final config, inject types, set an adapter
astro:server:setupIn astro dev, after the Vite server is createdAdd dev-only middleware, watch external content
astro:server:startIn astro dev, once the server is listeningRead the port and address
astro:build:startBefore a production buildSet up clients or global objects for the build
astro:build:setupBefore Vite bundlesAdjust the Vite build config
astro:build:doneAfter the build completesRead 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:

  1. Provides a virtual module, virtual:build-info, exporting the build time.
  2. Injects a JSON endpoint at /build-info.json.
  3. Optionally adds a console banner to every page.
  4. Generates TypeScript types for the virtual module.
  5. Writes a build-report.json file 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 as new URL('./my.config.json', config.root).
  • addRenderer(): register a UI framework renderer, which is how @astrojs/react and 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, preview or sync.
  • isRestart: true when 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:

  1. Create a package with "type": "module" and an exports map that includes any files you reference by specifier.
  2. Declare Astro as a peer dependency so users supply their own version.
  3. Add the astro-integration keyword so that astro add can 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.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper