> ## Documentation Index
> Fetch the complete documentation index at: https://aerojs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Importing and bundling

> How Vite bundles client scripts and options for htmx, Alpine, CDN loads, externals, and code splitting.

## 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](/getting-started/scripts) for script types (`is:build`, plain `<script>`, `is:inline`, `src="..."`) and the [Server](/getting-started/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-bundle](https://github.com/jamiewilson/aero/tree/main/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`):

```typescript theme={"theme":"github-dark-default"}
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.

| 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        |                                                        |

**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](https://github.com/jamiewilson/aero/tree/main/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:

```html theme={"theme":"github-dark-default"}
<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>
```

2. In your entry, use the globals and optionally type them:

```typescript theme={"theme":"github-dark-default"}
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](https://github.com/jamiewilson/aero/tree/main/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. Set `rolldownOptions.external` in `vite.config.ts`:

```typescript theme={"theme":"github-dark-default"}
// vite.config.ts
import { createViteConfig } from '@aero-js/core/vite-config'
import { mergeConfig } from 'vite'

export default mergeConfig(createViteConfig(), {
	build: {
		rolldownOptions: {
			external: ['htmx.org', 'alpinejs'],
		},
	},
})
```

In your layout, add an import map before your entry:

```html theme={"theme":"github-dark-default"}
<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:

```typescript theme={"theme":"github-dark-default"}
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](https://github.com/jamiewilson/aero/tree/main/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](https://github.com/jamiewilson/aero/tree/main/examples/import-bundling/dynamic-import)

```typescript theme={"theme":"github-dark-default"}
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

| 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 |

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](https://github.com/jamiewilson/aero/blob/main/examples/import-bundling/README.md) for more.
