The problem
When you add client-side libraries like HTMX or Alpine.js, you need to decide how they reach the browser: bundled into one file, loaded from a CDN, or split into lazy-loaded chunks. Each approach has tradeoffs around bundle size, caching, and complexity.How Aero helps
Client-side JavaScript in Aero is processed by Vite. How you import dependencies determines whether they are bundled into your entry chunk, loaded from the network (CDN), or split into separate chunks. Aero does not prescribe one approach — you pick the strategy that fits your project. See Scripts for script types (is:build, plain <script>, is:inline, src="...") and the Server page for HTMX and Alpine.js setup patterns.
How client scripts are bundled
- Plain
<script>(nois:*) and<script src="@scripts/...">are both handled by Vite. - Plain
<script>body is turned into a virtual module;srcto a local path is treated as an entry point. In both cases, every ES module import in that file is followed by Vite and inlined (or chunked) into the build output. - If your entry does
import htmx from 'htmx.org'andimport Alpine from 'alpinejs', those libraries are bundled into the same output file (or a small set of chunks) by default.
Option 1: Single bundle (default)
Demo: examples/import-bundling/single-bundle —pnpm --dir examples/import-bundling/single-bundle dev
What you do: Import everything in your client entry (for example client/assets/scripts/index.ts):
When to use: When you prefer simplicity and are fine with the total bundle size.
Option 2: Load from the page (CDN or static), use globals
Demo: examples/import-bundling/cdn-globals What you do: Do not import htmx or Alpine in your client entry. Instead:- In your layout (for example
base.html), add<script>tags before your entry script so htmx and Alpine are available as globals:
- In your entry, use the globals and optionally type them:
declare global block; or configure them in an inline script after Alpine so they run before your entry.
Result: Your entry bundle contains only Aero and your app code. htmx and Alpine load separately and can be cached by the browser or CDN.
When to use: When you want smaller first-load JS and good cache reuse for htmx and Alpine.
Option 3: ESM from CDN (import map or external URL)
Demo: examples/import-bundling/esm-import-map Many libraries ship an ESM build on a CDN. Using that instead of the UMD or IIFE build gives CDN caching, no bundling of that lib, and native ESM in the browser. Import map (recommended): Markhtmx.org and alpinejs as external in Vite so the built entry keeps those imports. Set rolldownOptions.external in vite.config.ts:
Alpine.start() before configuring htmx:
import in your code. Prefer the import map so the CDN URL and version live in HTML.
Option 4: Externals (do not bundle, expect globals)
Demo: examples/import-bundling/cdn-externals Mark packages as externals inaero.config.ts (same rolldownOptions.external as option 3), load libraries via <script> in the layout, and use globals in the entry (as in option 2). You can keep import for types if you add a small shim, or use globals directly so the bundle does not contain unresolved imports.
Option 5: Code-splitting with dynamic import()
Demo: examples/import-bundling/dynamic-import
Comparison
In all cases, your Aero runtime and app code stay in the client entry (or its chunks). The only difference is whether htmx and Alpine are bundled with it, loaded from the page, or split into lazy-loaded bundles.
Demos in the repo
Runnable demos live underexamples/import-bundling/. From the repo root, pnpm install, then:
- Option 1:
pnpm --dir examples/import-bundling/single-bundle dev - Option 2:
pnpm --dir examples/import-bundling/cdn-globals dev - Option 3:
pnpm --dir examples/import-bundling/esm-import-map dev - Option 4:
pnpm --dir examples/import-bundling/cdn-externals dev - Option 5:
pnpm --dir examples/import-bundling/dynamic-import dev