Serve multiple domains from one Astro app on Netlify

One codebase, one deployment, many domains. A practical SSR architecture for multi-brand, franchise, and white-label sites.

By on

Loading the Elevenlabs Text to Speech AudioNative Player...

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

  1. One Astro app running in SSR mode on Netlify
  2. Domain detection at request time via Astro.url.hostname
  3. A sites/ directory where each subdirectory holds site-specific content
  4. A catch-all dynamic route that imports the right site’s components based on the detected domain
  5. 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:

  1. Go to Domain management for your site
  2. Add each domain (both example.com and www.example.com)
  3. 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:

  1. Add a CNAME record: acmeco-nyc.com -> apex-loadbalancer.netlify.com (DNS Only / gray cloud)
  2. Add a CNAME record: www -> acmeco-nyc.com (DNS Only / gray cloud)
  3. 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:

  1. Add to SITE_CONFIG in src/utils/domain.ts (the only config change needed in the app code)
  2. Copy src/sites/template/ to src/sites/{newKey}/ and customize the content
  3. Add the location email to your form handler’s LOCATION_EMAILS
  4. Add a www redirect to public/_redirects
  5. Add the domain in Netlify’s dashboard (both www and non-www)
  6. Point DNS to Netlify
  7. Renew the SSL certificate in Netlify
  8. Deploy - one git push and all sites update

Why This Is Great

But Its Not Perfect