Bundling
Basic Bundling
Section titled “Basic Bundling”zntc --bundle entry.ts -o bundle.jsOutput Directory
Section titled “Output Directory”zntc --bundle entry.ts --outdir dist/Code Splitting
Section titled “Code Splitting”Splits dynamic imports and shared modules into separate chunks.
zntc --bundle entry.ts --splitting --outdir dist/Preserve Modules
Section titled “Preserve Modules”Preserves the original directory structure for library builds (Rollup/Rolldown compatible).
zntc --bundle src/index.ts --preserve-modules --outdir dist/zntc --bundle src/index.ts --preserve-modules --preserve-modules-root=src --outdir dist/Platforms
Section titled “Platforms”zntc --bundle entry.ts --platform=browser # Default, IIFE wrappingzntc --bundle entry.ts --platform=node # Node built-ins are externalzntc --bundle entry.ts --platform=react-native # RN presetbrowser (default)
Section titled “browser (default)”- Defaults to IIFE format when
--formatis not specified - Automatically defines
process.env.NODE_ENVas"production" - Replaces Node built-in modules with empty modules
- Automatically externalizes Node built-in modules and their subpaths
react-native
Section titled “react-native”- Automatically resolves
.native.*/.ios.*/.android.*extensions main-fields:react-native, browser, module, main- Flow is enabled automatically
External
Section titled “External”zntc --bundle entry.ts --external react --external react-domzntc --bundle entry.ts --alias:react=preact/compatIn the config, two forms are supported (esbuild / Vite compatible).
// Object form — exact + prefix matching (esbuild alias)defineConfig({ alias: { react: "preact/compat", "@/": "./src/" },});
// Array form — RegExp supported (Vite resolve.alias). build() only, not buildSync.defineConfig({ alias: [ { find: /^@\/(.*)$/, replacement: "./src/$1" }, { find: "lodash", replacement: "lodash-es" }, ],});alias is substituted unconditionally before normal resolution. To apply only on resolve failure, use fallback. See the Babel migration guide for full babel-plugin-module-resolver mapping.
Fallback
Section titled “Fallback”Compatible with webpack resolve.fallback / Metro resolver.extraNodeModules. Applied only when normal resolution fails. Mainly used to swap in browser polyfills for Node built-ins.
defineConfig({ fallback: { fs: false, // replace with empty module crypto: "crypto-browserify", stream: "stream-browserify", },});A string value re-resolves to that specifier. false substitutes an empty module.
Block List
Section titled “Block List”Compatible with Metro resolver.blockList / webpack IgnorePlugin. Matching absolute paths fail to resolve and are excluded from the bundle graph.
defineConfig({ blockList: [ /\/__mocks__\//, /\.test\.tsx?$/, "/private-internal/.*", ],});RegExp: extracts.sourceand uses it as the patternstring: treated as a regex string verbatim- Supported syntax: literals,
.*,^,$,\xescapes.|,[],(),+?,\w\dare not supported - With
platform: "react-native", Metro’s default patterns (__tests__, iOS/Android build folders, etc.) are auto-prepended; user patterns are appended after
Loader
Section titled “Loader”zntc --bundle entry.ts --loader:.png=file --loader:.svg=dataurlSupported loaders: js, jsx, ts, tsx, json, css, text, file, dataurl, base64, binary, copy, empty
--loader maps an extension to a loader. To pick the loader for a single import instead, use a Vite-style query suffix (./data.txt?raw, ./icon.png?url) — see Query suffixes.
Default loaders by extension
Section titled “Default loaders by extension”Common binary asset extensions get the file loader with no flag at all, so import logo from "./logo.png" and url(./font.woff2) work out of the box:
| Kind | Extensions |
|---|---|
| Image | .png .jpg .jpeg .jfif .pjpeg .pjp .gif .svg .ico .webp .avif .bmp |
| Font | .woff .woff2 .eot .ttf .otf |
| Media | .mp4 .webm .ogg .mp3 .wav .flac .aac .opus .mov .m4a .vtt |
| Other | .webmanifest .pdf |
Extension matching is case-insensitive, so LOGO.PNG counts too. Extensions outside this table still need an explicit --loader:.ext=type — otherwise the build fails with No loader is configured.
Assets
Section titled “Assets”CSS url()
Section titled “CSS url()”url() references in CSS go through the same asset pipeline as JS imports: the target file is emitted with a content hash and the url() is rewritten in place.
@font-face { font-family: "Inter"; src: url(./fonts/inter.woff2) format("woff2");}.bg { background: url(./img/hero.png) no-repeat;}@font-face { font-family: "Inter"; src: url("./inter-9f3c1a20.woff2") format("woff2");}.bg { background: url("./hero-a1b2c3d4.png") no-repeat;}This covers @font-face { src: url(...) }, background / background-image, border-image, cursor, mask-image, list-style-image, CSS custom properties, and image-set() / -webkit-image-set() (including the bare-string form). The rewritten URL is relative to the emitted CSS file, or prefixed with --public-path when that option is set.
?query and #fragment suffixes are preserved, so the IE9 @font-face hack keeps working:
src: url(./f.eot?#iefix); /* → url("./f-a1b2c3d4.eot?#iefix") */background: url(./i.svg#icon); /* fragment preserved */These forms are not rewritten and pass through verbatim:
| Form | Why |
|---|---|
url(#gradient) | SVG filter / gradient reference, not a file |
url(/logo.png) | Root-absolute path — the public/ convention |
url(https://…) url(//cdn…) url(data:…) url(blob:…) | External |
If an asset cannot be resolved, ZNTC emits a warning (Cannot resolve CSS url() asset — left unchanged) and leaves the original url() alone rather than failing the build — such assets are often served or copied in by a separate deploy step.
Inlining small assets
Section titled “Inlining small assets”Assets of at most --asset-inline-limit bytes (default 4096) are inlined as data: URLs instead of being emitted as separate files. 0 turns inlining off.
zntc --bundle entry.ts --outdir dist/ --asset-inline-limit=8192 # inline up to 8 KBzntc --bundle entry.ts --outdir dist/ --asset-inline-limit=0 # always emit filesInlining applies only to assets that got file from the default extension table above. These are never inlined:
- an explicit
--loader:.png=file— an explicit request wins over the implicit default - the
copyloader — “copy the original as-is” is already an explicit intent - React Native asset-registry mode — Metro’s AssetRegistry needs real file paths and
@2x/@3xscale variants
The equivalent config key is assetInlineLimit (Vite’s assetsInlineLimit).
Query suffixes (?raw / ?url / ?inline / ?worker)
Section titled “Query suffixes (?raw / ?url / ?inline / ?worker)”ZNTC understands the Vite-style query-suffix idiom, so an import can pick its own loading mode per call site — no --loader flag involved:
| Suffix | Behavior |
|---|---|
?raw | Inline the file contents as a string (the text loader) |
?url | Emit the file as an asset and export its URL string. Ignores --asset-inline-limit — you asked for a URL, so even a file below the limit stays a real file instead of turning into a data URL |
?inline | Inline as a data: URL (the dataurl loader). Always inlined, regardless of size |
?worker / ?sharedworker | Default-export a Worker constructor function — new W() gives you the Worker |
import txt from "./data.txt?raw"; // "hello raw content"import u from "./icon.png?url"; // "./icon-a1b2c3d4.png"import i from "./icon.png?inline"; // "data:image/png;base64,..."import W from "./x.worker.js?worker";const w = new W();The query is part of the module identity: the same file imported under different queries becomes different modules. import "./x.png" is an asset while import "./x.png?raw" is a string, and importing both gives you both.
?worker doesn’t introduce a separate worker mechanism — it synthesizes the standard pattern (new Worker(new URL(f, import.meta.url))) and reuses the existing Web Worker machinery, so the worker is built as its own chunk and the URL is rewritten to the final filename. ?sharedworker synthesizes new SharedWorker(...) instead.
The synthesized call does not pass { type: "module" }: zntc always emits worker entries as classic scripts (IIFE), and loading a classic bundle as a module worker would apply different semantics (strict mode, no importScripts).
Unknown queries are left alone. A specifier like ./Comp.vue?vue&type=style&lang.css passes through untouched, because that form is the established idiom for plugins handling virtual paths.
The standard alternatives keep working too, if you’d rather not depend on the query form: ?url ≈ new URL(f, import.meta.url), and ?worker ≈ new Worker(new URL(f, import.meta.url)).
Web Worker
Section titled “Web Worker”ZNTC auto-detects the new Worker(new URL("./worker.ts", import.meta.url)) pattern and emits the worker entry as a standalone IIFE bundle. No additional build configuration or entry option is needed.
const worker = new Worker(new URL("./worker.ts", import.meta.url));worker.postMessage({ task: "compute", n: 1000 });worker.onmessage = (e) => console.log(e.data);
// src/worker.tsself.onmessage = (e) => { const { task, n } = e.data; if (task === "compute") { let sum = 0; for (let i = 0; i < n; i++) sum += i; self.postMessage({ sum }); }};SharedWorker is auto-detected with the same pattern (new SharedWorker(new URL(...))).
The Vite-style import W from "./worker.ts?worker" form is supported as well — it synthesizes this same pattern and feeds it into the machinery described below, so everything in this section applies to it (Query suffixes).
Output
Section titled “Output”The worker entry is emitted as a separate chunk (not an import dependency of the main bundle). Its filename uses the fixed form <source-stem>-<crc32-hex>.js (the --chunk-names pattern does not apply here), and the new Worker(new URL(...)) call site in the main bundle is automatically rewritten to point at the built worker URL. The worker chunk’s module format is always IIFE (or CJS when bundling for a Node CJS target).
Limitations
Section titled “Limitations”- Only the exact static pattern
new Worker(new URL(...))/new SharedWorker(new URL(...))is auto-detected. These forms are not picked up:- URL stored in a variable:
const url = new URL(...); new Worker(url); - Dynamic path:
new Worker(new URL(\./${name}.ts`, import.meta.url))` - Aliased constructor:
const W = Worker; new W(new URL(...))
- URL stored in a variable:
- The second-argument options object (e.g.
{ type: "module" }) is ignored — workers are always bundled as IIFE. If you need an ESM module worker, build it as a separate entry and pass the URL manually. ServiceWorkeris not auto-detected. Build it as a separate entry and pass the resulting URL manually.
Filename Patterns
Section titled “Filename Patterns”zntc --bundle entry.ts --outdir dist/ \ --entry-names="[name]-[hash]" \ --chunk-names="chunks/[name]-[hash]" \ --asset-names="assets/[name]-[hash]"Banner / Footer / Intro / Outro
Section titled “Banner / Footer / Intro / Outro”banner / footer insert text outside the format wrapper, at the very top/bottom (license headers, shebangs). intro / outro insert text inside the wrapper, before/after the bundle code (Rollup output.intro/output.outro compatible). The difference is most visible with wrapper formats like IIFE/UMD.
zntc --bundle entry.ts -o bundle.js \ --banner:js="/* MIT License */" \ --footer:js="/* End of bundle */" \ --intro="'use strict';" \ --outro="globalThis.__BUILD_OK__ = true;"defineConfig({ banner: "/* MIT License */", footer: "/* End of bundle */", intro: "'use strict';", outro: "globalThis.__BUILD_OK__ = true;",});Metafile
Section titled “Metafile”zntc --bundle entry.ts -o bundle.js --metafile=meta.jsonzntc --bundle entry.ts -o bundle.js --analyzeUpload meta.json to Metafile Analyze to inspect output sizes, input sizes, and the import graph.
Which metric to look at, and what to do about it
Section titled “Which metric to look at, and what to do about it”| Metric | What it tells you | Action |
|---|---|---|
| bytesInOutput per chunk | Chunk size distribution | If one chunk is bloated, try --splitting or manualChunks |
| inputs[].imports | Which module imports what | Surface unintended deep imports (e.g. all of lodash) — switch to named imports |
| inputs[].bytes vs bytesInOutput | Input vs output size ratio | Ratio close to 1 means tree-shaking barely fired — check sideEffects / @__PURE__ |
| outputs[].imports | Inter-chunk dependency graph | Decide preload/prefetch priority |
| entry pointer chain | Hops from entry to first used module | Candidates for shortening initial-load critical path |
allowOverwrite
Section titled “allowOverwrite”By default ZNTC refuses output paths that would overwrite an input file. Allow it explicitly when you really intend an in-place transpile.
zntc --bundle src/index.ts -o src/index.ts --allow-overwritedefineConfig({ entryPoints: ["src/index.ts"], outfile: "src/index.ts", allowOverwrite: true,});Note: with sourcemaps enabled, overwriting in place causes the second build’s sourcemap reference to point at the first build’s output. Prefer a separate output directory whenever possible.
Minify
Section titled “Minify”zntc --bundle entry.ts -o bundle.js --minify # All three
# Granular (esbuild-compatible) — toggle individuallyzntc --bundle entry.ts -o bundle.js --minify-whitespacezntc --bundle entry.ts -o bundle.js --minify-syntaxzntc --bundle entry.ts -o bundle.js --minify-identifiersCode Dropping
Section titled “Code Dropping”zntc --bundle entry.ts --drop=console --drop=debuggerzntc --bundle entry.ts --drop-labels=DEV,TEST--drop-labels removes the whole labeled statement for matching labels. For example,
DEV: { console.log("dev only"); } is omitted when --drop-labels=DEV is set.
ES Target
Section titled “ES Target”# ES version (es2015~esnext)zntc --bundle entry.ts -o bundle.js --target=es2020
# Engine target — feature-level downlevelingzntc --bundle entry.ts -o bundle.js --target=chrome80,safari14zntc --bundle entry.ts -o bundle.js --target=node18zntc --bundle entry.ts -o bundle.js --target=hermes0.70browserslist
Section titled “browserslist”You can specify the downleveling matrix with a Browserslist query string (or string array) instead of target. When set, browserslist takes precedence over target. With platform: "react-native", the Hermes matrix is enforced and browserslist cannot be passed (ignored at runtime).
defineConfig({ browserslist: "> 0.5%, last 2 versions, not dead", // or // browserslist: ["chrome >= 80", "safari >= 14"],});The matrix is shared with CSS post-processing (Lightning CSS).
Runtime Polyfills (core-js)
Section titled “Runtime Polyfills (core-js)”--target lowers syntax; --runtime-polyfills fills runtime API gaps (Promise, Map, String.prototype.replaceAll, Array.prototype.at, structuredClone, …) with core-js. The core-js/modules/*.js the target doesn’t support are detected in the bundle graph and injected as a prelude that runs before the user entry.
bun add core-js core-js-compatzntc --bundle entry.ts -o bundle.js --target=es5 --runtime-polyfills=auto --runtime-target="ios_saf 12"Modes (auto/usage/entry/off), the runtimePolyfills config object, the @babel/preset-env useBuiltIns mapping, execution order — see → Runtime Polyfills (core-js).
Output Format
Section titled “Output Format”zntc --bundle entry.ts --format=esm # ESM (default)zntc --bundle entry.ts --format=cjs # CommonJSzntc --bundle entry.ts --format=iife --global-name=MyLib # IIFEzntc --bundle entry.ts --format=umd --global-name=MyLib # UMDzntc --bundle entry.ts --format=amd # AMDIIFE/UMD external → global mapping (globals)
Section titled “IIFE/UMD external → global mapping (globals)”Compatible with Rollup output.globals. In IIFE/UMD output, substitutes external specifiers with runtime global variables.
zntc --bundle entry.ts -o bundle.js --format=umd --global-name=MyLib \ --external react --external react-dom \ --global:react=React --global:react-dom=ReactDOMdefineConfig({ format: "umd", globalName: "MyLib", external: ["react", "react-dom"], globals: { react: "React", "react-dom": "ReactDOM" },});Watch Mode
Section titled “Watch Mode”zntc --bundle entry.ts -o bundle.js --watchzntc --bundle entry.ts -o bundle.js --watch-json # NDJSON event outputDebugging — limits worth knowing
Section titled “Debugging — limits worth knowing”The cases you most commonly trip over when bundle output isn’t what you expect.
CJS wrapper modules don’t tree-shake
Section titled “CJS wrapper modules don’t tree-shake”// my-lib (CJS)const featureA = require('./feature-a');const featureB = require('./feature-b');module.exports = { featureA, featureB };import { featureA } from 'my-lib'; // featureB is also bundledA require_X() call counts as a side effect, so the entire CJS wrap module is preserved even when the named import is unused. Either migrate the library to ESM or import the deep path directly:
import featureA from 'my-lib/feature-a'; // only what you needJSON modules are converted to ESM ASTs and do tree-shake at the named-export level.
Variables that shadow globals get auto-renamed
Section titled “Variables that shadow globals get auto-renamed”const document = createVirtualDocument();document.title = "Hi";In the bundle this becomes document$1 or similar — automatic protection against TDZ and shadowing accidents. Sourcemaps preserve the original names. Which names are protected depends on the target environment (browser / node / react-native).
Namespace re-export hurts tree-shaking precision
Section titled “Namespace re-export hurts tree-shaking precision”import * as utils from './utils';export { utils };This pattern marks every export of utils as used. Prefer explicit re-exports when you can:
export { foo, bar } from './utils';--define values must be JavaScript literals
Section titled “--define values must be JavaScript literals”# ✗ Wrong — admin becomes an identifier, producing unintended codezntc --bundle entry.ts --define:USERNAME=admin
# ✓ Right — quote it so it becomes a string literalzntc --bundle entry.ts --define:USERNAME='"admin"'
# ✓ Numbers / booleans / null are literals as-iszntc --bundle entry.ts --define:DEBUG=false --define:MAX=100Shell quoting gotcha: in bash/zsh you need to wrap the double quotes in single quotes to keep them.