astro-split-build: building a large multilingual Astro site on a shared host

· 7 min read

An open-source npm package that builds a multilingual Astro site one locale at a time, merges the builds into one dist/ and checks every page in a single pass, so a broken bundle, link or hreflang never goes live.

An open-source npm package that builds a multilingual Astro site one locale at a time, merges the builds into one dist/ and checks every page in a single pass, so a broken bundle, link or hreflang never goes live. An open-source npm package that builds a multilingual Astro site one locale at a time, merges the builds into one dist/ and checks every page in a single pass, so a broken bundle, link or hreflang never goes live.

At a glance

Problem
The VW Polo 6R parts catalog is a static Astro site in three languages, built on a shared host with a per-account process and thread limit. One build of every locale did not fit, and neither did three builds at once.
What was built
A build runner that renders one locale at a time, merges the builds into one dist/ and checks the merged pages in a single pass: bundles, internal links, canonicals, hreflang and your own checks.
The hard part
Merging the outputs. Some bundles exist in only one locale's build, the filesystem loses files mid-copy, and links between languages can only be checked once every language is in one place.
My role
Author and maintainer: the build core in the catalog, its extraction into a public repository, the tests, the example site, the CI and the npm releases.
Result
Every catalog release (8,617 pages) is built and checked by the package before it goes live. The checks after the merge went from 26.2 s and 2.9 GB of memory to under 14 s and 118 MB. MIT licence, npm i -D astro-split-build.

The parts catalog for the VW Polo 6R is one Astro site in English, Polish and German. On the live release I measured at the end of October 2026 that is 8,617 pages and 2.3 GB of HTML. It is built on the production server itself, a shared Hostido host, and that host decides how the build has to work.

What follows is the story of how the catalog gets built: what broke, what the build does now, and how that code became a public npm package anyone can install.

Why one build was too much

A shared host runs every account inside a CloudLinux LVE, which caps how much memory and how many processes and threads one account may use. A single astro build of every locale ran out of memory or hit EMFILE: too many open files. Building the three locales in parallel was tried as well, and failed the same way: three builds at once is three times the memory and three times the open files.

The thread limit shows up in odd places too. Rolldown, Vite’s bundler, sizes its thread pool to the CPU count, which is 32 on that host; the account refuses the threads and the build panics before it renders a page. The catalog pins the pool to eight threads for that reason, and it runs its builds strictly one after another.

Building one locale at a time, and what that breaks

Splitting the build is simple. Each run gets BUILD_LOCALE, and the catch-all route renders only that locale’s pages. Three small builds fit where one big one did not.

The trouble starts when the three outputs become one site. A naive merge has two traps:

  1. Lost bundles. Each build emits its own content-hashed JS and CSS. Keep only pl/ from the Polish build and any chunk that hashed differently there is gone. The page still answers 200, but its interactive island never loads. This happened in production.
  2. A filesystem that lags. On NFS-like shared storage a recursive delete races (ENOTEMPTY), and a large copy is not always visible to the next directory read (ENOENT in the middle of a loop).

So the merge copies the English build whole to the root, keeps only /pl/ and /de/ from the other two, and merges the shared _astro/ bundle directory from all three. Since bundle names are content hashes, two files with the same name hold the same bytes, and overwriting is safe. Every copy and delete is retried, and a save waits a moment before the next step reads what it wrote.

From three builds to one release
    1. astro build: en

      copied whole, as the root

    2. astro build: pl

      only its pl/ is kept

    3. astro build: de

      only its de/ is kept

  1. Merge into one dist/

    en at the root, /pl/ and /de/ under it, every _astro/ bundle from all three builds.

  2. Check every page, once

    • asset references
    • internal links
    • canonical and hreflang, linking back
    • custom page checks
    • the catalog's locale leak check
  3. Every check passes

    Switch the release

    The new build goes live.

    A check fails

    Keep the previous release

    Nothing is switched; the failed builds stay on disk to inspect.

Why the checks run after the merge

Every check reads the merged dist/ on purpose. The language switcher and the hreflang alternates link across languages: an English page points at its Polish and German versions, and they must point back. A Polish build on its own cannot tell whether /de/… exists, because it never built it, so only the merged output can answer that.

So after the merge, the package reads the pages and fails the build if:

  • a page references a bundle in _astro/ that is not in the merged output,
  • an internal link, a canonical or an hreflang alternate leads to a page that was not built (a link may go through one of the server’s redirects, as long as the redirect ends on a built page),
  • an alternate does not link back, or one page gives one language two URLs,
  • any custom page check reports a problem. The catalog adds its own here: no page may carry another language’s interface strings, since a Polish page once shipped with a German title.

The build exits with a non-zero code, and the catalog’s deploy script only switches the release symlink after a zero. A broken build never replaces a working one; the previous release stays live and the saved builds are kept for inspection.

One pass instead of three

At first each check was its own script, and each one listed dist/ and read all 8,617 pages on its own: 2.3 GB of HTML, three times over. The link check also held on to every page until the end. It kept the href and hreflang values it found, and V8 stores a substring as a view into the string it came from, so each small value kept its whole page in memory.

The fix had two parts. All checks now run as callbacks over a single scan: the directory is listed once and each page is read once, however many checks there are. And the values a check keeps are copied out of the page first (detach() in the package), so the page can be freed as soon as the scan moves on.

I measured both versions over SSH on the live release, and they found exactly the same things (122,958 asset references, 2,833,064 links, no hreflang problems):

Post-merge checks: time
Post-merge checks: timeWall time of the checks after the merge, on 8,617 pages. Three separate scripts took 26.2 seconds; one pass takes about 13.6 seconds.Three separate checks · before: 3 scripts26.2  sOne pass, three checks · after: one pass13.6  s

Before: 5.0 + 12.6 + 8.6 s. After: 13.2 to 13.7 s across runs.

Post-merge checks: time
ItemGroupValue
Three separate checksbefore: 3 scripts26.2  s
One pass, three checksafter: one pass13.6  s
Post-merge checks: peak memory
Post-merge checks: peak memoryPeak resident memory of the checks after the merge. The link check alone reached 2.9 GB; the single pass peaks at 118 MB.Three separate checks · before: 3 scripts2,900  MBOne pass, three checks · after: one pass118  MB

Peak RSS. Before, the link check alone reached 2.9 GB; after, the live heap stays at or under 15 MB.

Post-merge checks: peak memory
ItemGroupValue
Three separate checksbefore: 3 scripts2,900  MB
One pass, three checksafter: one pass118  MB

On a host where memory is the scarce thing, the second chart matters more than the first.

From a folder in the catalog to an npm package

The build core started as a folder inside the catalog repository, with tests, but importable only by relative path. Nothing in it was specific to car parts, so the epic spokospace/catalog.polo.blue#1047 moved it out:

  • It got its own repository, spokospace/astro-split-build, split from the catalog together with its history. The licence is MIT, it needs Node 20 or newer and has no dependencies.
  • examples/astro-i18n is a small Astro site in three locales with a custom page check. CI runs the tests on Node 20, 22 and 24 and builds the example twice, at the root and under a base path (/docs), because a base changes how every link and asset reference has to be resolved.
  • semantic-release publishes it from GitHub Actions through npm trusted publishing (OIDC). There is no NPM_TOKEN secret to leak or expire, and every version carries a provenance attestation that ties it to the commit and the workflow that built it.
  • 0.1.0 came out on 30 October 2026 and 0.2.0 on 1 November, with TypeScript declarations generated from the JSDoc. The package stays plain JavaScript, but the config, the results and the page checks are typed.
  • The catalog installs the package from npm like any other dependency, pinned to an exact version. Only what belongs to the catalog stays in its repository: the locales, the host’s thread limit, the locale leak check, the Open Graph images and Pagefind.

Using it takes one config file and one command:

js
// split-build.config.js
/** @type {import('astro-split-build').SplitBuildOptions} */
export default {
  targets: [
    { name: 'en', root: true },
    { name: 'pl', subdir: 'pl' },
    { name: 'de', subdir: 'de' },
  ],
  command: 'astro build',
  env: (target) => ({ BUILD_LOCALE: target.name }),
  sharedDirs: ['_astro'],
  verifyAssets: { dir: '_astro' },
  verifyLinks: { site: 'https://example.com' },
};
bash
npx astro-split-build --config split-build.config.js

When you need it, and when you don’t

If one astro build of every locale fits in memory, keep it; splitting adds a merge you do not need. The package is for the sites where it does not fit: thousands of pages in several languages, on shared hosting, a small VPS or Windows.

The checks are useful without the split, though. verifyAssetRefs, verifyInternalLinks and scanPages run against any finished static build, so a single-build site can still fail a deploy on a missing bundle or an hreflang that does not link back.

My role

I wrote the build core inside the catalog, found and fixed the failures it guards against (the lost bundles, the filesystem races, the memory held by substrings), measured the single pass on production, and extracted it into the public package: the repository, the README, the example site, the tests, the CI and the release pipeline.

Tech

Astro

The site generator. The package expects Astro i18n routing with the default locale at the root and the others under their prefix.

Node.js, no dependencies

Plain JavaScript on Node 20+, using only the standard library: child processes for the builds, fs for the merge and the scan.

TypeScript declarations

Generated from the JSDoc on publish, so configs and custom page checks are typed in the editor.

semantic-release and npm provenance

Conventional commits pick the version; trusted publishing over OIDC releases it with a provenance attestation.

Open source (MIT)

Public on GitHub with tests on Node 20, 22 and 24 and an example Astro site built in CI.

Back to portfolio