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

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.

**Canonical:** https://spoko.space/astro-split-build/  
**Language:** en  
**Published:** 2026-11-01  
**Tags:** astro, i18n, javascript, npm, open-source, seo  
**Category:** Portfolio  
**GitHub:** https://github.com/spokospace/astro-split-build

---
<div class="text-center flex gap-4 justify-center flex-wrap">

</div>

## At a glance {#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](https://spoko.space/vw-polo-6r-parts-catalog/) 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 {#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 {#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) → `astro build: pl` (only its `pl/` is kept) → `astro build: de` (only its `de/` is kept)
2. **Merge into one dist/** — en at the root, `/pl/` and `/de/` under it, every `_astro/` bundle from all three builds.
3. **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 {#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 {#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**

| Item | Group | Value |
| --- | --- | --- |
| Three separate checks | before: 3 scripts | 26.2 s |
| One pass, three checks | after: one pass | 13.6 s |

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

**Post-merge checks: peak memory**

| Item | Group | Value |
| --- | --- | --- |
| Three separate checks | before: 3 scripts | 2,900 MB |
| One pass, three checks | after: one pass | 118 MB |

Peak RSS. Before, the link check alone reached 2.9 GB; after, the live heap stays at or under 15 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 {#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](https://github.com/spokospace/catalog.polo.blue/issues/1047) moved it out:

- It got its own repository, [spokospace/astro-split-build](https://github.com/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`](https://github.com/spokospace/astro-split-build/tree/main/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} */

```

```bash
npx astro-split-build --config split-build.config.js
```

## When you need it, and when you don't {#when-you-need-it-and-when-you-dont}

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 {#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 {#tech}

<div class="text-center flex gap-4 justify-center flex-wrap mt-8">

</div>
