I built this architecture for a franchise client with locations across the US. Every location wanted its own domain, its own contact info, and its own little snowflake of page content… while still being basically the same site.
My first instinct was separate repos. That lasted about two weeks before the maintenance burden started slapping me in the face.
If you’re dealing with a franchise, a white-label product, or a pile of brands that are basically the same site wearing different hats, this is the architecture that didn’t collapse under its own weight.
The Solution at a Glance
- One Astro app running in SSR mode on Netlify
- Domain detection at request time via
Astro.url.hostname - A
sites/directory where each subdirectory holds site-specific content - A catch-all dynamic route that imports the right site’s components based on the detected domain
- Shared layouts and components that pull site data from a central config
All your domains point to the same Netlify deployment. The app reads the incoming hostname to decide which site to serve.
Step 1: Enable SSR
This entire approach requires server-side rendering with the Netlify adapter. The app needs to read the request hostname at runtime, which a static build cannot do. Static builds are many things, but psychic mind readers they are not.
// astro.config.mjs
import { defineConfig } from "astro/config";
import netlify from "@astrojs/netlify";
export default defineConfig({
output: "server",
adapter: netlify({
cacheOnDemandPages: true, // cache pages after first render
}),
// DON'T set 'site' - this is a multi-domain project.
// Each domain should resolve its own canonical URLs.
});
Important: do not set the site config option. Astro assumes there’s one canonical domain. Your project has several. If you set it anyway, Astro will confidently generate the wrong URLs and you’ll spend an afternoon wondering why.
Step 2: Create the Site Configuration
One config object defines every location: its domain, name, contact info, and any location-specific data. Everything else derives from this.
// src/utils/domain.ts
type SiteConfig = {
name: string;
brandName: string;
email: string;
phone: string;
hostname: string; // the production domain
address: string;
// Add whatever fields your sites need
};
// This is the SINGLE SOURCE OF TRUTH
const SITE_CONFIG: Record<string, SiteConfig> = {
nyc: {
name: "New York City",
brandName: "Acme Co of New York City",
email: "nyc@acmeco.com",
phone: "828-555-0101",
hostname: "acmeco-nyc.com",
address: "123 Main St<br />Asheville, NC 28801",
},
atl: {
name: "Greater Atlanta",
brandName: "Acme Co of Greater Atlanta",
email: "atl@acmeco.com",
phone: "404-555-0102",
hostname: "acmeco-atl.com",
address: "456 Peachtree St<br />Atlanta, GA 30308",
},
// ... add as many locations as you need
} as const;
// Derive types and arrays from the config - no duplication
type SiteKey = keyof typeof SITE_CONFIG;
const SITE_KEYS = Object.keys(SITE_CONFIG) as Array<SiteKey>;
The Domain Resolution Function
This function takes a hostname and returns the matching site key. It also handles the development case (localhost).
export function getSiteKey(hostname: string): SiteKey {
// In dev, allow an env var to select which site to preview
if (hostname === "localhost" && import.meta.env.DEV) {
const devSite = import.meta.env.DEV_SITE;
if (devSite && SITE_KEYS.includes(devSite as SiteKey)) {
return devSite as SiteKey;
}
return "nyc"; // default for dev
}
// Match the incoming hostname against the config
for (const [key, config] of Object.entries(SITE_CONFIG)) {
if (hostname === config.hostname) {
return key as SiteKey;
}
}
return "nyc"; // fallback
}
export function getSiteConfig(siteKey: SiteKey) {
return SITE_CONFIG[siteKey];
}
For local development, set DEV_SITE in .env to choose which location you’re pretending to be.
DEV_SITE=atl
Step 3: Organize Site-Specific Content
Each location gets its own directory under src/sites/. The structure is identical across sites; only the content differs.
src/sites/
nyc/
index.astro # Homepage
contact.astro # Contact page
programs/
camps/
index.astro # Program page content
meta.ts # Program metadata (title, order)
afterschool/
index.astro
meta.ts
atl/
index.astro
contact.astro
programs/
camps/
index.astro
meta.ts
template/ # Copy this when adding new sites
index.astro
contact.astro
reservations.astro
programs/
...
A typical site page is minimal; it imports a shared layout and passes site-specific content:
---
// src/sites/nyc/index.astro
import HomeLayout from "../../layouts/HomeLayout.astro";
---
<HomeLayout />
The layout itself reads the hostname and pulls the right config:
---
// src/layouts/HomeLayout.astro
import { getSiteKey, getSiteName, getSiteConfig } from "../utils/domain.ts";
const siteKey = getSiteKey(Astro.url.hostname);
const siteName = getSiteName(siteKey);
const siteConfig = getSiteConfig(siteKey);
const title = siteConfig.brandName;
---
<!-- Layout uses siteConfig for name, phone, email, etc. -->
Program Metadata
Each program has a meta.ts file alongside its index.astro content. This keeps metadata (used for listings and cards) separate from page content.
// src/sites/nyc/programs/camps/meta.ts
export const programMeta = {
slug: "camps",
title: "Camp",
description: "Summer becomes an adventure!",
order: 3,
cover: "../../../../assets/images/programs/camps.webp",
coverAlt: "Children at an outdoor summer camp table",
};
A utility function gathers all program metadata for a site using import.meta.glob:
// src/utils/programData.ts
export async function getProgramsBySite(
siteKey: string,
): Promise<ProgramSummary[]> {
const programMetaFiles = import.meta.glob("/src/sites/*/programs/*/meta.ts", {
eager: true,
});
const programs: ProgramSummary[] = [];
for (const [path, module] of Object.entries(programMetaFiles)) {
if (!path.includes(`/sites/${siteKey}/programs/`)) continue;
const slug = path.split("/").at(-2);
const programMeta = (module as any).programMeta;
if (programMeta) {
programs.push({
slug: programMeta.slug || slug,
title: programMeta.title,
description: programMeta.description,
url: `/programs/${programMeta.slug || slug}`,
order: programMeta.order,
});
}
}
return programs.sort((a, b) => {
if (a.order !== undefined && b.order !== undefined)
return a.order - b.order;
if (a.order !== undefined) return -1;
if (b.order !== undefined) return 1;
return a.title.localeCompare(b.title);
});
}
Step 4: The Routing System
You need just three files in src/pages/:
Homepage: src/pages/index.astro
---
import { getSiteKey } from "../utils/domain";
const siteKey = getSiteKey(Astro.url.hostname);
const { default: PageComponent } = await import(
`../sites/${siteKey}/index.astro`
);
---
<PageComponent />
All other pages: src/pages/[...slug].astro
This catch-all route handles every non-homepage URL. It detects the site, looks up the right component, and renders it.
---
import { getSiteKey } from "../utils/domain.ts";
const { slug } = Astro.params as { slug: string };
const siteKey = getSiteKey(Astro.url.hostname);
// Eager-load all site components at build time for O(1) runtime lookup
const pages = import.meta.glob("../sites/**/*.astro", { eager: true });
// Check program pages first, then regular pages
let componentPath = `../sites/${siteKey}/programs/${slug}/index.astro`;
if (!pages[componentPath]) {
componentPath = `../sites/${siteKey}/${slug}.astro`;
}
// 404 if nothing matches
if (!pages[componentPath]) {
return new Response(null, { status: 404, statusText: "Not Found" });
}
const PageComponent = pages[componentPath].default;
---
<PageComponent />
The { eager: true } option is important for performance. It pre-loads all site components at build time, turning runtime component lookup into an O(1) object property access instead of a dynamic import with I/O overhead on every request.
404 page: src/pages/404.astro
A site-aware 404 page for URLs that don’t match any route:
---
import { getSiteKey } from "../utils/domain";
const siteKey = getSiteKey(Astro.url.hostname);
const siteName = siteKey.toUpperCase();
---
<html lang="en">
<body>
<h1>404 - Page Not Found</h1>
<p>The page you're looking for doesn't exist on the {siteName} site.</p>
<a href="/">Return to Homepage</a>
</body>
</html>
Step 5: Netlify Domain Configuration
All domains point to the same Netlify deployment. In the Netlify dashboard:
- Go to Domain management for your site
- Add each domain (both
example.comandwww.example.com) - Netlify will serve the same app for all of them
WWW Redirects
Add a public/_redirects file to redirect www to the bare domain (or vice versa):
https://www.acmeco-nyc.com/* https://acmeco-nyc.com/:splat 301!
https://www.acmeco-atl.com/* https://acmeco-atl.com/:splat 301!
One line per domain. The ! forces the redirect even for pages that exist (important for SSR).
DNS Setup (Cloudflare Example)
For each domain in Cloudflare:
- Add a CNAME record:
acmeco-nyc.com->apex-loadbalancer.netlify.com(DNS Only / gray cloud) - Add a CNAME record:
www->acmeco-nyc.com(DNS Only / gray cloud) - Set SSL encryption mode to Full (strict)
The “DNS Only” (gray cloud) setting is critical: you want Netlify to handle SSL, not Cloudflare’s proxy.
SSL
In Netlify’s domain management dashboard under the HTTPS section, click Renew certificate after adding new domains. Netlify provisions SSL automatically via Let’s Encrypt.
Step 6: Form Handling Per Site
If your sites have contact forms, you need to route submissions to the correct location’s email. Pass a site-key hidden field in your forms:
<input type="hidden" name="site-key" value="{siteKey}" />
Then in your Netlify function (or any backend handler), map site keys to email addresses:
// netlify/functions/form-handler.js
const LOCATION_EMAILS = {
nyc: "nyc@acmeco.com",
atl: "atl@acmeco.com",
// ...
};
const locationEmail = LOCATION_EMAILS[siteKey] || LOCATION_EMAILS.nyc;
Adding a New Site
Adding a new location is mostly copying files and updating config. With the template directory ready, it takes about 30 minutes to go from zero to a live domain:
- Add to
SITE_CONFIGinsrc/utils/domain.ts(the only config change needed in the app code) - Copy
src/sites/template/tosrc/sites/{newKey}/and customize the content - Add the location email to your form handler’s
LOCATION_EMAILS - Add a www redirect to
public/_redirects - Add the domain in Netlify’s dashboard (both www and non-www)
- Point DNS to Netlify
- Renew the SSL certificate in Netlify
- Deploy - one
git pushand all sites update
Why This Is Great
- Single deployment. One build, one serverless function, one set of infrastructure. Many domains are no harder to operate than one.
- Shared components with per-site content. Layouts, headers, footers, and UI components are shared. Only the actual content (page copy, site-specific data, unique details) lives in site-specific directories.
- Type safety. The
SiteKeytype is derived directly fromSITE_CONFIG, so TypeScript catches invalid site keys at compile time. - Fast. Eager-loaded
import.meta.globmeans all components are resolved at build time. Runtime routing is just an object lookup. Netlify’scacheOnDemandPagescaches rendered pages after first hit. - Simple local dev. One env var (
DEV_SITE=atl) switches which site you’re previewing. No need to mess with/etc/hostsor local DNS.
But Its Not Perfect
- SSR is required. You can’t do this with a fully static Astro build because you need the request hostname at runtime. This means slightly higher latency on first hit. In practice with Netlify’s on-demand page cache, it’s been a non-issue after the first visitor warms a page.
- All sites deploy together. A content change to one site triggers a rebuild of the whole app. For my projects with a small-to-medium number of sites (under ~50), this is fine. At massive scale, good luck.
- No
siteconfig. You lose Astro features that depend on a single canonical origin (auto-generated sitemaps, RSS feeds with absolute URLs). You’d need to generate these per-domain manually if needed. Each domain also needs to build its own search authority independently - worth understanding before committing to this structure. See my multi-location website SEO and architecture decisions. - Netlify-specific. The
_redirectsfile, the Netlify adapter, and the serverless functions are Netlify-specific. But the core pattern (hostname detection + site directories) should work on Vercel, Cloudflare Pages, or any SSR host with minor adapter changes.