Skip to content
Wei Li
Go back

Using Alpine.js in Astro: Why and How

Edit page

Alpine.js adds behavior to HTML through attributes: x-data declares some state, and directives like @click, x-text and x-show react to it. There’s no component file format and no build step of its own. This site already uses Vue for its bigger widgets; this post covers why Alpine is worth having as well, how the Astro integration works, and three examples.

A first example

This counter is the official Astro example, restyled for this site:

0
Rendered by Astro, made interactive by Alpine.

It’s a plain .astro component, src/components/AlpineCounter.astro. Astro renders the HTML, including the starting count from props; the Alpine attributes in that HTML do the rest:

---
const { initialCount = 0 } = Astro.props;
---

<div x-data={`{ count: ${initialCount} }`}>
  <button @click="count--">−</button>
  <output x-text="count">{initialCount}</output>
  <button @click="count++">+</button>
</div>

Because the starting value comes from props, the same component can start anywhere. This one is <AlpineCounter initialCount={5} />:

5

Unlike a Vue component, both counters work without a client:* directive. That’s the first big difference.

How it works

The @astrojs/alpinejs integration adds one script to every page. It imports Alpine and calls Alpine.start() once the HTML has loaded. Alpine then scans the page for x-data attributes and wires up each one it finds.

There’s no hydration step, because there’s nothing to hydrate. Astro has already rendered the final HTML at build time, and Alpine attaches behavior to it in place. Readers see the content before any JavaScript runs, which makes it a good fit for progressive enhancement: the page works as HTML, and Alpine makes it nicer.

Alpine also watches the page for changes. When this site swaps in a new page without a full reload, as it does when you follow a link, Alpine notices the new x-data elements and initializes them too.

Alpine works in any post format

Since Alpine only needs the right attributes in the HTML, it isn’t limited to MDX. It works in plain Markdown and AsciiDoc posts too, as raw HTML. Vue components can’t do that.

Which Map implementation keeps its keys sorted?

TreeMap. It’s a red-black tree, so lookups and inserts are O(log n), and it adds navigation methods like floorKey and headMap.

That box is raw HTML written straight into this post. The same markup works in a .md file:

<div x-data="{ open: false }">
  <button type="button" @click="open = !open">Show answer</button>
  <p x-show="open" x-cloak>TreeMap.</p>
</div>

In AsciiDoc, wrap it in a passthrough block so Asciidoctor leaves it alone:

++++
<div x-data="{ open: false }">
  <button type="button" @click="open = !open">Show answer</button>
  <p x-show="open" x-cloak>TreeMap.</p>
</div>
++++

One catch in MDX: attributes are JSX, whose names can’t start with @ or : or contain dots. Use the long forms there, x-on:click instead of @click and x-bind:class instead of :class, and leave out modifiers like .opacity in x-transition.opacity or .prevent in @submit.prevent. If you need a modifier, put the markup in an .astro component, where any attribute name works. Two more MDX quirks: write attributes without a value as x-cloak="" and x-transition="" (JSX would otherwise output x-transition="true", which Alpine reads as a list of CSS classes), and keep text on the same line as its tag, because MDX wraps text on its own lines in <p> elements. If that gets awkward, put the markup in an .astro component, where it’s plain HTML.

Example: a Java quiz

The questions below are passed to src/components/JavaQuiz.astro as a prop from this post. Astro highlights the code with Shiki at build time, the same way it highlights every other code block. Alpine only keeps track of which answers you picked and your score.

1/4: What does this print?

Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println((a == b) + " " + (c == d));

Autoboxing calls Integer.valueOf, which by default returns cached objects for -128 to 127. == compares references, so it's only true inside that range. Compare boxed numbers with equals.

2/4: What does this print?

List<Integer> list = new ArrayList<>(List.of(1, 2, 3));
list.remove(1);
System.out.println(list);

remove(int index) matches the int literal better than remove(Object), so the element at index 1 is removed. To remove the value 1, call list.remove(Integer.valueOf(1)).

3/4: What does this print?

Stream.of("a", "b", "c")
    .peek(System.out::print)
    .map(String::toUpperCase);

Intermediate operations are lazy. Without a terminal operation such as toList() or forEach, the pipeline never runs, so peek never prints.

4/4: What does this print?

record Point(int x, int y) {}

Point p = new Point(1, 2);
System.out.println(p.equals(new Point(1, 2)) + " " + p);

Records generate equals, hashCode and toString from their components, and toString uses the Name[field=value, ...] format.

0 of 4 correct (4 left)

The interesting part is how data gets from Astro to Alpine. The component builds its x-data object at build time, serializing the correct answers from its props into it:

---
const state = `{
  picked: {},
  answers: ${JSON.stringify(questions.map(q => q.answer))},
  pick(question, option) {
    if (!(question in this.picked)) this.picked[question] = option;
  },
  get score() { /* ... */ },
}`;
---

<div x-data={state}>
  {questions.map((q, i) => (
    <section>
      <Code code={q.code} lang="java" />
      {q.options.map((option, o) => (
        <button @click={`pick(${i}, ${o})`}>{option}</button>
      ))}
    </section>
  ))}
</div>

Example: an epoch converter

Paste a Unix timestamp, in seconds or milliseconds, to see it as java.time prints it. The output matches Instant.toString() and ZonedDateTime.toString() exactly, including details like Java leaving out zero seconds.

Enter a whole number of seconds or milliseconds since 1970-01-01T00:00:00Z, for a date between the years 1 and 9999.

Read as
Relative
Instant

This one has more logic than fits comfortably in an attribute, so it’s written as a TypeScript module and registered as a named component with Alpine.data. The integration’s entrypoint option points to a file that runs before Alpine starts:

// astro.config.ts
integrations: [alpine({ entrypoint: "/src/alpine/entrypoint" })],
// src/alpine/entrypoint.ts
import type { Alpine } from "alpinejs";
import { epochConverter } from "./epochConverter";

export default (Alpine: Alpine) => {
  Alpine.data("epochConverter", epochConverter);
};

The markup then refers to it by name: <div x-data="epochConverter">. The logic lives in src/alpine/epochConverter.ts, where TypeScript checks it and it can be unit-tested like any other module. Registering through the entrypoint also means the component is defined on every page before Alpine starts, so it keeps working after client-side navigation.

Alpine or Vue?

This site uses both, for different jobs:

Alpine.jsVue
Where the UI livesAttributes in HTML.vue single-file components
How it startsScans the page; no client:* directiveHydrates islands marked with client:*
Loaded onEvery page, about 20 KB gzippedOnly pages with an island, about 33 KB plus components
Post formatsMDX, Markdown and AsciiDoc (raw HTML)MDX only
Type checkingModules registered with Alpine.data; not attribute expressionsFull TypeScript, templates included
Good forToggles, reveals, small forms, quizzesStateful tools: playgrounds, testers, data tables

A rough rule: if the behavior fits in a few attributes, or has to work in a Markdown or AsciiDoc post, use Alpine. If it has enough state that you’d want components, computed values and type-checked templates, use Vue. The regex tester would be painful in Alpine; the “Show answer” box above would be overkill in Vue.

What it costs

Setting it up

  1. Install the integration and Alpine:

    pnpm add @astrojs/alpinejs alpinejs @types/alpinejs
  2. Add it to astro.config.ts, with an entrypoint if you’ll register components or plugins:

    import alpine from "@astrojs/alpinejs";
    
    export default defineConfig({
      integrations: [alpine({ entrypoint: "/src/alpine/entrypoint" })],
    });
  3. Hide elements until Alpine has initialized them, so content behind x-show doesn’t flash on page load. Alpine removes the x-cloak attribute once it’s ready:

    [x-cloak] {
      display: none !important;
    }
  4. Inside a post, give a component’s outer element not-prose text-foreground, as for Vue components, so the post’s typography styles stay out and the text stays readable in dark mode.


Edit page
Share this post:

Previous Post
Using Vue Components in Posts