Four websites, one engine: an Astro monorepo that shares code but not looks
One core/ plus a folder per site: four sites share layouts, article pages, i18n and deploy, and differ only in tokens and a home page. Plus one fix that reached all four.
- Copying a site four times creates four places to fix. Put what is identical in a core and let each site hold only what makes it itself
- Shared CSS must contain no colours or fonts at all. Every colour is a variable in that site's tokens.css, so sites differ without touching core
- Fix a bug in core once, rebuild, and every site gets it at the same time. Which also means every change to core has to be deliberate
- Sites that genuinely need different features do not belong in one engine
Each of our knowledge sites has the same article pages, table of contents, learning series, two languages and SEO. They differ only in name, colours, fonts and home page. When the second, third and fourth site came along, the question was whether to copy the first site wholesale or to pull the identical parts out into an engine and let each site hold only what is its own. This article describes the second way. The engine was extracted from this very site, 6amdev.com, and now runs aroysure.com, pickoshop.com, devmakerx.com and mykaset.com.
Tested with Astro 5.14 on Node 22 for builds, and Ubuntu 24.04 with nginx on the server.
The problem: copy a site four times, fix it in four places
Copy the folder, change the colours and the name, and day one is fast. From the second week on, every bug in the article page, every new security header, every mobile layout tweak has to be repeated four times, and remembered. The fourth site drifts away from the first without anyone deciding it should.
What is identical should live in one place. What differs should sit in a handful of files you can point at.
Splitting core from site
One folder, sites/, with one package.json and one node_modules. Inside, a core and one folder per site:
sites/
├── core/ # the engine: layouts, components, lib, base.css
├── _starter/ # template copied when adding a site
├── aroysure/ pickoshop/ devmakerx/ mykaset/
│ ├── astro.config.mjs
│ ├── src/site.ts # name, URL, tagline, nav, logo, contact
│ ├── src/styles/tokens.css # every colour and font of this site
│ ├── src/data/taxonomy.json # categories, levels, series
│ ├── src/components/Home.astro # its own home page
│ ├── src/content/blog/{th,en}/ # articles
│ └── public/
└── scripts/ # run.mjs deploy.sh nginx-site.sh nginx-site.conf
Each site is its own Astro root. A site’s astro.config.mjs is two lines:
import { siteConfig } from '../core/astro-config.mjs';
export default siteConfig({ site: 'https://aroysure.com', dir: import.meta.url });
core/astro-config.mjs sets up i18n, the sitemap and the two aliases that make the whole thing work: @core points at the core folder and @site at the src/ of whichever site is being built.
vite: {
resolve: { alias: { '@core': fileURLToPath(new URL('.', import.meta.url)), '@site': here('./src') } },
server: { fs: { allow: [here('..')] } },
},
So the shared layout can import { SITE } from '@site/site' and import '@site/styles/tokens.css' without knowing which site it currently is. fs.allow is needed because Vite refuses to read files outside the site’s root unless told to.
Sites build one at a time with astro build --root <site>. scripts/run.mjs just finds folders with an astro.config.mjs and loops:
const sites = readdirSync('.').filter((d) => existsSync(`${d}/astro.config.mjs`) && !d.startsWith('_'));
if (cmd === 'build-all') for (const s of sites) run(`npx astro build --root ${s}`);
npm run dev -- aroysure 4331 # one site
npm run build:all # all four
What a site provides and what it inherits:
| Site provides | File | Inherited from core |
|---|---|---|
| Name, URL, tagline, description, nav, logo, contact | src/site.ts | Layout, header, footer, mobile menu |
| Every colour and font | src/styles/tokens.css | Article page, TOC, FAQ, related posts |
| Categories, levels, series | src/data/taxonomy.json | Category pages, series pages, lab notes |
| Home page | src/components/Home.astro | TH/EN switch, hreflang |
| Articles, images, icons | src/content/, public/ | Filters, SEO, JSON-LD, sitemap, 404 |
Home.astro is the deliberate exception: the home page is where sites genuinely differ, so each writes its own. Everything else under src/pages/** is a thin file calling a component from @core.
Tokens: every colour is a variable
The heart of “share code, not looks” is core/src/base.css. Its first lines say it plainly:
/* Shared structure for every site. No colours or fonts here: those are tokens in each site's
src/styles/tokens.css (--bg --surface --soft --line --ink --muted --sun --on-sun --violet --violet-soft
--night --night-2 --on-night --on-night-muted --term --display --body --mono). */
Over 600 lines of base.css contain not one colour code; everywhere it is var(--sun), var(--ink) and so on. The names came from 6amdev’s sunrise theme (--sun is the action colour, --night the header band) and we kept them: a name for the role matters more than a name for the hue. The same base.css therefore comes out as:
| Site | --bg | --ink | --sun (button) | --on-sun | --violet (links) | --night (header) |
|---|---|---|---|---|---|---|
| pickoshop | #FFFFFF | #3B1527 | #C2185B | #FFFFFF | #A3175F | #FFFFFF |
| mykaset | #FFFFFF | #16301C | #2E7D32 | #FFFFFF | #2E6B30 | #FFFFFF |
| aroysure | #FFFFFF | #2A1712 | #FF8A1F | #2A1712 | #A8380B | #FF8A1F |
| devmakerx | #F3F6F1 | #10201A | #C8F03C | #10201A | #1D5BB8 | #0C2A20 |
Pink and white, green and white, orange and white, and a dark maker theme, from the same HTML and CSS skeleton. devmakerx also has a dark mode, declared by repeating the same variables inside @media (prefers-color-scheme:dark); no component knows about it.
A tokens.css may also adjust shape, but only shape, never structure. pickoshop uses pill buttons:
.btn,.lang-switch,.fchip{border-radius:999px}
mykaset uses cards with a green left edge:
.svc,.steps li{border-left:5px solid var(--sun)}
aroysure is the only site with a coloured header, because its --night is the same orange as its --sun. The core paints hovered nav links in --sun, which would vanish against that header, so it overrides to an underline instead:
.main-nav a:hover,.main-nav a[aria-current="page"]{color:var(--on-night);text-decoration:underline}
The rule every tokens.css must pass: every text and background pair contrasts at least 4.5:1, per WCAG. Convert each colour to relative luminance (each sRGB channel divided by 255; if it is at most 0.03928 divide by 12.92, otherwise ((c+0.055)/1.055)^2.4; then weight 0.2126R + 0.7152G + 0.0722B) and compare (lighter+0.05)/(darker+0.05). It is a short script run with Node:
const lum = (hex) => { const [r, g, b] = hex.match(/\w\w/g).map((h) => { const c = parseInt(h, 16) / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
const ratio = (a, b) => { const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); return ((hi + 0.05) / (lo + 0.05)).toFixed(1); };
console.log(ratio('#2A1712', '#FF8A1F')); // 7.2 dark text on aroysure's orange button
console.log(ratio('#FFFFFF', '#2E7D32')); // 5.1 white text on mykaset's green button
The checked pairs are written in the comment at the top of each site’s tokens.css, for example pickoshop: white on sun 5.9 · violet on white 7.4 · sun on violet-soft 4.9, so whoever changes a colour later knows what to re-check. White text on the orange #FFA62B scores only 2.0, which is why orange buttons always carry dark text.
One fix, four sites
Thai headings have a line-breaking problem: the browser splits loanwords such as “คลาวด์” in the middle. The core’s answer is to wrap each phrase in <span class="nw">, which is white-space:nowrap, so lines break only between phrases.
The English hero phrases used the same .nw. When one site picked a wide display font, a single English phrase became wider than a phone screen and overflowed to the right, because it was not allowed to break. Thai was fine; its phrases are shorter.
With four copied sites this would be four separate fixes, found one site at a time. Here it was two lines in core/src/base.css:
.nw{white-space:nowrap}
/* English has spaces to break at, and a wide display face can make one phrase wider than a phone */
@media (max-width:520px){html[lang="en"] .nw{white-space:normal}}
English has spaces to break at, so below 520px it is allowed to wrap normally while Thai stays nowrap. npm run build:all, and all four sites had the fix at once.
The other side of the coin: every change to core touches every site. So core changes only on purpose, and after any change we open the two sites that look least alike, at 375px, in both languages.
Deploying one site at a time
The same folder swap described in the atomic deploy article, except the script takes the site name as a parameter and reads the domain from that site’s astro.config.mjs:
npm run deploy -- mykaset
npx astro build --root "$site"
tar -C "$site/dist" -czf "/tmp/$site-dist.tgz" .
# upload, then on the server:
# unpack into /tmp/$site-new → chown → mv /var/www/$site to .old → mv the new one into place → curl for 200
One improvement over the 6amdev script: chown runs before the swap, so the new version is complete from the first request. Rollback is swapping /var/www/<site> and <site>.old back.
On the server, one nginx serves several sites. The per-domain config is generated from the template scripts/nginx-site.conf (sed fills in __NAMES__ and __SITE__), and nginx -t always runs before a reload. If the test fails, the script removes the site it just added so the others keep working:
if ! sudo nginx -t; then
echo 'nginx -t failed: removing the new site so other sites keep working'
sudo rm -f /etc/nginx/sites-enabled/$domain
exit 1
fi
sudo systemctl reload nginx
Cost: a site of 21 pages (TH+EN) builds in what Astro reports as 21 page(s) built in 2.75s. All four take well under half a minute.
When not to do this
- Sites that genuinely need different features, say a shopping cart on one and a login on another. One core becomes an engine full of
if (site === ...); separate repos are better - Sites run by different teams on different release schedules. A core change hitting everyone at once becomes a liability; make the engine a package each team pins to a version
- One site. Wait until the second site is real, then extract; that is how this engine was born
Try it in 10 minutes
mkdir sites && cd sites && npm init -y && npm i astro @astrojs/sitemap
mkdir core site-a site-b
- Move
src/layouts,src/components,src/liband the structural CSS intocore/. Hunt down every colour code and font name in that CSS and turn it intovar(--…) - Write
core/astro-config.mjsexportingsiteConfig({ site, dir })with the@coreand@sitealiases andserver.fs.allow - In
site-a/create the two-lineastro.config.mjs,src/site.ts,src/styles/tokens.cssand asrc/pages/index.astrothat uses the layout from@core npx astro dev --root site-a, look at it, then copysite-atosite-bchanging onlysite.tsandtokens.css- Change something in
core/and open both sites. If both get it at once, the structure works
Common mistakes
- A colour code or font name left behind in the shared CSS: one spot on the new site shows the old site’s colour
- Naming tokens after hues (
--pink) instead of roles (--sun); when the next site is green the name lies - Shape overrides in tokens.css that grow into structure changes. Past a dozen lines, ask what core should support instead
- Forgetting
server.fs.allow, so Vite refuses to readcore/in dev - Changing core and deploying one site, leaving the other three on the old version
FAQ
Why not give each site its own repo and install the engine as an npm package?
You can, and it suits several teams. For one person running four sites, opening pull requests, cutting versions and updating each repo in turn is work with no return. One folder, one node_modules, and a change you see at once is far simpler.
How does a dark theme work when every colour is a variable?
Declare the same set of variables again inside @media (prefers-color-scheme:dark) in that site's tokens.css. Components in core never know a dark theme exists. Only one site does this today (devmakerx); the owner chose light-only for the other three.
Does this work with Next.js or another framework?
The principle is the same, with shared layouts and components and each site passing in its config and tokens, but the details differ. This article sticks to what we actually did with Astro, which has --root and aliases ready to use.
Commands and settings were tested on sample systems. Try them on a test machine before production.