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):
| Pros | Cons |
|---|---|
| Single request, simple deployment | Larger initial JS; no CDN caching for third-party libs |
| Tree-shaking possible for libs that support it | |
| No ordering or global-variable concerns |
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. Use aero.config.ts with createViteConfig(aeroConfig) 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
| Approach | Bundled? | Loaded how? | Best when |
|---|---|---|---|
| 1. Single bundle | Yes, everything | One (or few) entry chunks | Simplicity, one request |
| 2. CDN + globals | No (htmx/Alpine) | Script tags then entry | Smaller app bundle, CDN cache |
| 3. ESM from CDN | No (that lib) | Import map or external URL | CDN + no bundle + keep import syntax |
| 4. Externals | No (marked external) | Script tags then entry | Same as 2, with import-style typing |
| 5. Dynamic import | Yes, in separate chunks | Entry then lazy chunks | Smaller initial chunk, no HTML changes |
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