If you're an agency, freelancer, or company running more than a couple of websites, you've hit the maintenance multiplier problem: every improvement has to be replicated across every site. A header fix becomes six PRs. A design refresh becomes a month-long project.
This article walks through how we solved it with a shared-component monorepo — and how you can use the same approach. We've open-sourced the entire setup as Astro Fleet. Version 2.2 just shipped this week with a new CLI, a working CMS example, and SEO defaults baked into the shared layer.
The problem
We maintain websites for multiple brands. Each site started as its own repo with its own copy of common components: header, footer, contact form, CTA block, SEO tags.
Within six months:
- The header in Site A had a mobile fix that Sites B–F didn't
- Site C had an improved footer layout that nobody propagated
- Three sites had slightly different versions of the same contact form
- Updating the company colour palette meant editing CSS in six places
Sound familiar?
The solution: shared components in a monorepo
The idea is simple: components that appear on every site live in one place. Each site imports them and brands them with its own design tokens.
astro-fleet/
├── packages/
│ ├── config/ ← design token presets (colours, fonts, spacing)
│ ├── shared-ui/ ← 22 components + 3 layouts
│ └── create-astro-fleet/ ← the scaffolding CLI
├── sites/
│ ├── client-a.com/ ← pages, site config, deploys independently
│ ├── client-b.com/ ← different brand, same components
│ └── client-c.com/
└── turbo.json ← Turborepo orchestrates builds
Key principle: components are brand-agnostic. They read CSS custom properties (--color-primary, --font-heading, etc.) instead of hardcoding colours. The design preset sets those variables. Same component, different look.
Step 1: Scaffold a new fleet with the CLI
The fastest way to start is with the create-astro-fleet CLI:
bunx create-astro-fleet
(Also works with npm create astro-fleet, pnpm create astro-fleet, or yarn create astro-fleet.)
The CLI prompts you for:
- A directory name
- Your first site's domain (e.g.
acme.com) - A design preset (
corporate,saas, orwarm) - Whether to keep the three demo sites as reference
Skip the prompts with flags:
bunx create-astro-fleet my-agency --domain acme.com --preset saas
Then:
cd my-agency
bun install
bun run dev
You're running your first site in about 60 seconds. Prefer to clone the repo directly? That's still supported:
git clone https://github.com/Indivar/astro-fleet.git
cd astro-fleet
bun install
bun run dev
Step 2: Add more sites to the fleet
Each new site gets its own directory under sites/ and deploys independently.
bunx create-astro-fleet add newclient.com warm
This scaffolds sites/newclient.com/ with the Warm preset applied (cream/amber palette, serif display typography). The scaffolder:
- Copies the starter template
- Updates
astro.config.mjswith your domain - Applies the design preset
- Sets the site name in
site-config.ts
(The old ./scripts/new-site.sh still works for anyone who prefers bash.)
Step 3: Customise the site identity
Every site has one file that controls its identity: src/lib/site-config.ts.
export const SITE_NAME = 'Acme Analytics';
export const TAGLINE = 'Product analytics for modern teams';
export const navigation: MenuItem[] = [
{ label: 'Product', href: '/services/', children: [
{ label: 'Event Pipeline', href: '/services/' },
{ label: 'Session Replay', href: '/services/' },
]},
{ label: 'Pricing', href: '/services/' },
{ label: 'Docs', href: '/about/' },
{ label: 'Sign in', href: '/contact/' },
];
export const footerColumns: FooterColumn[] = [
// ... your footer structure
];
export const contactInfo: ContactInfo = {
email: 'hello@acme.com',
phone: '+1 (555) 000-0000',
};
export const socialLinks: SocialLink[] = [
{ platform: 'linkedin', url: 'https://linkedin.com/company/acme' },
{ platform: 'twitter', url: 'https://twitter.com/acme' },
];
Edit this one file and the header, footer, mobile menu, and breadcrumbs all update across the entire site. The social links also flow into the auto-generated JSON-LD graph (more on that in Step 6).
Step 4: Build pages with shared components
Pages import components from the shared library and pass content via props:
---
import BaseLayout from '@astro-fleet/shared-ui/src/layouts/BaseLayout.astro';
import FAQ from '@astro-fleet/shared-ui/src/components/FAQ.astro';
import PricingTable from '@astro-fleet/shared-ui/src/components/PricingTable.astro';
import CTABlock from '@astro-fleet/shared-ui/src/components/CTABlock.astro';
import { SAAS } from '@astro-fleet/config/tokens';
import { SITE_NAME, navigation, footerColumns, socialLinks } from '../lib/site-config';
---
<BaseLayout title="Pricing" siteName={SITE_NAME} navigation={navigation}
footerColumns={footerColumns} socialLinks={socialLinks} designTokens={SAAS}>
<PricingTable
heading="Simple pricing"
tiers={[
{ name: 'Free', price: '$0', period: 'mo',
features: [{ text: '10K events', included: true }],
ctaHref: '/signup' },
{ name: 'Pro', price: '$49', period: 'mo', highlighted: true,
badge: 'Popular',
features: [{ text: 'Unlimited events', included: true }],
ctaHref: '/signup' },
]}
/>
<FAQ items={[
{ question: 'Can I switch plans?', answer: 'Yes, anytime.' },
]} />
<CTABlock heading="Start free today"
primaryButton={{ text: 'Sign up', href: '/signup' }} />
</BaseLayout>
Every component adapts to your preset automatically. No CSS to write for branding.
Step 5: The design token system
Tokens are TypeScript objects that define a site's visual identity:
export const SAAS: DesignTokens = {
colorPrimary: '#0a0f14',
colorSecondary: '#1a1f2e',
colorAccent: '#34d399',
colorBackground:'#0d1117',
colorText: '#e6edf3',
colorCta: '#10b981',
fontHeading: 'Sora',
fontBody: 'Inter, system-ui, sans-serif',
};
These get converted to CSS custom properties by BaseLayout and injected into :root. Every component reads var(--color-primary), var(--font-heading), etc. — so switching from Corporate to SaaS is a one-line change in your page's import.
Three presets ship out of the box. Creating your own is just defining a new object that conforms to the DesignTokens interface.
Step 6: SEO by default
This is new in v2.2 and it's where the starter genuinely saves you weeks of work.
BaseLayout automatically emits:
-
A linked JSON-LD
@graphwithWebSite+Organizationnodes, wired with@idreferences so crawlers can walk the relationships. TheOrganization.sameAsis derived from your site-config'ssocialLinksarray. Your logo becomes anImageObjectreferenced by@id. -
Full robots directives on indexable pages (
max-snippet:-1, max-image-preview:large, max-video-preview:-1) so Google can show rich snippets. -
Canonical URL derived from
Astro.site— UTM-stripped. -
Open Graph with proper
og:image:width/height/altwhen you pass an image, and Twitter card defaults that match. -
<link rel="icon">pointing at/favicon.svg(every site ships one).
And in the site's public/ directory:
-
_headerswith a 1-year immutable cache on/_astro/*andNo-Vary-Searchto strip UTM params from the CDN cache key. -
robots.txtreferencing the sitemap.
Page-level JSON-LD (Article, Product, BreadcrumbList) merges cleanly into the auto-generated @graph — just pass it as structuredData:
<BaseLayout
title={entry.data.title}
description={entry.data.summary}
siteName={SITE_NAME}
ogType="article"
structuredData={{
'@type': 'Article',
headline: entry.data.title,
datePublished: entry.data.publishedAt.toISOString(),
author: { '@type': 'Person', name: entry.data.author },
}}
/* ...layout props */
/>
For optional SEO add-ons (per-page OG images with Satori, git-based sitemap lastmod, llms.txt, IndexNow, FuzzyRedirect, view transitions), see docs/seo-recipes.md.
Step 7: Adding a CMS (so clients can edit their own content)
This is the other big new piece in v2.2. The Meridian demo ships with a working Keystatic integration — a git-based CMS where content stays as markdown in your repo, editors log in via a web UI at /keystatic, and changes commit back to git.
The architecture is two files plus content:
sites/meridian-advisory.com/
├── keystatic.config.ts ← field schema, validation, storage
├── src/content.config.ts ← Astro content collection mirror
└── src/content/insights/*/index.mdoc ← actual entries, committed to git
keystatic.config.ts defines what editors can type:
import { config, fields, collection } from '@keystatic/core';
export default config({
storage: { kind: 'local' },
collections: {
insights: collection({
label: 'Insights',
slugField: 'title',
path: 'src/content/insights/*',
format: { contentField: 'content' },
schema: {
title: fields.slug({
name: { label: 'Title', validation: { length: { min: 5, max: 120 } } },
}),
publishedAt: fields.date({ label: 'Published at' }),
summary: fields.text({ label: 'Summary', multiline: true }),
content: fields.markdoc({ label: 'Content' }),
},
}),
},
});
Run bun run dev --filter=meridian-advisory.com, open /keystatic, and you get a full admin UI. Save an entry and it writes a real markdown file to disk that git tracks.
The caveat to understand before rolling this out to clients: Keystatic's auth is binary — "can you push to this GitHub repo, yes or no?" It doesn't do row-level permissions. In a monorepo, that means everyone with access to the admin can edit every site. Fine for an agency team editing client sites in-house. Not fine for giving each client their own isolated login.
The full walkthrough (including production editing with GitHub mode, and alternatives — Sanity, Directus, Decap, Payload, Tina) is in docs/adding-a-cms.md.
What ships in the component library
22 components, each with typed Props, scoped styles, and zero third-party dependencies:
Page structure: Header, Footer, BaseLayout, IndustryLayout, ProductLayout, Breadcrumb, SEOHead
Content sections: HeroSlider, StatsBar, FeatureGrid, ServiceCard, ProductCard, Timeline, TeamGrid, TestimonialSlider, LogoCloud, TrustBar
Conversion: CTABlock, PricingTable, ComparisonTable, ContactForm, Newsletter, FAQ
Utility: Banner, SectionDivider
Only 2 of 22 use any JavaScript (the carousel sliders). Everything else is pure HTML + CSS.
Deploying independently
Each site builds independently via Turborepo:
bun run build --filter=acme.com
wrangler pages deploy sites/acme.com/dist --project-name=acme
Turborepo caches builds, so if only one site changed, only that site rebuilds. A full fleet of 10 sites builds in seconds.
See it in action
We built three demo sites to show the range:
| Preset | Demo | What it looks like |
|---|---|---|
| Corporate | astro-fleet-meridian.pages.dev | Consulting firm — editorial hero, stats strip, numbered practice areas, live CMS-backed /insights blog |
| SaaS | astro-fleet-flux.pages.dev | Dev tool — dark theme, code mockup, pricing tiers, comparison table |
| Warm | astro-fleet-olive.pages.dev | Restaurant — warm gradients, serif type, dotted-leader menu |
Click through to the About and Contact pages — each site uses a different combination of the shared components.
Getting started
bunx create-astro-fleet
Or:
git clone https://github.com/Indivar/astro-fleet.git
cd astro-fleet
bun install
bun run dev
Full documentation:
Repo: github.com/Indivar/astro-fleet — MIT licensed, contributions welcome.
We use this in production to run vairi.com, claspt.app, and stakteck.com. If you ship a site with Astro Fleet, open a PR adding it to the "Built with" section — we'd love to feature it.
Top comments (0)