Skip to main content

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> (no is:*) and <script src="@scripts/..."> are both handled by Vite.
  • Plain <script> body is turned into a virtual module; src to 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' and import Alpine from 'alpinejs', those libraries are bundled into the same output file (or a small set of chunks) by default.
That single-bundle behavior is normal for many apps. Whether you change it depends on your goals (smaller initial bundle, CDN caching, parallel loading, and so on).

Option 1: Single bundle (default)

Demo: examples/import-bundling/single-bundlepnpm --dir examples/import-bundling/single-bundle dev What you do: Import everything in your client entry (for example client/assets/scripts/index.ts):
import aero from '@aero-js/core'
import htmx from 'htmx.org'
import Alpine from '@scripts/alpine'

htmx.config.globalViewTransitions = true
htmx.onLoad(node => Alpine.initTree(node as HTMLElement))

aero.mount({
	target: '#app',
	onRender(el) {
		htmx.process(el)
		Alpine.initTree(el)
	},
})
Result: One (or a few) hashed asset files. All of Aero runtime, htmx, Alpine, and your app code in one download.
ProsCons
Single request, simple deploymentLarger initial JS; no CDN caching for third-party libs
Tree-shaking possible for libs that support it
No ordering or global-variable concerns
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:
  1. In your layout (for example base.html), add <script> tags before your entry script so htmx and Alpine are available as globals:
<link rel="stylesheet" href="@styles/global.css" />
<script src="https://unpkg.com/htmx.org@2.0.8" defer></script>
<script src="https://unpkg.com/alpinejs@3.15.8" defer></script>
<script type="module" src="@scripts/index.ts"></script>
  1. In your entry, use the globals and optionally type them:
import aero from '@aero-js/core'

declare global {
	var htmx: typeof import('htmx.org').default
	var Alpine: import('alpinejs').Alpine
}

htmx.config.globalViewTransitions = true
htmx.onLoad(node => Alpine.initTree(node as HTMLElement))

aero.mount({
	target: '#app',
	onRender(el: HTMLElement) {
		htmx.process(el)
		Alpine.initTree(el)
	},
})
If you use Alpine plugins or stores, add their script tags before your entry and extend the 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): Mark htmx.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:
// aero.config.ts
import { defineConfig } from '@aero-js/config'

export default defineConfig({
	vite: {
		build: {
			rolldownOptions: {
				external: ['htmx.org', 'alpinejs'],
			},
		},
	},
})
In your layout, add an import map before your entry:
<link rel="stylesheet" href="@styles/global.css" />
<script type="importmap">
	{
		"imports": {
			"htmx.org": "https://unpkg.com/htmx.org@2.0.8/dist/htmx.esm.js",
			"alpinejs": "https://unpkg.com/alpinejs@3.15.8/dist/module.esm.min.js"
		}
	}
</script>
<script type="module" src="@scripts/index.ts"></script>
In your entry, use normal imports. Because Alpine’s ESM build does not auto-start, call Alpine.start() before configuring htmx:
import aero from '@aero-js/core'
import htmx from 'htmx.org'
import Alpine from 'alpinejs'

Alpine.start()
htmx.config.globalViewTransitions = true
htmx.onLoad(node => Alpine.initTree(node as HTMLElement))

aero.mount({
	target: '#app',
	onRender(el) {
		htmx.process(el)
		Alpine.initTree(el)
	},
})
When to use: When the library provides an ESM CDN build and you want CDN caching, no bundling of that lib, and to keep 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 in aero.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
import aero from '@aero-js/core'

const htmx = (await import('htmx.org')).default
const Alpine = (await import('@scripts/alpine')).default

htmx.config.globalViewTransitions = true
htmx.onLoad(node => Alpine.initTree(node as HTMLElement))

aero.mount({
	target: '#app',
	onRender(el) {
		htmx.process(el)
		Alpine.initTree(el)
	},
})
Result: Smaller main chunk; htmx and Alpine load in separate hashed chunks when the entry runs. They are still bundled by Vite unless you also use CDN options. When to use: When you want a smaller first load and are okay with bundled, lazy-loaded libs.

Comparison

ApproachBundled?Loaded how?Best when
1. Single bundleYes, everythingOne (or few) entry chunksSimplicity, one request
2. CDN + globalsNo (htmx/Alpine)Script tags then entrySmaller app bundle, CDN cache
3. ESM from CDNNo (that lib)Import map or external URLCDN + no bundle + keep import syntax
4. ExternalsNo (marked external)Script tags then entrySame as 2, with import-style typing
5. Dynamic importYes, in separate chunksEntry then lazy chunksSmaller initial chunk, no HTML changes
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 under examples/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
See examples/import-bundling README for more.