How to integrate AlpineJS?
This guide describes two ways to load AlpineJS into a Nutin application - bundled from npm, or loaded from a CDN - and three ways to use it alongside Nutin.
Load Alpine
From npm
npm install alpinejs
npm install --save-dev @types/alpinejs
Alpine ships no types of its own: without @types/alpinejs, TypeScript fails.
Alpine needs builder.esbuild.target set to es2020 (or later) in nutin.config.js.
// nutin.config.js
esbuild: {
// ...
target: ['es2020'],
},
Import Alpine in src/app/main.ts and start it yourself, as the last step of your bootstrap:
// src/app/main.ts
import Alpine from 'alpinejs';
document.addEventListener('DOMContentLoaded', async () => {
// ...
new App();
Alpine.start();
});
Import Alpine in main.ts only, not in a component nor a view file.
From a CDN
Add Alpine's CDN script to src/index.html's <head>, pinned to an exact version:
<!-- src/index.html -->
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.17.4/dist/cdn.min.js"></script>
</head>
cdn.min.js starts Alpine on its own as soon as it loads.
Note: With Nutin's docker feature, Alpine's standard builds don't run under its Content Security Policy. See With the docker feature (CSP).
With the docker feature (CSP)
The docker feature serves your app with a Content Security Policy that allows only your own scripts (script-src 'self'). Alpine's standard builds (alpinejs from npm, cdn.min.js) evaluate your expressions with the Function constructor, which that policy blocks: Alpine loads, but no directive works, and the console shows EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directive because 'unsafe-eval' is not an allowed source of script.
Use Alpine's CSP build instead, rather than adding 'unsafe-eval' to the policy (it would weaken the protection against injected scripts). Everything in this guide works with it.
From npm: install
@alpinejs/cspinstead ofalpinejs, and import it the same way:npm install @alpinejs/csp npm install --save-dev @types/alpinejs// src/app/main.ts import Alpine from '@alpinejs/csp';It has no type definitions of its own; it shares Alpine's:
// src/app/globals.d.ts declare module '@alpinejs/csp' { import Alpine from 'alpinejs'; export default Alpine; }From a CDN: load the CSP build's
cdn.min.js, and add its origin to the nginx CSP map, scoped to the version's path (script-src 'self' https://cdn.jsdelivr.net/npm/@alpinejs/csp@3.17.4/):<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/csp@3.17.4/dist/cdn.min.js"></script>Use version 3.15.0 or later: earlier CSP builds can't read inline expressions like
x-data="{ count: 0 }"or@click="count++", only components registered withAlpine.data().Expressions can't reach globals such as
document:@click="document.getElementById('menu')…"fails withUndefined variable: document. Put that code in a method of anAlpine.data()component or anAlpine.store(), and call the method from the expression. See Alpine's CSP build for what it supports.The inline
<script type="module">of Declaring x-data in main.ts is blocked too (inline scripts aren't allowed). Load Alpine from npm instead: you set#app'sx-dataand start Alpine frommain.tsthe same way.
Approach 1: Keeping Alpine separated from Nutin
This pattern keeps Alpine state completely isolated; it is never touched by Nutin, so it's safe ground for Alpine to own.
- You can mount it through Nutin's global layout service
registerGlobalsAPI. This gives the Alpine root a clear entrypoint inmain.tsalongside the rest of the app's global components:
// src/app/components/globals/alpine-root/alpine-root.component.ts
import { Component, html } from '../../../../core/index.js';
const templateFn = () => html`
<button @click="count++">Increment</button>
<span x-text="count"></span>
`;
export class AlpineRootComponent extends Component {
constructor(mountTarget: HTMLElement) {
super({ mountTarget, tagName: 'div', templateFn });
}
protected override onBeforeRender(): void {
this.element.setAttribute('x-data', '{ count: 0 }');
}
}
registerGlobals({
after: [{ component: AlpineRootComponent, id: 'alpine-root' }],
});
- A plain sibling
<div>hand-authored directly inindex.htmlstill works:
<!-- src/index.html -->
<body>
<main id="app"></main>
<div x-data="{ count: 0 }">
<button @click="count++">Increment</button>
<span x-text="count"></span>
</div>
</body>
Approach 2: Using Alpine state inside Nutin
To let @click/x-text/etc. inside component and view templates read and mutate shared Alpine state, declare x-data directly on #app itself - it is permanent for the life of the page.
With npm
- You start Alpine yourself, so you can set it from
main.ts, right beforeAlpine.start():
// src/app/main.ts
document.getElementById('app')!.setAttribute('x-data', '{ count: 0 }');
Alpine.start();
- Hand-authoring
maindirectly inindex.htmlstill works:
<!-- src/index.html -->
<main id="app" x-data="{ count: 0 }"></main>
Then, inside any component's or view's own .html template, use directives with no local
x-data — they inherit the scope from #app by normal DOM ancestry, and the state survives navigating between views:
<!-- e.g. home.view.html -->
<button @click="count++">Increment</button>
<span x-text="count"></span>
With the CDN script
Declare it in index.html, since cdn.min.js starts Alpine before main.ts ever runs:
<!-- src/index.html -->
<body>
<main id="app" x-data="{ count: 0 }"></main>
</body>
Advanced: Declaring x-data in main.ts
Above approach requires hand-editing index.html because of the auto-starting cdn.min.js - before main.ts ever runs.
Swap to Alpine's non-auto-starting ESM build to set #app's x-data from TypeScript instead.
- Replace the "Load Alpine" script tag with an inline import that doesn't call .start():
<!-- src/index.html -->
<head>
<!-- ... -->
<!-- This replaces the `cdn.min.js` script tag from "Load Alpine" -->
<!-- Use one or the other, not both. -->
<script type="module">
import Alpine from 'https://cdn.jsdelivr.net/npm/alpinejs@3.17.4/dist/module.esm.js';
window.Alpine = Alpine;
</script>
</head>
- Add it in the
Windowinterface
// src/app/globals.d.ts
declare interface Window {
Alpine: {
start(): void;
};
}
- Set x-data on #app and call window.Alpine.start() yourself as the last step of your bootstrap:
// src/app/main.ts
document.addEventListener('DOMContentLoaded', () => {
// ...
document.getElementById('app')!.setAttribute('x-data', '{ count: 0 }');
window.Alpine.start();
});
Alternative: Holding Alpine state inside a component
A component can hold its own x-data root if its parent registers it with keep (or, for catalog items, with keepBy).
When the parent re-renders, a kept child is moved into the new template as-is instead of being recreated, so its Alpine state survives.
// src/app/components/counter/counter.component.ts
import { Component, html } from '../../../core/index.js';
const templateFn = () => html`
<button @click="count++">Increment</button>
<span x-text="count"></span>
`;
export class CounterComponent extends Component {
constructor(mountTarget: HTMLElement) {
super({ mountTarget, templateFn });
}
protected override onBeforeRender(): void {
this.element.setAttribute('x-data', '{ count: 0 }');
}
}
// In the parent component or view
public registerChildren(): Nutin.ComponentConfig[] {
return [
{ selector: 'counter', keep: 'counter', factory: (el) => new CounterComponent(el) },
];
}
The same holds for catalog items kept with keepBy, as long as the item itself didn't change.
A changed item, or a child whose keep value changed, is recreated, and its state starts over.
With a11y-elements
Alpine directives work inside a11y-elements components that stay where they're written, like form controls: e.g. @change on the checkbox of an <a11y-switch>.
Overlays (<a11y-modal>, <a11y-drawer>, <a11y-dropdown>…) need a different setup: they move themselves to <body>, out of #app, so they leave its x-data scope. Directives on or inside them can't see that state any more (modal is not defined).
- Give the overlay its own
x-data, so Alpine still processes it in<body>. - Keep the state it shares with the page in an
Alpine.store(), which any component can reach as$store. - Open it through its
openattribute, from a store method: the overlay closes itself (its close button, Escape, the backdrop), so a state of your own tracking whether it's open would go stale.
// src/app/main.ts, before Alpine.start() (with the CDN script: before new App())
Alpine.store('ui', {
count: 0,
open(id: string) {
document.getElementById(id)?.setAttribute('open', '');
},
});
<!-- e.g. home.view.html -->
<button @click="$store.ui.count++">Add one</button>
<button @click="$store.ui.open('cart')">Open cart</button>
<a11y-modal id="cart" x-data>
<h2>Cart</h2>
<p x-text="'Items: ' + $store.ui.count"></p>
</a11y-modal>
The method works with both builds (see With the docker feature (CSP)). With the CDN script, Alpine is window.Alpine.
The component or view whose template contains the overlay still has to remove it on re-render and destroy, as with any overlay: see Use overlays inside components.
Important
An x-data root inside Nutin's rendered tree only survives while the element holding it is kept.
It is reset when:
- The component holding it renders again: its own
render(), or alistenToRenderEvents()event, rebuilds its content. - It isn't kept across an ancestor's re-render: it has no
keep/keepBy, or any component between it and the re-rendering ancestor has none. Such a component is recreated along with everything inside it. - It is part of a view's own template: views are destroyed on every navigation. Never declare
x-datain a view's template, only in kept components or on#app(Approach 2).