Most Astro integrations add a renderer, inject one endpoint, or wire a Vite plugin. Mine injects an entire application: every admin page, every storefront route, every API endpoint, all shipped inside an npm package so a consumer project can mount them with one line and then override any of them.
integrations: [autonnel({ excludeRoutes: ['/login'], plugins: [oauth2()] })]
The whole integration is 261 lines across eight files. Here's how it works and, more usefully, where the approach leaks.
Enumerating your own pages at config time
injectRoute takes a pattern and an entrypoint, one call at a time. I have 203 routable files and I'm not writing that list by hand, so the integration walks its own src/pages directory during astro:config:setup and derives the patterns the same way Astro's file router would:
function derivePattern(relPath: string): string {
let p = relPath.replace(/\\/g, '/').replace(/\.(astro|ts|js)$/, '');
p = p.replace(/\/index$/, '');
if (p === 'index') p = '';
return '/' + p;
}
overview.astro becomes /overview. orders/index.astro becomes /orders. api/permissions/roles/[id].ts becomes /api/permissions/roles/[id], dynamic segment intact, because the bracket syntax is already the pattern syntax. That's the part that makes this viable: file-router conventions and route-pattern syntax are the same language, so the translation is three string operations.
The walk has to reproduce the router's exclusions too:
if (name.startsWith('_') || name.startsWith('.')) continue; // Astro ignores these
if (!/\.(astro|ts|js)$/.test(name)) continue;
if (/\.test\.(ts|js)$/.test(name)) continue;
if (/\.d\.ts$/.test(name)) continue;
if (name === '404.astro') continue; // consumer owns its own 404
That last one is a product decision hiding in a loop. Injecting a 404 page means the consuming app can never have its own, and a 404 is exactly the sort of page a consumer wants to brand. Excluding one file is cheaper than an option nobody would find.
The dual-path detail: the same code has to work from source during development and from dist/ after publish, so the pages directory is resolved from where the integration file itself lives.
const pagesRel = integrationUrl.includes('/dist/') ? '../../src/pages/' : '../pages/';
A string check against your own module URL is not elegant. It is, as far as I can tell, the shortest thing that actually works for a package that must behave identically before and after a build step.
Exclusion is the whole point
export function filterRoutes(routes: InjectableRoute[], exclude: string[]): InjectableRoute[] {
if (!exclude || exclude.length === 0) return routes;
const set = new Set(exclude);
return routes.filter((r) => !set.has(r.pattern));
}
Eight lines, and they're the reason this design works at all. A consumer that wants a different login page passes excludeRoutes: ['/login'] and puts their own src/pages/login.astro in their project. The core route is never injected, so there's no conflict to resolve and no precedence rule to remember.
Plugin routes deliberately bypass the filter:
// These bypass excludeRoutes: a consumer typically excludes a core route (e.g. /login) so a
// plugin can serve its own page at that pattern.
for (const plugin of resolved.plugins) {
for (const route of plugin.routes ?? []) injectRoute({ pattern: route.pattern, entrypoint: route.entrypoint });
}
The common case is exclude-then-replace, so making the two lists interact would break the only workflow anyone uses.
The part I'd flag in review
The comment I'm least happy with is in index.ts:
The injected pages are core SOURCE files importing via
@/...; the consumer must resolve that alias to the package src and mark the package srcssr.noExternal. Astro'supdateConfigdoes not reliably apply a resolver plugin / alias from here, so this stays consumer-side.
That is an integration with a documentation dependency. Install it, and it doesn't work until you also add an alias and an ssr.noExternal entry to your own config. I tried to push both into updateConfig and couldn't make it apply reliably from the setup hook.
If you're designing something similar, know that this is the sharp edge: injectRoute will happily take an entrypoint that resolves inside your package, but the imports inside that entrypoint resolve in the consumer's module graph, not yours. Any non-relative import in an injected page is a constraint you're imposing on the consumer, and there's no mechanism to declare it.
The alternative is compiling injected pages to use relative or bare specifiers only. That's a real fix and it's a large refactor of a hundred files, so I've written down the constraint instead of paying for it. That's a trade, not a solution, and I'd want a reviewer to call it that.
The gap I found writing this post
The integration is also where plugins get registered:
registerAuthoringComponents();
registerPlugins(resolved.plugins);
Which means plugins only register when the app is consumed through the integration. My two documented install paths (a Docker image and a git clone) both run the repo's own astro.config.mjs and use the native file router, not the integration. So on both of them, registerPlugins never runs and a marketplace plugin can't activate.
That's a real gap, not a hypothetical one, and I found it by writing out the flow for this post rather than by reading the code. There's a lesson in that which I'd generalize: any initialization that lives in an optional wrapper is unreachable on every path that doesn't use the wrapper. Registration belongs in the composition root that all paths share. Mine doesn't, yet.
Would I recommend this shape
For "reusable pieces", no. Ship components and let people compose them.
For "an entire application that a consumer extends", the injected-routes approach is genuinely good, and the exclusion mechanism is what makes it feel like a framework rather than a fork. The honest caveats: your injected pages' imports become a contract you can't enforce, and any setup work that happens in the integration hook silently doesn't happen for anyone running your source directly.
Top comments (0)