If you’ve ever used Astro, you will no doubt have come across Astro’s integration system. You can think of integrations like plugins: They’re pieces of code that add new functionality and behavior to your project.
Astro integrations come in many different shapes and sizes! If you come from React, you might have used @astrojs/react before, which is an integration that allows you to use React components in your Astro project. There are more of these “renderer” integrations, both official ones from the Astro team and community-made ones! There are also “feature” integrations, like @astrojs/sitemap, which add new features to your project. The aforementioned integration adds a sitemap to your site, as the name suggests. There are also “library” integrations. They add new behavior to your site and sometimes come with components and scripts you can use in your project once installed. The last type of integrations are “adapters”, which allow you to deploy Astro to various platforms like Vercel, Netlify or a NodeJS environment.
You can add any integration to your project by running npx astro add, and then tagging the name of the integration at the end of that command. For example, to add the sitemap integration to your project, simply run:
Internally, Astro integrations are nothing but a small function which is exported from the package you install (or code locally). They can take in one or multiple parameters and return an object of the AstroIntegration type:
What you see above is a valid integration! All it needs is a name and the hooks object, and you’re good to go. You can run any code you want before the return statement, for example adding a console.log statement, which will show up once your integration is loaded by Astro. But the hooks are where the real magic happens.
Integration Hooks
Astro allows you to “hook” into certain events during the development and build processes. A full list of these hooks can be seen in the integrations reference, and I recommend reading through them if you are in the process of writing an integration.
Each hook exposes a few helpful properties like the user’s full Astro configuration, a logger you can use to print to console in the same format as Astro, and multiple functions that you can use to add functionality programmatically.
Let’s take a look at the astro:config:setup hook as an example! You can add it to your integration like this:
This hook runs on initialization, before the Vite or Astro configurations have initialized, allowing you to modify either configuration with options that your integration might need. It also allows you to add renderers like @astrojs/react, add middleware, or even inject scripts to be sent to the browser. Here’s a full reference of the options that are exposed by this hook, taken from the astro:config:setup hook reference:
As you can see, this hook gives you a lot of helpful information you might need later on in your integration’s logic. It is worth noting that, in any hook, you can always store information in other variables to access them in other hooks later on:
Since we’re writing TypeScript code, TypeScript will complain that these options are of the any type. Luckily, Astro ships with Zod, a TypeScript schema validation library that is extremely helpful in creating type-safe, verifiable options.
Zod’s Version
At the time of writing, Astro (currently at v5.8.1) still ships with Zod v3. Although Zod 4 doesn’t change much, please refer to the old Zod v3 docs to make sure you don’t run into any issues. I will update this article as soon as Astro switches to Zod v4.
Zod is, in my opinion, the way to go when it comes to validating the options of your integration. Let’s implement a small schema, infer its types and validate it so we have type-safe access to the options passed to the integration:
import { z } from"astro/zod";
/**
* The options for this integration. You can use docstrings on the schema so the user can see them too!
// parsedOptions.foo and parsedOptions.bar will be fully typed!
// Additionally, if invalid options are passed, the server will fail to start.
}
As you can see, these schemas are incredibly helpful when it comes to integration options.
Virtual Modules & Runtime Modifications
In some cases, your integration won’t just live within the hooks themselves. Sometimes, you want to pass data to pages within Astro at runtime, for example a component or a certain string that has been passed to your integration as an option. The way to go about this? Vite’s virtual modules. We’re going to get into more technical explanations here, but I’ll try to keep it as simple as possible.
Essentially, a virtual module is a “fake” module created at runtime when the server starts, contrary to normal JavaScript modules. A normal JavaScript module can be any file with an export; it can also import and re-export other modules/files. The virtual module is a way of collecting information when the server starts, allowing you to access information a normal JS module could not expose. Virtual modules are held in memory and discarded once the server stops.
The easiest way to differentiate between a virtual module and a normal module is by looking at it’s name. Usually, virtual modules will have a colon in their names, since that is an “illegal” symbol in normal NodeJS modules:
// Normal module
import something from"normal-module";
// Virtual Module
import something from"virtual:module";
Creating these virtual modules can be a bit of a pain, since they are low-level Vite code and difficult to understand. You can read the Vite guide for more information on the conventions.
Luckily, the community has created the Astro Integration Kit (AIK), which makes creating these virtual imports a breeze. First, install AIK. After that, you can use the addVirtualImports function to specify your virtual modules:
Virtual imports/modules are specified with a key-value structure, with the key being the name of the module (which should contain a colon) and the value being the TypeScript code that is used for the virtual module. In the example above, you can see the foo option being used inside of this string, meaning we can later import it from virtual:foo anywhere inside of our Astro project.
It is important to mention that virtual modules, when defined like this, can only contain strings. If you want to pass objects, you need to JSON.stringify them:
If you want to expose functions or entire files through virtual modules, the process is a little more involved. Since we don’t want to define entire functions inside of a string, we are going to move them to another file, which we then re-export through the virtual module. Let’s get started by defining a function in a new utils.ts file:
exportfunctionadd(a:number, b:number):number {
return a + b;
}
Next, in the file where we define our integration, we’re going to construct a string to turn into a virtual module. For this to work, we need to make sure that even once our integration is published to NPM, the paths still get resolved correctly. To do this, we’re going to use AIK’s createResolver function. This will return a function we can use to resolve the path to the file correctly, without having to mess with any package.json export fields:
"virtual:utils": `export { add } from '${resolve("./utils.js")}';`
}
});
}
}
}
}
Once done, you can access and call the function from the virtual:utils module:
import { add } from`virtual:utils`;
console.log(add(1, 2)); // Output: 3
You might have seen that it is possible to define integrations entirely using AIK. While this can be a nice option for some, this post focuses solely on the basics and bare internals! Having said that, I encourage you to check out their other utility functions as they are very helpful, even if you just want to learn how integrations function under the hood.
Virtual Module Types
If you have tried to use this new virtual module, you probably noticed that there are no types. This is an unfortunate side effect of us defining our virtual modules as strings - TypeScript does not recognize our code. The solution to this is creating a .d.ts file containing your types! A .d.ts file is a TypeScript declaration file that contains type information about JavaScript code. You might have seen them in your node_modules folder before, where they are commonly used by distributed packages that are compiled to normal JavaScript before they are published. This way, they can be used in TypeScript and JavaScript, while still providing types. Astro also uses these files in a few places, for example when you want to add types to your context.locals data.
We’re going to inject these types using the astro:config:done hook, since it exposes a helpful function we can use to inject types into the .astro directory. Let’s start by creating a my-module.d.ts file next to our two other files so we don’t have to type out the entire type declaration within a string:
typeOptions= {
foo:string;
bar:boolean;
}
declaremodule"virtual:options" {
exportdefault Options;
}
declaremodule"virtual:utils" {
exportconstadd: (a:number, b:number) =>number;
}
Importing in .d.ts files
Due to the way that .d.ts files work, you must not use normal, top level import ... from "..."; statements, as they will turn the declaration file into a normal module. Instead, if you want to import types instead of declaring them twice, use inline imports:
// ...
typeAddFn=typeofimport('./utils.js').add;
declaremodule"virtual:utils" {
exportconstadd:AddFn;
}
Then, head to your file where you define your integration and import the file as a raw string:
import declarations from"./my-module.d.ts?raw";
The ?raw is a Vite feature, allowing us to import any file as a string. We will then pass this to the injectTypes function exposed by the astro:config:done hook:
'astro:config:done': ({ injectTypes }) => {
injectTypes({
filename: "my-module.d.ts",
content: declarations
});
}
And that’s it! By the way, typing out the declarations in an inline string allows you to dynamically generate types based on the options the user passes in. There’s some cool things you can do! When you next start the development server, the file will be created in your .astro directory.
Exposing Astro Components & CSS
You may want to expose Astro components or CSS files through virtual modules. This is relatively simple and works similar to functions and variables. For Astro components, you can simply re-export the default export of a file:
'astro:config:setup': (params) => {
addVirtualImports(params, {
name: "my-custom-integration",
imports: {
// Importable using `import Component from "v:component";`
"v:component": `export { default } from '${resolve("./Component.astro")}';`
// Importable using `import { Foo } from "v:component";`
"v:component": `export { default as Foo } from '${resolve("./Component.astro")}';`
}
});
}
If you want to expose a CSS file, it’s even easier! You just need to import the file within the virtual module:
'astro:config:setup': (params) => {
addVirtualImports(params, {
name: "my-custom-integration",
imports: {
// CSS will be applied when imported with `import "v:css";`
"v:css": `import '${resolve("./custom.css")}';`
}
});
}
Afterthoughts
Astro Integrations are a great way to enhance Astro with new functionality and behavior. While I hope that this post has been a good explainer for the basics, I encourage you to look at AIK, the Integrations API reference and other integrations by the community to see what is possible using integrations. If you want to look at some very advanced integrations, I can recommend the following:
@studiocms/ui, a UI library which exposes all of it’s components, CSS and helpers through virtual modules
Domain Expansion, an integration that modifies Astro’s build process to support incremental builds
Typed Rest Routes, an integration that does heavy type generation for type-safe API routes
That’s it for this post! If you’ve got questions, put them in the comments below.
Until next time!
Footnotes
If you want to add your own ambient types by generating a .d.ts file, make sure to do so before the config has resolved. In most cases, a function only works inside the hook that exposes it, so it’s always recommended to use the functions within the hooks you get them from! ↩