At a glance
dist/ and checks the merged pages in a single pass: bundles, internal links, canonicals, hreflang and your own checks.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:
- 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. - 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 (ENOENTin 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.
astro build: encopied whole, as the root
astro build: plonly its
pl/is keptastro build: deonly its
de/is kept
Merge into one dist/
en at the root,
/pl/and/de/under it, every_astro/bundle from all three builds.Check every page, once
- asset references
- internal links
- canonical and hreflang, linking back
- custom page checks
- the catalog's locale leak check
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
hreflangalternate 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):
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-i18nis 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 abasepath (/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_TOKENsecret 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:
// 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' },
};npx astro-split-build --config split-build.config.jsWhen 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.




