π§ͺ NXSamples
July 24, 2026 Β· View on GitHub
Build once. Ship everywhere. Serve bytes, not megabytes.
Six independent Nx workspaces, six bundler setups, one obsession: a production frontend artifact that is built exactly once, compressed to the bone, and re-configured at container start time without a rebuild.
Every sample answers the same three questions with a different toolchain:
- π§© How do I eject the bundler config (
webpack.config.js,rspack.config.js,vite.config.ts, esbuild plugin options) so I fully control the output? - π How do I keep runtime configuration out of the bundle so one image can run in dev, test, stage and prod?
- ποΈ How do I emit pre-compressed assets (Brotli, gzip, Zstandard, AVIF, WebP) that
nginx-brotlican serve straight from disk?
πΊοΈ The sample matrix
| Sample | Framework | Bundler | Ejected config | Runtime config source | Compression strategy |
|---|---|---|---|---|---|
AngularEsBuild | Angular 22 | esbuild (@nx/angular:application) | inline esbuild plugins + tools/ui-compression.config.cjs | src/config.js injected as env-config script | π @adaskothebeast/esbuild-compressor 2.1 (gzip 9, Brotli 11, Zstd 19, AVIF, WebP) |
AngularWebpack | Angular 22 | webpack 5 (customWebpackConfig) | apps/ui/webpack.config.js | src/config.js injected as env-config script | compression-webpack-plugin (gzip 9, Brotli 11, Zstd 19) + sharp (AVIF, WebP) |
ReactWebpack | React 19 | webpack 5 (NxAppWebpackPlugin) | apps/ui/webpack.config.js | src/config.js via plugin scripts option | compression-webpack-plugin (gzip 9, Brotli 11, Zstd 19) + sharp (AVIF, WebP) |
ReactRsPack | React 19 | Rspack 2 | apps/ui/rspack.config.js | src/config.ts as a second entry point named env-config | compression-webpack-plugin (gzip 9, Brotli 11, Zstd 19) + compress.js sweep (AVIF, WebP) |
ReactVite | React 19 | Vite 8 (rolldown) | apps/ui/vite.config.ts | src/config.ts isolated via manualChunks | vite-plugin-compression2 (gzip 9, Brotli 11, Zstd 19) + compress.js sweep (AVIF, WebP) |
VueVite | Vue 3.5 | Vite 8 (rolldown) | apps/ui/vite.config.ts | src/config.ts isolated via manualChunks | vite-plugin-compression2 (gzip 9, Brotli 11, Zstd 19) + compress.js sweep (AVIF, WebP) |
Each folder is a standalone Nx workspace with its own package.json, yarn.lock and
Playwright e2e project. Clone the repo, cd into any sample, and it just runs. π―
Compression coverage, per sample:
| Sample | .gz | .br | .zst | AVIF / WebP | External binary |
|---|---|---|---|---|---|
| AngularEsBuild | β | β | β
(level 19, zstd: true) | β | none |
| AngularWebpack | β | β | β (level 19) | β | none |
| ReactWebpack | β | β | β (level 19) | β | none |
| ReactRsPack | β | β | β (level 19) | β | none |
| ReactVite | β | β | β (level 19) | β | none |
| VueVite | β | β | β (level 19) | β | none |
Every sample compresses with Node's built-in zlib, so gzip, Brotli and Zstd all work with a plain
yarn install. Zstd needs Node.js 22.15+ / 24+; on older runtimes the samples warn and skip
.zst instead of failing. π
π Build once, configure at startup
Baking API_BASE_URL into a bundle means one image per environment. That is a build matrix
nobody wants. Instead, every sample keeps configuration in a separate, tiny, un-hashed-by-contract
bundle called env-config:
// apps/ui/src/config.js (or config.ts)
window._env_ = {
API_BASE_URL: 'https://localhost:5001',
APP_TENANT_ID: 'your-tenant-id',
APP_CLIENT_ID: 'your-client-id',
API_SCOPES: 'api://your-client-id/.default',
};
Typed on the app side so consumers get IntelliSense instead of any:
// apps/ui/src/types/window.d.ts
export {};
declare global {
interface Window {
_env_: {
clientId: string;
tenantId: string;
scopes: string[];
};
}
}
At container start, env.sh inside the
nginx-brotli image finds
env-config*.js (hashed or not), rewrites it from real environment variables, and nginx serves
it with Cache-Control: no-store. The rest of the app stays byte-identical and fully cacheable.
graph LR
A["π§βπ» Source + config.js"] --> B["π¦ One CI build"]
B --> C["π³ One image<br/>hashed assets + .gz / .br / .zst"]
C --> D1["env.sh writes env-config.js<br/>π’ DEV"]
C --> D2["env.sh writes env-config.js<br/>π‘ STAGE"]
C --> D3["env.sh writes env-config.js<br/>π΄ PROD"]
π§ How each bundler was tamed
Angular + esbuild β‘ (AngularEsBuild)
Angular's scripts option keeps config.js out of the module graph and emits a
standalone, auto-injected env-config bundle. Compression is delegated to my own plugin:
// apps/ui/project.json
"build": {
"executor": "@nx/angular:application",
"options": {
"plugins": [
{
"path": "@adaskothebeast/esbuild-compressor",
"options": {
"extensions": [".js"],
"skipFilesPattern": "env-config.*\\.js$",
"gzipOptions": { "level": 9 },
"brotliOptions": { "params": { "BROTLI_PARAM_QUALITY": 11 } },
"zstd": true,
"zstdOptions": { "params": { "ZSTD_c_compressionLevel": 19 } }
}
}
],
"scripts": [
{ "input": "apps/ui/src/config.js", "bundleName": "env-config", "inject": true }
]
}
}
Because Angular writes JS, global CSS and index.html in different stages, the esbuild plugin
alone only sees the JS stage. A compress target runs the package's directory CLI afterwards
so every final artifact (including images) is covered:
"compress": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": { "command": "esbuild-compressor --config tools/ui-compression.config.cjs" }
}
// tools/ui-compression.config.cjs
module.exports = {
directory: 'dist/apps/ui/browser',
extensions: ['.js', '.css', '.html', '.json', '.svg'],
// main*.js is already handled by the esbuild plugin above, so skip it here.
skipFilesPattern: '^(?:env-config|main)(?:-[^.]+)?\\.js$',
gzip: true,
gzipOptions: { level: 9 },
brotli: true,
brotliOptions: { params: { BROTLI_PARAM_QUALITY: 11 } },
zstd: true,
zstdOptions: { params: { ZSTD_c_compressionLevel: 19 } },
imageExtensions: ['.png', '.jpg', '.jpeg'],
imageFormats: { avif: { quality: 50 }, webp: { quality: 75 } },
};
yarn build here is literally nx compress ui, so "build" always means "build and compress". β
The output is main-*.js{,.gz,.br,.zst}, styles-*.css{,.gz,.br,.zst}, index.html{,.gz,.br,.zst}
and an untouched env-config-*.js.
Angular / React + webpack π§± (AngularWebpack, ReactWebpack)
Full webpack.config.js ejection, then a stack of compression-webpack-plugin instances:
- gzip, level 9
- Brotli, quality 11
- Zstandard, level 19 through a custom
algorithmbacked by Node'szlib.zstdCompress - AVIF (q50) and WebP (q75) siblings for
.png/.jpg/.jpegthroughsharp
The Zstd plugin is only added when the runtime supports it, so older Node versions still build:
const zstdPlugins =
typeof zlib.zstdCompress === 'function'
? [
new CompressionPlugin({
filename: '[path][base].zst',
algorithm(input, options, callback) {
zlib.zstdCompress(
input,
{ params: { [zlib.constants.ZSTD_c_compressionLevel]: options.level ?? 19 } },
callback,
);
},
exclude: skipRuntimeConfig,
deleteOriginalAssets: false,
test: /\.(js|css|html|svg|ttf)$/,
threshold: 1024,
minRatio: 0.8,
compressionOptions: { level: 19 },
}),
]
: [];
Original assets are always kept (deleteOriginalAssets: false) so brotli_static / gzip_static
can fall back gracefully for clients without the right Accept-Encoding.
React + Rspack π¦ (ReactRsPack)
Rspack is webpack-API compatible, so compression-webpack-plugin works out of the box. The
interesting part is how config.ts becomes its own artifact: a second entry point.
config.entry = {
'env-config': path.resolve(__dirname, 'src/config.ts'),
main: path.resolve(__dirname, 'src/main.tsx'),
};
Zstandard here needs no external binary, because Node's zlib gained native Zstd support:
new CompressionPlugin({
filename: '[path][base].zst',
algorithm(input, options, callback) {
zlib.zstdCompress(
input,
{ params: { [zlib.constants.ZSTD_c_compressionLevel]: options.level ?? 19 } },
callback,
);
},
exclude: /env-config(.*)\.js$/,
threshold: 1024,
compressionOptions: { level: 19 },
});
React / Vue + Vite π± (ReactVite, VueVite)
Vite has no scripts option, so the split is done with manualChunks plus an explicit
<script> tag in index.html:
const manualChunks: ManualChunks = (id) => {
if (id.includes('node_modules')) return 'vendor';
if (id.includes('config.ts')) return 'env-config';
};
plugins: [
compression({
threshold: 1025,
exclude: [/env-config.*\.js$/],
algorithms: [
defineAlgorithm('gzip', { level: 9 }),
defineAlgorithm('brotliCompress', {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 },
}),
defineAlgorithm('zstd', {
params: { [zlib.constants.ZSTD_c_compressionLevel]: 19 },
}),
],
}),
];
<script type="module" src="/src/config.ts"></script>
<script type="module" src="/src/main.tsx"></script>
π§Ή The post-build sweep (compress.js)
ReactRsPack, ReactVite and VueVite share the same
compress.js, wired as nx run ui:compress with dependsOn: ["build"]
(so yarn build runs it too). It walks dist/apps/ui and fills in whatever the bundler plugin
did not produce:
.gz,.brand.zstforjs/mjs/css/html/json/svgbelow the plugin's threshold- AVIF (q50) and WebP (q75) siblings for
png/jpg/jpeg - skips anything matching
env-config(.*)\.js$ - skips targets that already exist, so nothing is compressed twice
- degrades gracefully when the runtime has no Zstd support
π @adaskothebeast/esbuild-compressor
Source: github.com/AdaskoTheBeAsT/esbuild-compressor Β·
npm: @adaskothebeast/esbuild-compressor
Written because Angular's esbuild pipeline had no clean compression hook. It ships two modes:
| Mode | Use it when | What it does |
|---|---|---|
| π esbuild plugin | everything you care about passes through esbuild | adds .gz, .br and optional .zst variants to in-memory output files |
| π₯οΈ post-build CLI | Angular application builder, multi-stage output | scans the finished directory, compresses JS/CSS/HTML/JSON/SVG and generates AVIF/WebP from PNG/JPEG |
Every algorithm is a separate switch (v2.1.0+):
| Option | Default | Effect |
|---|---|---|
gzip | true | emit .gz, tuned through gzipOptions |
brotli | true | emit .br, tuned through brotliOptions.params |
zstd | false | emit .zst, tuned through zstdOptions.params (ZSTD_c_* names) |
// tools/ui-compression.config.cjs
module.exports = {
directory: 'dist/apps/ui/browser',
gzip: false, // CDN already handles gzip
brotliOptions: { params: { BROTLI_PARAM_QUALITY: 11 } },
zstd: true,
zstdOptions: { params: { ZSTD_c_compressionLevel: 22 } },
};
Zstandard uses zlib.zstdCompress, so no zstd binary is needed; on runtimes without it the
compressor warns once and skips .zst instead of failing the build.
Other options that matter: extensions, imageExtensions, imageFormats and the all-important
skipFilesPattern. π
π« Why env-config must not be pre-compressed
This is the sharpest edge of the whole setup, and every sample handles it explicitly:
- π§
env-config*.jsis rewritten at container startup. A stale.br/.gz/.zstsibling from build time would still be served bybrotli_static/gzip_static, silently shipping the wrong config. - βοΈ Hence
skipFilesPattern: "env-config.*\\.js$"in the esbuild plugin and the CLI config,exclude: /env-config(.*)\.js$/in the webpack, Rspack and Vite plugins, and the same guard incompress.js. - π
env.shmatchesenv-config.js,env-config-DgyoikIV.jsandenv-config.something.js, so output hashing stays enabled for cache busting. - π
nginx adds
Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0forenv-config(.*)\.js$only. Everything else keeps long-lived caching.
π³ Serving it: nginx-brotli
The companion image AdaskoTheBeAsT/nginx-brotli
is nginx:alpine-slim plus ngx_brotli and headers-more:
brotli on;
brotli_comp_level 11;
brotli_static on; # serve the .br file we built, zero CPU at request time
gzip on;
gzip_static on; # serve the .gz file we built
gzip_vary on;
gzip_comp_level 9;
Multi-stage Dockerfile, condensed:
################# Build #################
FROM adaskothebeast/node-build:v1.4.3 AS build
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build && yarn test && yarn lint
################# Final #################
FROM adaskothebeast/nginx-brotli:v2.0.17-slim AS deploy
WORKDIR /var/www
COPY --from=build /app/dist/apps/ui .
COPY ./.env .
RUN adduser -D -g 'www' www
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "export API_BASE_URL \
&& export APP_TENANT_ID \
&& export APP_CLIENT_ID \
&& export API_SCOPES \
&& /usr/local/bin/env.sh API APP \
&& nginx -g 'daemon off;'"]
USER www
env.sh API APP means: take every env var starting with API or APP, write them into
window._env_, and (bonus) hot-patch Content-Security-Policy in headers.conf from
CONTENT_SECURITY_POLICY. π The container runs rootless as www.
β οΈ Zstandard: smaller is not the point
To be clear: Zstandard does not beat Brotli on size for static text. Measured on
ReactWebpack's own main.js (237 932 B raw, Node 24 zlib):
| Encoding | Size | vs Brotli | Compression time |
|---|---|---|---|
| gzip level 9 | 75 242 B | +17.2% | 6 ms |
| Brotli quality 11 | 64 185 B | baseline | 228 ms |
| Zstd level 19 | 67 826 B | +5.7% | 36 ms |
| Zstd level 22 | 67 825 B | +5.7% | 40 ms |
So Brotli 11 stays the winner for pre-compressed assets; Zstd's edge is speed (roughly 6x faster
here, which matters for large builds and for on-the-fly compression), not ratio. .zst is included
as an opt-in extra, not as a Brotli replacement. On top of that, mind the deployment reality:
- π« There is no
zstd_staticin nginx. Neither mainline nginx nor thengx_brotlistack ships a module that serves pre-compressed.zstfiles the waygzip_staticandbrotli_staticdo, so thenginx-brotliimage cannot pick them automatically. - π
Content-Encoding: zstdis supported by current Chromium and Firefox, so the bytes are useful once a server hands them out. - π οΈ Options today: serve them manually in nginx (a
mapon$http_accept_encodingplustry_filesand an explicitContent-Encoding: zstdheader, rememberingVary: Accept-Encoding), put a server with native support in front (Caddy, Envoy, some CDNs), or simply skip.zst. - β
Keep gzip and Brotli as the portable baseline. Treat
.zstas a bonus artifact, which is exactly why it is opt-in in@adaskothebeast/esbuild-compressor.
π§ Zstandard prerequisites
No sample needs the zstd CLI anymore. All six use Node's built-in zlib.zstdCompress
(Node.js 22.15+ / 24+), and each one degrades to gzip + Brotli on older runtimes instead of
failing the build. That is the only prerequisite:
node --version # must be >= 22.15 (or >= 24) for .zst output
The zstd CLI is still handy for inspecting or verifying artifacts locally
(zstd -d main.js.zst -c | head), and it is required if you fall back to a shell-based pipeline.
Earlier revisions of the webpack samples shelled out to it and guarded the build with a
check-zstd.js script, which is worth keeping around for that case:
// check-zstd.js
const { exec } = require('child_process');
const os = require('os');
exec('zstd --version', (error) => {
if (!error) {
console.log('Zstd CLI is installed.');
return;
}
console.error('Zstd CLI is not installed. Please install it before proceeding:');
if (os.platform() === 'darwin') {
console.error('For macOS: brew install zstd');
} else if (os.platform() === 'linux') {
console.error('For Linux: sudo apt-get install zstd');
} else if (os.platform() === 'win32') {
console.error(
'For Windows: choco install zstandard or download from https://github.com/facebook/zstd/releases',
);
}
process.exit(1);
});
Install the CLI per platform:
| OS | Command |
|---|---|
| πͺ Windows | choco install zstandard or winget install Facebook.Zstandard, or grab a binary from zstd releases and put it on PATH |
| π§ Linux (Debian/Ubuntu) | sudo apt-get install zstd |
| π§ Linux (Fedora/RHEL) | sudo dnf install zstd |
| π§ Alpine (Docker build image) | apk add --no-cache zstd |
| π macOS | brew install zstd |
Verify with:
zstd --version
Docker note: the node-build image only needs apk add --no-cache zstd if you actually rely on the
CLI. For these samples, plain Node is enough. π
π Getting started
Node.js 22.15+ or 24+ (for native Zstd) and Yarn 4, pinned per
workspace via packageManager. No other tooling required.
Pick a sample:
cd AngularEsBuild # or AngularWebpack | ReactWebpack | ReactRsPack | ReactVite | VueVite
yarn install
yarn build # builds and produces .gz / .br / .zst plus AVIF/WebP
yarn test
yarn lint
Useful Nx targets:
nx serve ui # dev server
nx run ui:compress # post-build compression sweep (where defined)
nx e2e ui-e2e # Playwright
nx show project ui --web # inspect every target of the app
Workspaces were scaffolded with npx create-nx-workspace@latest --package-manager=yarn
and then moved to Yarn 4 (yarn set version stable).
ποΈ Layout
NXSamples/
βββ AngularEsBuild/ # Angular 22 + esbuild + @adaskothebeast/esbuild-compressor
βββ AngularWebpack/ # Angular 22 + webpack 5 (gzip/brotli/zstd/avif/webp)
βββ ReactRsPack/ # React 19 + Rspack (env-config as second entry, native zstd)
βββ ReactVite/ # React 19 + Vite 8 (manualChunks + compression2 + zstd)
βββ ReactWebpack/ # React 19 + webpack 5 (NxAppWebpackPlugin ejected)
βββ VueVite/ # Vue 3.5 + Vite 8 (manualChunks + compression2 + zstd)
Every workspace follows the same shape:
<Sample>/
βββ apps/
β βββ ui/
β β βββ src/
β β β βββ config.js|ts # π runtime configuration seed -> env-config bundle
β β β βββ types/window.d.ts # π§ typed window._env_
β β β βββ main.ts|tsx
β β βββ project.json # ποΈ build / compress / serve targets
β β βββ <bundler>.config.* # π§ the ejected config
β βββ ui-e2e/ # π Playwright
βββ compress.js | tools/*.cjs # ποΈ post-build compression (bundler dependent)
π‘ Takeaways
- π§± Eject the config. Every real deployment need (custom compression, extra entry points, chunk naming) eventually requires it. All six samples prove it stays maintainable.
- π
window._env_beats build-time env vars. One artifact, N environments, zero rebuilds. - ποΈ Compress at build time, not at request time.
brotli_staticat quality 11 costs nothing per request and typically cuts transfer by an order of magnitude. - π§ Never pre-compress the file you intend to rewrite. Skip patterns are not optional.
- πΌοΈ Ship modern image formats (AVIF, WebP) as siblings, not replacements.
- β οΈ Measure before believing. Brotli 11 compresses smaller than Zstd 19 on these bundles
(Zstd is ~6% bigger but ~6x faster), and nginx has no
zstd_staticat all, so gzip and Brotli remain the baseline and.zststays a bonus.
π Related repositories
- π³ nginx-brotli - nginx with Brotli + headers-more,
env.shruntime config injection, rootless, secure headers - ποΈ esbuild-compressor -
@adaskothebeast/esbuild-compressor, esbuild plugin + post-build directory CLI
π License
MIT. See LICENSE.