DEV Community

Cover image for How to manage multiple websites from one Astro monorepo
Varinder Singh
Varinder Singh

Posted on

How to manage multiple websites from one Astro monorepo

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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, or warm)
  • 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
Enter fullscreen mode Exit fullscreen mode

Then:

cd my-agency
bun install
bun run dev
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This scaffolds sites/newclient.com/ with the Warm preset applied (cream/amber palette, serif display typography). The scaffolder:

  1. Copies the starter template
  2. Updates astro.config.mjs with your domain
  3. Applies the design preset
  4. 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' },
];
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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',
};
Enter fullscreen mode Exit fullscreen mode

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 @graph with WebSite + Organization nodes, wired with @id references so crawlers can walk the relationships. The Organization.sameAs is derived from your site-config's socialLinks array. Your logo becomes an ImageObject referenced 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/alt when 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:

  • _headers with a 1-year immutable cache on /_astro/* and No-Vary-Search to strip UTM params from the CDN cache key.
  • robots.txt referencing 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 */
/>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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' }),
      },
    }),
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or:

git clone https://github.com/Indivar/astro-fleet.git
cd astro-fleet
bun install
bun run dev
Enter fullscreen mode Exit fullscreen mode

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)