One Astro codebase, two domains
This site and my other personal site at murat.im both run from one Astro repo. This note walks through how the split works and what to do (and not do) if you try the same.
The shape of the problem
What I needed:
- Two domains, two Cloudflare Pages projects, one GitHub repo.
- Per-variant sitemap, robots.txt, OG images, schema.org Person + WebSite.
- Dark theme on the developer site, light theme on the personal site, both from one Tailwind config.
- One source tree. No git submodules, no monorepo gymnastics.
The whole thing turns on a single environment variable: SITE_VARIANT=personal (developer site) or SITE_VARIANT=business (personal site).
The variant config
Everything that differs between the two sites lives in src/lib/variant.ts. It exports a single siteConfig object resolved at module load time based on the env var.
export type SiteVariant = 'personal' | 'business';
const raw = (import.meta.env.SITE_VARIANT ?? process.env.SITE_VARIANT ?? 'personal').toString();
export const SITE_VARIANT: SiteVariant = raw === 'business' ? 'business' : 'personal';
export interface SiteConfig {
variant: SiteVariant;
domain: string;
origin: string;
siteName: string;
themeClass: 'dark' | '';
desktopNav: NavLink[];
mobileNav: NavLink[];
routes: string[]; // top-level path segments this variant owns
socials: SocialKey[]; // which social icons render in the header
person: PersonSchema; // jobTitle, knowsAbout, sameAs, feeds JSON-LD
}
const personal: SiteConfig = { /* ...the developer site... */ };
const business: SiteConfig = { /* ...the personal site... */ };
export const siteConfig: SiteConfig = SITE_VARIANT === 'business' ? business : personal;
Components import siteConfig directly, with no React context and no provider. Astro evaluates the module per build, so the shape is fixed for that build’s output.
Build-time route pruning
The harder problem is that src/pages/ contains both variants’ pages. src/pages/tools/*.astro are developer-site-only, src/pages/projects.astro and src/pages/blog.astro are personal-site-only. Astro will build all of them by default.
I tried a few approaches before settling on post-build pruning:
astro:route:setuphook withprerender = falseswitches the route to SSR, doesn’t exclude it. Wrong tool.- A pre-build script that moves files in and out of
src/pages/works, but state lives outside git and a crashed build leaves the tree wrong. - Conditional
getStaticPathsreturning[]is perfect for dynamic routes (used for/domain-inquiry/[domain]and/og/[...slug].png), but doesn’t help for static.astrofiles.
The post-build approach is dumb and reliable. An Astro integration walks dist/ after build and deletes HTML files that don’t belong to the active variant.
const variantFilter = {
name: 'variant-filter',
hooks: {
'astro:build:done': async ({ dir, logger }) => {
const distRoot = fileURLToPath(dir);
const walk = (currentDir) => {
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
const abs = path.join(currentDir, entry.name);
if (entry.isDirectory()) { walk(abs); continue; }
if (!entry.name.endsWith('.html')) continue;
const urlPath = '/' + path.relative(distRoot, abs).replace(/\.html$/, '');
if (!belongsToVariant(urlPath)) fs.unlinkSync(abs);
}
};
walk(distRoot);
},
},
};
belongsToVariant lives in astro.config.mjs and checks whether a path’s first segment matches one of the variant’s routes. The personal-site build emits ~93 pages then prunes ~54 of them. Wasteful at build time, clean in the output.
For the OG image route and domain-inquiry, getStaticPaths returns [] on the wrong variant. That actually skips generation, which mattered enough on the personal-site build to drop it from 14s to 4.7s.
Sitemap and robots.txt per variant
@astrojs/sitemap runs before the prune step, so it would happily include pruned URLs. Fix: the sitemap filter reuses the same belongsToVariant check.
sitemap({
filter: (page) => {
const pathname = new URL(page).pathname.replace(/\/$/, '');
return belongsToVariant(pathname || '/');
},
// ...
}),
For robots.txt, the old setup was a static file in public/robots.txt with a hardcoded sitemap URL. It got copied verbatim into both builds, pointing crawlers on the personal site at the developer-site sitemap. The fix is converting it to an Astro endpoint:
// src/pages/robots.txt.ts
export const GET: APIRoute = async () => {
const body = `User-agent: *
Allow: /
Sitemap: ${siteConfig.origin}/sitemap-index.xml
`;
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
};
Same for the canonical URL, og:url, twitter cards, and the Person + WebSite schemas in Layout.astro. All driven off siteConfig.origin.
OG images with a variant-driven palette
OG image generation runs through Satori then resvg-js, which renders an inline JSX tree to a PNG. The template lives in src/lib/og.tsx with hardcoded colors. Variant-aware version:
const PALETTE = siteConfig.variant === 'business'
? { BG: '#ffffff', FG: '#1a1d29', MUTED: '#6b7280', ACCENT: '#000000' }
: { BG: '#0a0a14', FG: '#fafafa', MUTED: '#a1a1aa', ACCENT: '#3b82f6' };
Brand text (siteConfig.domain) and the available page paths also branch on variant, so the personal-site build only generates OG PNGs for /, /about, /projects, /blog, and individual blog posts. No leftover developer-site branding.
Cross-domain forwarding
Cross-domain redirects are handled by a Cloudflare Pages _redirects file emitted at build time. On the personal-site build:
/tools https://murat.dev/tools 301
/tools/* https://murat.dev/tools/:splat 301
/notes https://murat.dev/notes 301
/notes/* https://murat.dev/notes/:splat 301
/uses https://murat.dev/uses 301
Static files take precedence over _redirects rules in CF Pages, so wildcards don’t clobber the variant’s own pages. _redirects only fires when there’s no file to serve. The developer-site build doesn’t emit redirects yet, just lets unknown paths 404.
Theme split
The developer site is dark, the personal site is light. Tailwind v4 needed an explicit declaration so the dark: utility prefix responds to class="dark" on <html> (the default is prefers-color-scheme):
@custom-variant dark (&:where(.dark, .dark *));
:root {
--background: 0 0% 100%;
--foreground: 222 22% 11%;
/* ...light palette... */
}
.dark {
--background: 240 40% 6%;
--foreground: 0 0% 98%;
/* ...dark palette... */
}
Then Layout.astro reads siteConfig.themeClass and applies it to <html>. The biggest chore turned out to be hunting down hardcoded text-white, bg-black, fill-zinc-300 etc. across components and swapping them for theme tokens (text-foreground, bg-background, fill-foreground) or dark:-prefixed conditionals.
Cloudflare Pages setup
Two Pages projects, one repo, one branch:
| Setting | Developer site (murat.dev) | Personal site (murat.im) |
|---|---|---|
| Build command | pnpm build | pnpm build:business |
| Build output | dist | dist |
| Env vars | (none) | SITE_VARIANT=business |
| Custom domain | murat.dev | murat.im |
pnpm build:business is just SITE_VARIANT=business astro build in package.json.
Trade-offs
Things that worked well:
- Single source of truth. Every per-domain decision goes in one config file. Nothing else needs to know which site it’s running on.
- Build-time isolation. No client-side detection, no
if (window.location.hostname...)hacks. The HTML each domain serves is exactly what it needs. - Per-variant SEO is clean. Sitemaps don’t cross-contaminate, robots.txt points at the right URL, OG images match the live theme.