Skip to main content
Alpine.js is a minimal JavaScript framework for adding interactivity to server-rendered HTML. Think of it as β€œjQuery for the modern web” or β€œTailwind for JavaScript.”

Comparison with Other Approaches

FeatureAlpineHTMXVue 3React
Bundle Size~15 KB~14 KB~34 KB~44 KB
JavaScript RequiredLightMinimalModerateHeavy
ReactivityProxy-basedNoneProxy-basedVirtual DOM
Build StepNoneNoneOptionalRequired
Learning CurveGentleGentleModerateSteep
Server DependencyMediumHighLowLow
SEOGoodExcellentNeeds SSRNeeds SSR
Progressive EnhancementGoodExcellentPoorPoor
Best ForInteractive widgetsCRUD appsInteractive appsComplex UIs
State ManagementLocal + StoresServer-sideVuex/PiniaRedux/Context
SyntaxHTML attributesHTML attributesTemplate syntaxJSX

Quick Start

mizu new ./my-alpine-app --template frontend/alpine
cd my-alpine-app
make dev

Why Alpine.js?

The Sweet Spot

Alpine occupies the perfect middle ground between vanilla JavaScript and heavy frameworks. It gives you reactive components without the build step or complexity. Vanilla JS:
document.getElementById('count').textContent = count++
Alpine:
<div x-data="{ count: 0 }">
  <span x-text="count"></span>
  <button @click="count++">Increment</button>
</div>
React (requires build step):
const [count, setCount] = useState(0)
return (
  <div>
    <span>{count}</span>
    <button onClick={() => setCount(count + 1)}>Increment</button>
  </div>
)

Key Benefits

  • Tiny: ~15kB minified and gzipped
  • No Build Step: Include via CDN and start using
  • Declarative: Write reactive code in HTML
  • Progressive: Add to existing HTML pages
  • Familiar: Syntax inspired by Vue and Angular
  • Composable: Works great with HTMX and other libraries
  • Powerful: Includes directives, magic properties, and plugins

When Alpine Shines

Alpine is perfect when you want:
  • Interactive widgets without a build step
  • Dropdowns, modals, tabs, accordions
  • Form validation and dynamic inputs
  • Progressive enhancement of server-rendered pages
  • To complement HTMX for client-side interactivity
  • Rapid development with minimal JavaScript

Installation

Via CDN

<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
Or use a specific version:
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
The defer attribute ensures Alpine loads after the DOM is ready.

Via npm

npm install alpinejs
Then import and initialize:
import Alpine from 'alpinejs'

window.Alpine = Alpine
Alpine.start()

With a Bundler

import Alpine from 'alpinejs'
import collapse from '@alpinejs/collapse'
import persist from '@alpinejs/persist'

// Register plugins
Alpine.plugin(collapse)
Alpine.plugin(persist)

// Start Alpine
window.Alpine = Alpine
Alpine.start()

Architecture

Development Mode

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      Browser (Port 3000)                     β”‚
β”‚                                                               β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚              Server-Rendered HTML                       β”‚ β”‚
β”‚  β”‚                                                          β”‚ β”‚
β”‚  β”‚  <div x-data="{ count: 0 }">                           β”‚ β”‚
β”‚  β”‚    <button @click="count++">Click</button>             β”‚ β”‚
β”‚  β”‚  </div>                                                 β”‚ β”‚
β”‚  β”‚                                                          β”‚ β”‚
β”‚  β”‚  Alpine.js initializes and attaches reactivity         β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                          β–²                                   β”‚
β”‚                          β”‚                                   β”‚
β”‚                          β”‚ Alpine.js (via CDN)              β”‚
β”‚                          β”‚                                   β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚                   Static Assets                         β”‚ β”‚
β”‚  β”‚  CSS, Images, Alpine.js library                        β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚
                          β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  Mizu Backend (Go Server)                    β”‚
β”‚                                                               β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚                      Router                             β”‚ β”‚
β”‚  β”‚                                                          β”‚ β”‚
β”‚  β”‚  GET  /              β†’ Render full HTML page           β”‚ β”‚
β”‚  β”‚  GET  /api/users     β†’ Return JSON data                β”‚ β”‚
β”‚  β”‚  POST /api/users     β†’ Process + return JSON           β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                          β”‚                                   β”‚
β”‚                          β–Ό                                   β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚                  View Engine                            β”‚ β”‚
β”‚  β”‚                                                          β”‚ β”‚
β”‚  β”‚  β€’ Templates (Go html/template)                        β”‚ β”‚
β”‚  β”‚  β€’ Layouts (default.html)                              β”‚ β”‚
β”‚  β”‚  β€’ Pages with Alpine directives                        β”‚ β”‚
β”‚  β”‚  β€’ Hot reload in dev mode                              β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                          β”‚                                   β”‚
β”‚                          β–Ό                                   β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚                 Business Logic                          β”‚ β”‚
β”‚  β”‚                                                          β”‚ β”‚
β”‚  β”‚  β€’ API Handlers (JSON responses)                       β”‚ β”‚
β”‚  β”‚  β€’ Services (UserService, AuthService)                 β”‚ β”‚
β”‚  β”‚  β€’ Database access                                      β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                          β”‚                                   β”‚
β”‚                          β–Ό                                   β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚                    Database                             β”‚ β”‚
β”‚  β”‚  PostgreSQL, SQLite, etc.                              β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Data Flow:
1. Server renders initial HTML with Alpine directives
2. Browser loads Alpine.js from CDN
3. Alpine initializes components (x-data elements)
4. User interactions trigger Alpine reactivity
5. For server data, Alpine fetches JSON via fetch/axios
6. Alpine updates DOM reactively based on state changes

Production Mode

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                         CDN / Edge                           β”‚
β”‚                                                               β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚              Static Assets (Cached)                     β”‚ β”‚
β”‚  β”‚  β€’ CSS, Images                                          β”‚ β”‚
β”‚  β”‚  β€’ Alpine.js library (cached at edge)                  β”‚ β”‚
β”‚  β”‚  β€’ Versioned and fingerprinted                         β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚
                          β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    Load Balancer                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β–Ό               β–Ό               β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Mizu Server 1β”‚ β”‚ Mizu Server 2β”‚ β”‚ Mizu Server 3β”‚
β”‚              β”‚ β”‚              β”‚ β”‚              β”‚
β”‚ Single Binaryβ”‚ β”‚ Single Binaryβ”‚ β”‚ Single Binaryβ”‚
β”‚ β€’ Templates  β”‚ β”‚ β€’ Templates  β”‚ β”‚ β€’ Templates  β”‚
β”‚ β€’ API Routes β”‚ β”‚ β€’ API Routes β”‚ β”‚ β€’ API Routes β”‚
β”‚ β€’ JSON       β”‚ β”‚ β€’ JSON       β”‚ β”‚ β€’ JSON       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
          β”‚               β”‚               β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β–Ό
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚    Database      β”‚
                β”‚  (with pooling)  β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Core Directives

x-data

Declares a new Alpine component with reactive state:
<!-- Simple data -->
<div x-data="{ open: false }">
  <button @click="open = !open">Toggle</button>
  <div x-show="open">Content</div>
</div>

<!-- Multiple properties -->
<div x-data="{ name: 'Alice', age: 25, email: '[email protected]' }">
  <p x-text="name"></p>
  <p x-text="age"></p>
  <p x-text="email"></p>
</div>

<!-- With methods -->
<div x-data="{
  count: 0,
  increment() {
    this.count++
  },
  decrement() {
    this.count--
  }
}">
  <button @click="decrement">-</button>
  <span x-text="count"></span>
  <button @click="increment">+</button>
</div>

<!-- With computed properties -->
<div x-data="{
  firstName: 'John',
  lastName: 'Doe',
  get fullName() {
    return this.firstName + ' ' + this.lastName
  }
}">
  <input x-model="firstName">
  <input x-model="lastName">
  <p x-text="fullName"></p>
</div>

x-show

Toggle visibility with CSS display:
<div x-data="{ visible: true }">
  <button @click="visible = !visible">Toggle</button>

  <!-- Element stays in DOM, display toggled -->
  <div x-show="visible">
    I'm visible!
  </div>

  <!-- With transition -->
  <div x-show="visible" x-transition>
    I fade in and out
  </div>
</div>

x-if

Conditionally add/remove element from DOM:
<div x-data="{ loggedIn: false }">
  <button @click="loggedIn = !loggedIn">Toggle Login</button>

  <!-- Element added/removed from DOM -->
  <template x-if="loggedIn">
    <div>Welcome back!</div>
  </template>

  <template x-if="!loggedIn">
    <div>Please log in</div>
  </template>
</div>
x-show vs x-if:
  • x-show: Fast toggle, element stays in DOM, uses CSS display
  • x-if: Slower, removes from DOM, better for heavy components

x-for

Loop over arrays:
<div x-data="{
  colors: ['red', 'green', 'blue']
}">
  <template x-for="color in colors" :key="color">
    <div x-text="color"></div>
  </template>
</div>

<!-- With index -->
<div x-data="{
  users: ['Alice', 'Bob', 'Charlie']
}">
  <template x-for="(user, index) in users" :key="index">
    <div>
      <span x-text="index + 1"></span>:
      <span x-text="user"></span>
    </div>
  </template>
</div>

<!-- Loop over objects -->
<div x-data="{
  user: { name: 'Alice', age: 25, email: '[email protected]' }
}">
  <template x-for="(value, key) in user" :key="key">
    <div>
      <strong x-text="key"></strong>: <span x-text="value"></span>
    </div>
  </template>
</div>

<!-- Nested loops -->
<div x-data="{
  categories: [
    { name: 'Fruits', items: ['Apple', 'Banana'] },
    { name: 'Vegetables', items: ['Carrot', 'Lettuce'] }
  ]
}">
  <template x-for="category in categories" :key="category.name">
    <div>
      <h3 x-text="category.name"></h3>
      <template x-for="item in category.items" :key="item">
        <div x-text="item"></div>
      </template>
    </div>
  </template>
</div>

x-model

Two-way data binding:
<!-- Text input -->
<div x-data="{ message: '' }">
  <input x-model="message" placeholder="Type something">
  <p>You typed: <span x-text="message"></span></p>
</div>

<!-- Number input -->
<div x-data="{ age: 25 }">
  <input type="number" x-model.number="age">
  <p>Age: <span x-text="age"></span></p>
</div>

<!-- Checkbox -->
<div x-data="{ agreed: false }">
  <label>
    <input type="checkbox" x-model="agreed">
    I agree to terms
  </label>
  <p x-show="agreed">Thank you!</p>
</div>

<!-- Multiple checkboxes -->
<div x-data="{ selected: [] }">
  <label><input type="checkbox" value="apple" x-model="selected"> Apple</label>
  <label><input type="checkbox" value="banana" x-model="selected"> Banana</label>
  <label><input type="checkbox" value="cherry" x-model="selected"> Cherry</label>

  <p>Selected: <span x-text="selected.join(', ')"></span></p>
</div>

<!-- Radio buttons -->
<div x-data="{ color: 'blue' }">
  <label><input type="radio" value="red" x-model="color"> Red</label>
  <label><input type="radio" value="green" x-model="color"> Green</label>
  <label><input type="radio" value="blue" x-model="color"> Blue</label>

  <p>Selected: <span x-text="color"></span></p>
</div>

<!-- Select dropdown -->
<div x-data="{ country: 'us' }">
  <select x-model="country">
    <option value="us">United States</option>
    <option value="ca">Canada</option>
    <option value="uk">United Kingdom</option>
  </select>

  <p>Country: <span x-text="country"></span></p>
</div>

<!-- Modifiers -->
<div x-data="{ value: '' }">
  <!-- .lazy: Update on change instead of input -->
  <input x-model.lazy="value">

  <!-- .debounce: Wait 500ms after typing -->
  <input x-model.debounce.500ms="value">

  <!-- .throttle: Max once per 1s -->
  <input x-model.throttle.1s="value">
</div>

x-on (@)

Event listeners:
<!-- Click event -->
<button @click="count++">Click me</button>

<!-- Full syntax -->
<button x-on:click="count++">Click me</button>

<!-- Multiple events -->
<div @click="handleClick" @mouseover="handleHover">
  Hover or click
</div>

<!-- Event modifiers -->
<div x-data="{ count: 0 }">
  <!-- .prevent: Prevent default -->
  <form @submit.prevent="handleSubmit">
    <button type="submit">Submit</button>
  </form>

  <!-- .stop: Stop propagation -->
  <div @click="outer">
    <button @click.stop="inner">Inner</button>
  </div>

  <!-- .outside: Trigger on clicks outside -->
  <div @click.outside="open = false">
    Click outside to close
  </div>

  <!-- .window: Listen on window -->
  <div @keyup.escape.window="open = false">
    Press ESC to close
  </div>

  <!-- .once: Only trigger once -->
  <button @click.once="runOnce">Only once</button>

  <!-- .debounce: Debounce event -->
  <input @input.debounce.500ms="search">

  <!-- .throttle: Throttle event -->
  <div @scroll.throttle.100ms="handleScroll">
    Scroll content
  </div>

  <!-- Key modifiers -->
  <input @keyup.enter="submit">
  <input @keyup.escape="cancel">
  <input @keyup.ctrl.s.prevent="save">
</div>

<!-- Access event object -->
<button @click="console.log($event)">
  Log event
</button>

<!-- Pass parameters -->
<button @click="handleClick('hello', 42)">
  Click with args
</button>

x-bind (:)

Bind attributes:
<div x-data="{ isActive: true, color: 'red' }">
  <!-- Bind class -->
  <div :class="isActive ? 'active' : 'inactive'">
    Status
  </div>

  <!-- Bind multiple classes -->
  <div :class="{
    'active': isActive,
    'highlighted': color === 'red'
  }">
    Multi-class
  </div>

  <!-- Bind style -->
  <div :style="{
    backgroundColor: color,
    fontSize: '20px'
  }">
    Styled
  </div>

  <!-- Bind any attribute -->
  <input :disabled="!isActive">
  <img :src="imageUrl" :alt="imageAlt">
  <a :href="linkUrl" :target="linkTarget">Link</a>
</div>

x-text

Set text content:
<div x-data="{ name: 'Alice' }">
  <p x-text="name"></p>
  <p x-text="'Hello, ' + name"></p>
  <p x-text="`Hello, ${name}!`"></p>
</div>

x-html

Set HTML content (use with caution):
<div x-data="{
  html: '<strong>Bold text</strong>'
}">
  <!-- Renders HTML -->
  <div x-html="html"></div>
</div>
Warning: Only use x-html with trusted content to avoid XSS attacks.

x-init

Run code when component initializes:
<div x-data="{ count: 0 }" x-init="console.log('Component initialized')">
  <p x-text="count"></p>
</div>

<!-- Fetch data on init -->
<div
  x-data="{ users: [] }"
  x-init="
    fetch('/api/users')
      .then(res => res.json())
      .then(data => users = data)
  "
>
  <template x-for="user in users" :key="user.id">
    <div x-text="user.name"></div>
  </template>
</div>

x-effect

Re-run code when dependencies change:
<div x-data="{
  firstName: 'John',
  lastName: 'Doe',
  fullName: ''
}" x-effect="fullName = firstName + ' ' + lastName">
  <input x-model="firstName">
  <input x-model="lastName">
  <p x-text="fullName"></p>
</div>

x-cloak

Hide element until Alpine initializes:
<style>
  [x-cloak] { display: none !important; }
</style>

<div x-data="{ message: 'Hello' }" x-cloak>
  <p x-text="message"></p>
</div>
Prevents flash of unstyled content (FOUC).

x-ignore

Prevent Alpine from initializing:
<div x-data="{ count: 0 }">
  <p x-text="count"></p>

  <!-- Alpine ignores this block -->
  <div x-ignore>
    <p x-text="count"></p> <!-- Won't work -->
  </div>
</div>

Magic Properties

$el

Reference the current element:
<button @click="$el.innerHTML = 'Clicked!'">
  Click me
</button>

<div x-data @click="console.log($el)">
  Log this element
</div>

$refs

Reference elements marked with x-ref:
<div x-data>
  <input x-ref="email" type="email">
  <button @click="$refs.email.focus()">
    Focus input
  </button>
</div>

<!-- Multiple refs -->
<div x-data>
  <input x-ref="name" placeholder="Name">
  <input x-ref="email" placeholder="Email">

  <button @click="
    $refs.name.value = 'John';
    $refs.email.value = '[email protected]'
  ">
    Fill form
  </button>
</div>

$watch

Watch for property changes:
<div x-data="{
  count: 0
}" x-init="
  $watch('count', value => {
    console.log('Count changed to:', value)
  })
">
  <button @click="count++">Increment</button>
  <p x-text="count"></p>
</div>

<!-- Watch nested properties -->
<div x-data="{
  user: { name: 'Alice', age: 25 }
}" x-init="
  $watch('user.age', value => {
    console.log('Age changed to:', value)
  })
">
  <input x-model="user.age" type="number">
</div>

$dispatch

Dispatch custom events:
<!-- Dispatch event -->
<div x-data @click="$dispatch('custom-event', { foo: 'bar' })">
  Dispatch event
</div>

<!-- Listen for event -->
<div @custom-event.window="console.log($event.detail)">
  Listening...
</div>

<!-- Component communication -->
<div x-data>
  <button @click="$dispatch('notify', { message: 'Hello!' })">
    Notify
  </button>
</div>

<div x-data @notify.window="alert($event.detail.message)">
  Listener
</div>

$nextTick

Wait for DOM updates:
<div x-data="{ show: false }">
  <button @click="
    show = true;
    $nextTick(() => {
      $refs.input.focus()
    })
  ">
    Show input
  </button>

  <input x-show="show" x-ref="input">
</div>

$root

Reference root element of component:
<div x-data id="app">
  <button @click="console.log($root)">
    Log root
  </button>

  <div>
    <button @click="console.log($root === document.getElementById('app'))">
      Check root
    </button>
  </div>
</div>

$data

Reference component data:
<div x-data="{ name: 'Alice', age: 25 }">
  <button @click="console.log($data)">
    Log data: { name: 'Alice', age: 25 }
  </button>
</div>

$id

Generate unique IDs:
<div x-data>
  <input :id="$id('text-input')" type="text">
  <label :for="$id('text-input')">Name</label>
</div>

$store

Access global stores (covered in Stores section).

Advanced Patterns

Reusable Components

Extract components into functions:
document.addEventListener('alpine:init', () => {
  Alpine.data('dropdown', () => ({
    open: false,
    toggle() {
      this.open = !this.open
    },
    close() {
      this.open = false
    }
  }))

  Alpine.data('counter', (initial = 0) => ({
    count: initial,
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    }
  }))
})
Use in HTML:
<!-- Dropdown -->
<div x-data="dropdown" @click.outside="close">
  <button @click="toggle">Menu</button>
  <div x-show="open" x-transition>
    <a href="/profile">Profile</a>
    <a href="/settings">Settings</a>
  </div>
</div>

<!-- Counter -->
<div x-data="counter(10)">
  <button @click="decrement">-</button>
  <span x-text="count"></span>
  <button @click="increment">+</button>
</div>

Stores (Global State)

Create global reactive stores:
document.addEventListener('alpine:init', () => {
  Alpine.store('auth', {
    user: null,
    loggedIn: false,

    login(user) {
      this.user = user
      this.loggedIn = true
    },

    logout() {
      this.user = null
      this.loggedIn = false
    }
  })

  Alpine.store('cart', {
    items: [],

    add(item) {
      this.items.push(item)
    },

    remove(index) {
      this.items.splice(index, 1)
    },

    get total() {
      return this.items.reduce((sum, item) => sum + item.price, 0)
    }
  })
})
Access in components:
<!-- Auth store -->
<div x-data>
  <template x-if="$store.auth.loggedIn">
    <div>
      <p>Welcome, <span x-text="$store.auth.user.name"></span>!</p>
      <button @click="$store.auth.logout()">Logout</button>
    </div>
  </template>

  <template x-if="!$store.auth.loggedIn">
    <button @click="$store.auth.login({ name: 'Alice' })">
      Login
    </button>
  </template>
</div>

<!-- Cart store -->
<div x-data>
  <p>Items: <span x-text="$store.cart.items.length"></span></p>
  <p>Total: $<span x-text="$store.cart.total"></span></p>

  <button @click="$store.cart.add({ name: 'Widget', price: 9.99 })">
    Add to cart
  </button>

  <template x-for="(item, index) in $store.cart.items" :key="index">
    <div>
      <span x-text="item.name"></span> - $<span x-text="item.price"></span>
      <button @click="$store.cart.remove(index)">Remove</button>
    </div>
  </template>
</div>

Transitions

Built-in transition directives:
<div x-data="{ open: false }">
  <button @click="open = !open">Toggle</button>

  <!-- Default transition -->
  <div x-show="open" x-transition>
    Fades in and out
  </div>

  <!-- Custom duration -->
  <div x-show="open" x-transition.duration.500ms>
    Slower fade
  </div>

  <!-- Different in/out -->
  <div x-show="open"
       x-transition:enter.duration.300ms
       x-transition:leave.duration.500ms>
    Fast in, slow out
  </div>

  <!-- Scale transition -->
  <div x-show="open"
       x-transition:enter="transition ease-out duration-300"
       x-transition:enter-start="opacity-0 transform scale-90"
       x-transition:enter-end="opacity-100 transform scale-100"
       x-transition:leave="transition ease-in duration-200"
       x-transition:leave-start="opacity-100 transform scale-100"
       x-transition:leave-end="opacity-0 transform scale-90">
    Scale transition
  </div>
</div>

Fetching Data

<div x-data="{
  users: [],
  loading: false,
  error: null,

  async fetchUsers() {
    this.loading = true
    this.error = null

    try {
      const response = await fetch('/api/users')
      if (!response.ok) throw new Error('Failed to fetch')
      this.users = await response.json()
    } catch (err) {
      this.error = err.message
    } finally {
      this.loading = false
    }
  }
}" x-init="fetchUsers()">

  <!-- Loading state -->
  <div x-show="loading">Loading...</div>

  <!-- Error state -->
  <div x-show="error" x-text="'Error: ' + error"></div>

  <!-- Data -->
  <div x-show="!loading && !error">
    <template x-for="user in users" :key="user.id">
      <div x-text="user.name"></div>
    </template>
  </div>

  <button @click="fetchUsers">Refresh</button>
</div>

Form Handling

<div x-data="{
  form: {
    name: '',
    email: '',
    message: ''
  },
  errors: {},
  submitting: false,
  success: false,

  validate() {
    this.errors = {}

    if (!this.form.name) {
      this.errors.name = 'Name is required'
    }

    if (!this.form.email) {
      this.errors.email = 'Email is required'
    } else if (!this.form.email.includes('@')) {
      this.errors.email = 'Invalid email'
    }

    if (!this.form.message) {
      this.errors.message = 'Message is required'
    }

    return Object.keys(this.errors).length === 0
  },

  async submit() {
    if (!this.validate()) return

    this.submitting = true
    this.success = false

    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(this.form)
      })

      if (!response.ok) throw new Error('Failed to submit')

      this.success = true
      this.form = { name: '', email: '', message: '' }
    } catch (err) {
      this.errors.submit = err.message
    } finally {
      this.submitting = false
    }
  }
}">
  <form @submit.prevent="submit">
    <div>
      <label>Name</label>
      <input x-model="form.name" @blur="validate">
      <p x-show="errors.name" x-text="errors.name" class="error"></p>
    </div>

    <div>
      <label>Email</label>
      <input x-model="form.email" type="email" @blur="validate">
      <p x-show="errors.email" x-text="errors.email" class="error"></p>
    </div>

    <div>
      <label>Message</label>
      <textarea x-model="form.message" @blur="validate"></textarea>
      <p x-show="errors.message" x-text="errors.message" class="error"></p>
    </div>

    <button type="submit" :disabled="submitting">
      <span x-text="submitting ? 'Sending...' : 'Send'"></span>
    </button>

    <p x-show="success" class="success">Message sent!</p>
    <p x-show="errors.submit" x-text="errors.submit" class="error"></p>
  </form>
</div>

Common Components

<div x-data="{ open: false }" @click.outside="open = false" class="relative">
  <button @click="open = !open" class="btn">
    Menu
  </button>

  <div
    x-show="open"
    x-transition
    class="absolute mt-2 w-48 bg-white rounded shadow-lg"
  >
    <a href="/profile" class="block px-4 py-2 hover:bg-gray-100">Profile</a>
    <a href="/settings" class="block px-4 py-2 hover:bg-gray-100">Settings</a>
    <a href="/logout" class="block px-4 py-2 hover:bg-gray-100">Logout</a>
  </div>
</div>
<div x-data="{ open: false }">
  <button @click="open = true">Open Modal</button>

  <!-- Modal backdrop -->
  <div
    x-show="open"
    @click="open = false"
    x-transition:enter="transition ease-out duration-300"
    x-transition:enter-start="opacity-0"
    x-transition:enter-end="opacity-100"
    x-transition:leave="transition ease-in duration-200"
    x-transition:leave-start="opacity-100"
    x-transition:leave-end="opacity-0"
    class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center"
  >
    <!-- Modal content -->
    <div
      @click.stop
      x-transition:enter="transition ease-out duration-300"
      x-transition:enter-start="opacity-0 transform scale-90"
      x-transition:enter-end="opacity-100 transform scale-100"
      x-transition:leave="transition ease-in duration-200"
      x-transition:leave-start="opacity-100 transform scale-100"
      x-transition:leave-end="opacity-0 transform scale-90"
      class="bg-white rounded-lg p-6 max-w-md w-full"
    >
      <h2 class="text-xl font-bold mb-4">Modal Title</h2>
      <p class="mb-4">Modal content goes here.</p>
      <button @click="open = false" class="btn">Close</button>
    </div>
  </div>
</div>

Tabs

<div x-data="{ activeTab: 'tab1' }">
  <div class="border-b">
    <button
      @click="activeTab = 'tab1'"
      :class="{ 'border-blue-500': activeTab === 'tab1' }"
      class="px-4 py-2 border-b-2"
    >
      Tab 1
    </button>
    <button
      @click="activeTab = 'tab2'"
      :class="{ 'border-blue-500': activeTab === 'tab2' }"
      class="px-4 py-2 border-b-2"
    >
      Tab 2
    </button>
    <button
      @click="activeTab = 'tab3'"
      :class="{ 'border-blue-500': activeTab === 'tab3' }"
      class="px-4 py-2 border-b-2"
    >
      Tab 3
    </button>
  </div>

  <div class="p-4">
    <div x-show="activeTab === 'tab1'" x-transition>
      Tab 1 content
    </div>
    <div x-show="activeTab === 'tab2'" x-transition>
      Tab 2 content
    </div>
    <div x-show="activeTab === 'tab3'" x-transition>
      Tab 3 content
    </div>
  </div>
</div>

Accordion

<div x-data="{
  openItems: []
}">
  <!-- Item 1 -->
  <div class="border-b">
    <button
      @click="openItems.includes(1)
        ? openItems = openItems.filter(i => i !== 1)
        : openItems.push(1)"
      class="w-full px-4 py-2 text-left flex justify-between items-center"
    >
      <span>Item 1</span>
      <span x-text="openItems.includes(1) ? '-' : '+'"></span>
    </button>
    <div x-show="openItems.includes(1)" x-collapse>
      <div class="p-4">Content for item 1</div>
    </div>
  </div>

  <!-- Item 2 -->
  <div class="border-b">
    <button
      @click="openItems.includes(2)
        ? openItems = openItems.filter(i => i !== 2)
        : openItems.push(2)"
      class="w-full px-4 py-2 text-left flex justify-between items-center"
    >
      <span>Item 2</span>
      <span x-text="openItems.includes(2) ? '-' : '+'"></span>
    </button>
    <div x-show="openItems.includes(2)" x-collapse>
      <div class="p-4">Content for item 2</div>
    </div>
  </div>
</div>

Tooltip

<div x-data="{ show: false }" class="relative inline-block">
  <button
    @mouseenter="show = true"
    @mouseleave="show = false"
  >
    Hover me
  </button>

  <div
    x-show="show"
    x-transition
    class="absolute bottom-full mb-2 px-2 py-1 bg-gray-800 text-white text-sm rounded"
  >
    Tooltip text
  </div>
</div>

Notification Toast

<div x-data="{
  notifications: [],

  notify(message, type = 'info') {
    const id = Date.now()
    this.notifications.push({ id, message, type })

    setTimeout(() => {
      this.notifications = this.notifications.filter(n => n.id !== id)
    }, 5000)
  }
}">
  <button @click="notify('Success message', 'success')">
    Show notification
  </button>

  <!-- Notification container -->
  <div class="fixed top-4 right-4 space-y-2">
    <template x-for="notification in notifications" :key="notification.id">
      <div
        x-transition:enter="transition ease-out duration-300"
        x-transition:enter-start="opacity-0 transform translate-x-8"
        x-transition:enter-end="opacity-100 transform translate-x-0"
        x-transition:leave="transition ease-in duration-200"
        x-transition:leave-start="opacity-100"
        x-transition:leave-end="opacity-0"
        :class="{
          'bg-blue-500': notification.type === 'info',
          'bg-green-500': notification.type === 'success',
          'bg-red-500': notification.type === 'error'
        }"
        class="px-4 py-2 rounded text-white shadow-lg"
      >
        <span x-text="notification.message"></span>
      </div>
    </template>
  </div>
</div>

Autocomplete

<div x-data="{
  query: '',
  suggestions: [],
  showSuggestions: false,

  async search() {
    if (this.query.length < 2) {
      this.suggestions = []
      return
    }

    const response = await fetch(`/api/search?q=${this.query}`)
    this.suggestions = await response.json()
    this.showSuggestions = true
  },

  select(suggestion) {
    this.query = suggestion
    this.showSuggestions = false
  }
}" @click.outside="showSuggestions = false">
  <div class="relative">
    <input
      x-model="query"
      @input.debounce.300ms="search"
      @focus="showSuggestions = true"
      placeholder="Search..."
      class="w-full px-4 py-2 border rounded"
    >

    <div
      x-show="showSuggestions && suggestions.length > 0"
      x-transition
      class="absolute w-full mt-1 bg-white border rounded shadow-lg max-h-60 overflow-auto"
    >
      <template x-for="suggestion in suggestions" :key="suggestion">
        <button
          @click="select(suggestion)"
          x-text="suggestion"
          class="block w-full text-left px-4 py-2 hover:bg-gray-100"
        ></button>
      </template>
    </div>
  </div>
</div>

With Mizu Backend

API Integration

Backend handler:
func handleUsers(c *mizu.Ctx) error {
    users := []map[string]any{
        {"id": 1, "name": "Alice", "email": "[email protected]"},
        {"id": 2, "name": "Bob", "email": "[email protected]"},
    }
    return c.JSON(200, users)
}

func handleCreateUser(c *mizu.Ctx) error {
    var user struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }

    if err := c.BindJSON(&user); err != nil {
        return c.JSON(400, map[string]string{"error": "Invalid request"})
    }

    // Validate
    if user.Name == "" || user.Email == "" {
        return c.JSON(400, map[string]string{"error": "Name and email required"})
    }

    // Create user...
    newUser := map[string]any{
        "id":    3,
        "name":  user.Name,
        "email": user.Email,
    }

    return c.JSON(201, newUser)
}
Alpine frontend:
<div x-data="{
  users: [],
  loading: false,
  error: null,

  form: {
    name: '',
    email: ''
  },

  async fetchUsers() {
    this.loading = true
    this.error = null

    try {
      const response = await fetch('/api/users')
      if (!response.ok) throw new Error('Failed to fetch')
      this.users = await response.json()
    } catch (err) {
      this.error = err.message
    } finally {
      this.loading = false
    }
  },

  async createUser() {
    this.loading = true
    this.error = null

    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(this.form)
      })

      if (!response.ok) {
        const data = await response.json()
        throw new Error(data.error || 'Failed to create')
      }

      const newUser = await response.json()
      this.users.push(newUser)
      this.form = { name: '', email: '' }
    } catch (err) {
      this.error = err.message
    } finally {
      this.loading = false
    }
  }
}" x-init="fetchUsers()">

  <!-- Create form -->
  <form @submit.prevent="createUser" class="mb-4">
    <input x-model="form.name" placeholder="Name" required>
    <input x-model="form.email" type="email" placeholder="Email" required>
    <button type="submit" :disabled="loading">Create</button>
  </form>

  <!-- Error message -->
  <div x-show="error" x-text="error" class="error"></div>

  <!-- Loading state -->
  <div x-show="loading">Loading...</div>

  <!-- User list -->
  <div x-show="!loading">
    <template x-for="user in users" :key="user.id">
      <div>
        <span x-text="user.name"></span> - <span x-text="user.email"></span>
      </div>
    </template>
  </div>
</div>

With HTMX

Alpine and HTMX work great together. Use HTMX for server interactions and Alpine for client-side UI state:
<script src="https://unpkg.com/[email protected]"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

<!-- Alpine controls visibility, HTMX fetches data -->
<div x-data="{ open: false }">
  <button @click="open = !open">Toggle</button>

  <div x-show="open" x-transition>
    <div hx-get="/users" hx-trigger="revealed once">
      Loading users...
    </div>
  </div>
</div>

<!-- Alpine for edit mode, HTMX for saving -->
<div x-data="{ editing: false }">
  <!-- View mode -->
  <div x-show="!editing">
    <span>Alice</span>
    <button @click="editing = true">Edit</button>
  </div>

  <!-- Edit mode with HTMX -->
  <div x-show="editing">
    <form
      hx-put="/users/1"
      hx-target="#user-1"
      @htmx:after-request="editing = false"
    >
      <input name="name" value="Alice">
      <button type="submit">Save</button>
      <button type="button" @click="editing = false">Cancel</button>
    </form>
  </div>
</div>

<!-- Alpine dropdown with HTMX actions -->
<div x-data="{ open: false }" @click.away="open = false">
  <button @click="open = !open">Actions</button>

  <div x-show="open" x-transition>
    <button hx-get="/edit" hx-target="#main">Edit</button>
    <button hx-delete="/users/1" hx-confirm="Delete?">Delete</button>
  </div>
</div>

Alpine Plugins

Persist

Persist state to localStorage:
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

<div x-data="{
  count: $persist(0),
  name: $persist('Guest').as('username')
}">
  <button @click="count++">Count: <span x-text="count"></span></button>
  <input x-model="name">
  <p>Hello, <span x-text="name"></span></p>
</div>
Values persist across page reloads.

Collapse

Smooth height transitions:
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

<div x-data="{ open: false }">
  <button @click="open = !open">Toggle</button>

  <!-- Animates height smoothly -->
  <div x-show="open" x-collapse>
    <p>Content that expands and collapses smoothly</p>
  </div>
</div>

Focus

Manage focus within elements:
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

<div x-data="{ open: false }" x-trap="open">
  <button @click="open = true">Open Modal</button>

  <div x-show="open">
    <!-- Focus trapped within modal when open -->
    <input type="text">
    <button @click="open = false">Close</button>
  </div>
</div>

Intersect

Trigger when element enters viewport:
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

<div x-data="{ shown: false }" x-intersect="shown = true">
  <div x-show="shown" x-transition>
    Appears when scrolled into view
  </div>
</div>

<!-- Lazy load images -->
<img
  x-data
  x-intersect.once="$el.src = '/image.jpg'"
  src="/placeholder.jpg"
>

Morph

Morph DOM elements (useful with HTMX):
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

<div x-data="{ message: 'Hello' }">
  <button @click="Alpine.morph($refs.message, '<p>Goodbye</p>')">
    Morph
  </button>

  <div x-ref="message">
    <p x-text="message"></p>
  </div>
</div>

Complete Application Example

Let’s build a complete Todo app with Mizu backend and Alpine frontend.

Backend

app/server/handlers.go:
package server

import (
    "sync"
    "time"

    "github.com/go-mizu/mizu"
)

type Todo struct {
    ID        int       `json:"id"`
    Text      string    `json:"text"`
    Completed bool      `json:"completed"`
    CreatedAt time.Time `json:"created_at"`
}

type TodoService struct {
    todos  []*Todo
    nextID int
    mu     sync.RWMutex
}

func NewTodoService() *TodoService {
    return &TodoService{
        todos:  []*Todo{},
        nextID: 1,
    }
}

func (s *TodoService) List(filter string) []*Todo {
    s.mu.RLock()
    defer s.mu.RUnlock()

    if filter == "" {
        return s.todos
    }

    var result []*Todo
    for _, todo := range s.todos {
        if filter == "active" && !todo.Completed {
            result = append(result, todo)
        } else if filter == "completed" && todo.Completed {
            result = append(result, todo)
        }
    }
    return result
}

func (s *TodoService) Create(text string) *Todo {
    s.mu.Lock()
    defer s.mu.Unlock()

    todo := &Todo{
        ID:        s.nextID,
        Text:      text,
        Completed: false,
        CreatedAt: time.Now(),
    }

    s.todos = append(s.todos, todo)
    s.nextID++

    return todo
}

func (s *TodoService) Toggle(id int) (*Todo, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()

    for _, todo := range s.todos {
        if todo.ID == id {
            todo.Completed = !todo.Completed
            return todo, true
        }
    }
    return nil, false
}

func (s *TodoService) Delete(id int) bool {
    s.mu.Lock()
    defer s.mu.Unlock()

    for i, todo := range s.todos {
        if todo.ID == id {
            s.todos = append(s.todos[:i], s.todos[i+1:]...)
            return true
        }
    }
    return false
}

func (h *Handlers) ListTodos(c *mizu.Ctx) error {
    filter := c.Query("filter")
    todos := h.todoService.List(filter)
    return c.JSON(200, todos)
}

func (h *Handlers) CreateTodo(c *mizu.Ctx) error {
    var req struct {
        Text string `json:"text"`
    }

    if err := c.BindJSON(&req); err != nil {
        return c.JSON(400, map[string]string{"error": "Invalid request"})
    }

    if req.Text == "" {
        return c.JSON(400, map[string]string{"error": "Text is required"})
    }

    todo := h.todoService.Create(req.Text)
    return c.JSON(201, todo)
}

func (h *Handlers) ToggleTodo(c *mizu.Ctx) error {
    id := c.ParamInt("id")

    todo, ok := h.todoService.Toggle(id)
    if !ok {
        return c.JSON(404, map[string]string{"error": "Todo not found"})
    }

    return c.JSON(200, todo)
}

func (h *Handlers) DeleteTodo(c *mizu.Ctx) error {
    id := c.ParamInt("id")

    if !h.todoService.Delete(id) {
        return c.JSON(404, map[string]string{"error": "Todo not found"})
    }

    return c.HTML(204, "")
}
app/server/routes.go:
app.Get("/api/todos", h.ListTodos)
app.Post("/api/todos", h.CreateTodo)
app.Patch("/api/todos/{id}/toggle", h.ToggleTodo)
app.Delete("/api/todos/{id}", h.DeleteTodo)

Frontend

views/pages/home.html:
<div x-data="todoApp()" x-init="fetchTodos()" class="max-w-2xl mx-auto p-4">
  <h1 class="text-3xl font-bold mb-6">Todo App</h1>

  <!-- Create form -->
  <form @submit.prevent="createTodo" class="mb-6">
    <div class="flex gap-2">
      <input
        x-model="newTodo"
        @keyup.escape="newTodo = ''"
        placeholder="What needs to be done?"
        class="flex-1 px-4 py-2 border rounded"
      >
      <button
        type="submit"
        :disabled="!newTodo.trim() || creating"
        class="px-6 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
      >
        <span x-text="creating ? 'Adding...' : 'Add'"></span>
      </button>
    </div>
    <p x-show="error" x-text="error" class="mt-2 text-red-600"></p>
  </form>

  <!-- Filters -->
  <div class="flex gap-2 mb-4">
    <button
      @click="filter = 'all'; fetchTodos()"
      :class="{ 'bg-blue-600 text-white': filter === 'all' }"
      class="px-4 py-2 border rounded"
    >
      All (<span x-text="stats.total"></span>)
    </button>
    <button
      @click="filter = 'active'; fetchTodos()"
      :class="{ 'bg-blue-600 text-white': filter === 'active' }"
      class="px-4 py-2 border rounded"
    >
      Active (<span x-text="stats.active"></span>)
    </button>
    <button
      @click="filter = 'completed'; fetchTodos()"
      :class="{ 'bg-blue-600 text-white': filter === 'completed' }"
      class="px-4 py-2 border rounded"
    >
      Completed (<span x-text="stats.completed"></span>)
    </button>
  </div>

  <!-- Loading state -->
  <div x-show="loading" class="text-center py-8">
    Loading todos...
  </div>

  <!-- Todo list -->
  <div x-show="!loading">
    <template x-if="todos.length === 0">
      <p class="text-center py-8 text-gray-500">
        No todos found. Add one above!
      </p>
    </template>

    <ul class="space-y-2">
      <template x-for="todo in todos" :key="todo.id">
        <li
          class="flex items-center gap-3 p-3 bg-white border rounded"
          x-transition:enter="transition ease-out duration-300"
          x-transition:enter-start="opacity-0 transform scale-95"
          x-transition:enter-end="opacity-100 transform scale-100"
        >
          <input
            type="checkbox"
            :checked="todo.completed"
            @change="toggleTodo(todo.id)"
            class="w-5 h-5"
          >
          <span
            :class="{ 'line-through text-gray-500': todo.completed }"
            x-text="todo.text"
            class="flex-1"
          ></span>
          <button
            @click="deleteTodo(todo.id)"
            class="text-red-600 hover:text-red-800"
          >
            Delete
          </button>
        </li>
      </template>
    </ul>
  </div>
</div>

<script>
function todoApp() {
  return {
    todos: [],
    newTodo: '',
    filter: 'all',
    loading: false,
    creating: false,
    error: null,

    get stats() {
      return {
        total: this.todos.length,
        active: this.todos.filter(t => !t.completed).length,
        completed: this.todos.filter(t => t.completed).length
      }
    },

    async fetchTodos() {
      this.loading = true
      this.error = null

      try {
        const filterParam = this.filter === 'all' ? '' : `?filter=${this.filter}`
        const response = await fetch(`/api/todos${filterParam}`)

        if (!response.ok) throw new Error('Failed to fetch todos')

        this.todos = await response.json()
      } catch (err) {
        this.error = err.message
      } finally {
        this.loading = false
      }
    },

    async createTodo() {
      if (!this.newTodo.trim()) return

      this.creating = true
      this.error = null

      try {
        const response = await fetch('/api/todos', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ text: this.newTodo })
        })

        if (!response.ok) {
          const data = await response.json()
          throw new Error(data.error || 'Failed to create todo')
        }

        const todo = await response.json()
        this.todos.unshift(todo)
        this.newTodo = ''
      } catch (err) {
        this.error = err.message
      } finally {
        this.creating = false
      }
    },

    async toggleTodo(id) {
      try {
        const response = await fetch(`/api/todos/${id}/toggle`, {
          method: 'PATCH'
        })

        if (!response.ok) throw new Error('Failed to toggle todo')

        const updated = await response.json()
        const index = this.todos.findIndex(t => t.id === id)
        if (index !== -1) {
          this.todos[index] = updated
        }
      } catch (err) {
        this.error = err.message
      }
    },

    async deleteTodo(id) {
      if (!confirm('Delete this todo?')) return

      try {
        const response = await fetch(`/api/todos/${id}`, {
          method: 'DELETE'
        })

        if (!response.ok) throw new Error('Failed to delete todo')

        this.todos = this.todos.filter(t => t.id !== id)
      } catch (err) {
        this.error = err.message
      }
    }
  }
}
</script>

Performance

Lazy Initialization

Defer Alpine initialization for better initial load:
<!-- Load Alpine deferred -->
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

<!-- Or manually control initialization -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
<script>
  // Do some work first

  // Then start Alpine when ready
  Alpine.start()
</script>

Debouncing and Throttling

Reduce unnecessary updates:
<!-- Debounce: Wait for user to stop typing -->
<input x-model.debounce.500ms="search">

<!-- Throttle: Max once per interval -->
<div @scroll.throttle.100ms="handleScroll">
  Scroll content
</div>

Virtual Scrolling

For very long lists, use virtual scrolling (requires plugin or manual implementation).

Minimize Re-renders

Use x-show instead of x-if when toggling frequently:
<!-- Fast toggle, stays in DOM -->
<div x-show="visible">Content</div>

<!-- Slower, removed from DOM -->
<template x-if="visible">
  <div>Content</div>
</template>

Security

XSS Protection

Never use x-html with user content:
<!-- Safe: text is escaped -->
<div x-text="userInput"></div>

<!-- Dangerous: HTML is rendered -->
<div x-html="userInput"></div>

CSRF Protection

Include CSRF tokens in requests:
<div x-data="{
  async submit() {
    const token = document.querySelector('[name=csrf_token]').value

    await fetch('/api/data', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': token
      },
      body: JSON.stringify(this.data)
    })
  }
}">
  <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
  <button @click="submit">Submit</button>
</div>

Sanitize User Input

Always validate and sanitize on the server:
func handleCreate(c *mizu.Ctx) error {
    var req struct {
        Text string `json:"text"`
    }

    if err := c.BindJSON(&req); err != nil {
        return c.JSON(400, map[string]string{"error": "Invalid request"})
    }

    // Validate
    if req.Text == "" {
        return c.JSON(400, map[string]string{"error": "Text required"})
    }

    // Sanitize
    text := html.EscapeString(req.Text)

    // Process...
}

Testing

Unit Tests

import Alpine from 'alpinejs'
import { expect, test } from 'vitest'

test('counter increments', async () => {
  document.body.innerHTML = `
    <div x-data="{ count: 0 }">
      <button @click="count++">Increment</button>
      <span x-text="count"></span>
    </div>
  `

  Alpine.start()

  const button = document.querySelector('button')
  const span = document.querySelector('span')

  expect(span.textContent).toBe('0')

  button.click()
  await Alpine.nextTick()

  expect(span.textContent).toBe('1')
})

E2E Tests

test('todo app works', async ({ page }) => {
  await page.goto('http://localhost:3000')

  // Add todo
  await page.fill('input[placeholder*="What needs"]', 'Buy milk')
  await page.click('button:has-text("Add")')

  // Check it appears
  await expect(page.locator('text=Buy milk')).toBeVisible()

  // Toggle completion
  await page.click('input[type="checkbox"]')
  await expect(page.locator('text=Buy milk')).toHaveClass(/line-through/)

  // Delete
  await page.click('button:has-text("Delete")')
  await page.click('button:has-text("OK")') // Confirm
  await expect(page.locator('text=Buy milk')).not.toBeVisible()
})

Troubleshooting

Alpine Not Working

Check:
  1. Alpine script is loaded: <script defer src="..."></script>
  2. Using defer attribute
  3. No JavaScript errors in console
  4. x-data is on parent element

Data Not Updating

Check:
  1. Data property is reactive (defined in x-data)
  2. Modifying data correctly (this.count++ not count++)
  3. No typos in property names
  4. Using Alpine’s reactivity (not vanilla JS)

Events Not Firing

Check:
  1. Event name is correct (@click not @onclick)
  2. Element can receive events
  3. No @click.stop preventing propagation
  4. Handler function exists

Transitions Not Working

Check:
  1. Element uses x-show not x-if (or use x-transition on template parent)
  2. Tailwind classes available
  3. No conflicting CSS

Debug Mode

Enable Alpine devtools:
<script>
  window.Alpine = Alpine
  Alpine.start()
</script>
Then use browser devtools to inspect window.Alpine.

When to Use Alpine

Perfect For

  • Interactive Widgets: Dropdowns, modals, tabs, accordions
  • Form Enhancement: Validation, dynamic inputs, autocomplete
  • Progressive Enhancement: Add interactivity to server-rendered pages
  • Prototypes: Rapid development without build tools
  • Small to Medium Apps: Todo apps, dashboards, admin panels
  • With HTMX: Client-side UI state + server interactions

Not Ideal For

  • Large SPAs: Complex routing and state management
  • Heavy Computation: Better suited for backend or Web Workers
  • Offline-First: No built-in offline support
  • Complex Data Flow: Redux-like patterns harder to implement

Hybrid Approach

Use Alpine for UI state, backend for business logic:
<!-- Alpine for dropdown state -->
<div x-data="{ open: false }" @click.away="open = false">
  <button @click="open = !open">Menu</button>

  <div x-show="open">
    <!-- HTMX for server actions -->
    <button hx-post="/action">Do something</button>
  </div>
</div>

Next Steps