---
name: aha-stack
description: Build server-rendered web apps with the AHA stack, Astro (output server) + htmx 4 + Alpine.js 3. Use when the user mentions the AHA stack, htmx, Alpine.js, hypermedia, HTML over the wire, or wants dynamic pages without React/Vue/Svelte or a JSON API. Covers Astro partials, htmx 4 attributes and differences from htmx 2, the htmx/Alpine split, D1 on Cloudflare, and deployment.
---

# The AHA stack

Astro renders HTML on the server. htmx sends requests and swaps the returned HTML into the page. Alpine.js handles small pieces of client-only UI state.

Reference site: https://ahastack.dev. Live demos with source: https://demo.ahastack.dev.

## Mental model

1. The server owns the state. The database is the single source of truth.
2. Endpoints return HTML fragments, never JSON. htmx swaps them into the page.
3. htmx for anything that touches the server. Alpine for anything that is only UI (open/closed, editing, hover, show password).
4. One Astro component renders a piece of UI both on first page load and inside an htmx response. Never duplicate a template.
5. Write as little client-side JavaScript as possible. Prefer an htmx or Alpine attribute over a `<script>`.

## Setup

```sh
npm create astro@latest -- --template minimal
npx astro add cloudflare   # or: npx astro add node
```

`astro.config.mjs`:

```js
import { defineConfig } from 'astro/config'
import cloudflare from '@astrojs/cloudflare'

export default defineConfig({
  output: 'server',
  adapter: cloudflare(),
})
```

Load both libraries from a CDN in the layout `<head>`. No bundling.

```html
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
```

Use htmx 4. It is the current release even though npm still tags 2.x as `latest`. Do not install `htmx.org` from npm without `@4`.

## Astro rules

- Pages under `src/pages/` handle every HTTP method. Read the method with `Astro.request.method`.
- An endpoint that returns a fragment is an `.astro` file with `export const partial = true`. It must not render `<html>` or `<head>`.
- Read form data with `await Astro.request.formData()`. Read query params with `Astro.url.searchParams`.
- Guard endpoints: `if (Astro.request.method !== 'POST') return new Response(null, { status: 405 })`.
- Set a status with `Astro.response.status = 422`. htmx 4 swaps 4xx and 5xx responses, so error HTML can be returned this way.
- Cookies: `Astro.cookies.get('name')?.value` and `Astro.cookies.set('name', value, { path: '/', httpOnly: true, sameSite: 'lax' })`. Works inside partials too.
- Astro rejects cross-origin `POST` by default. htmx requests from the same origin pass. When testing with `curl`, add `-H 'Origin: http://localhost:4321'`.
- On Cloudflare, bindings come from `import { env } from 'cloudflare:workers'`. Do not use `Astro.locals.runtime`, it was removed.
- Astro 7 collapses newlines between text and inline elements like JSX. Keep prose paragraphs with inline tags on one line, or add `{' '}`.

### Partial example

```astro
---
// src/pages/api/counter/increment.astro
import { env } from 'cloudflare:workers'

export const partial = true

const count = await env.DB.prepare(
  'UPDATE counter SET value = value + 1 WHERE id = 1 RETURNING value'
).first('value')
---

<span id='count' hx-swap-oob='true'>{count}</span>
```

Do one atomic SQL statement for read-modify-write operations. Do not `SELECT`, compute in JS, then `UPDATE`.

## htmx 4 rules

- Put `hx-get`/`hx-post`/`hx-put`/`hx-delete` on the element. Default trigger: `click` for most elements, `change` for inputs, `submit` for forms.
- Default target is the element itself, default swap is `innerHTML`. Override with `hx-target` and `hx-swap`.
- Attributes do not inherit unless the parent says so: `hx-target:inherited='#list'`. This differs from htmx 2. Prefer putting attributes directly on the element.
- Extended selectors: `closest li`, `next .error`, `previous input`, `find .x`, `this`.
- Swap styles: `innerHTML`, `outerHTML`, `beforeend`, `afterend`, `beforebegin`, `afterbegin`, `delete`, `none`.
- `hx-swap-oob='true'` on an element in the response swaps it into the element with the same `id` anywhere on the page. If the response contains only oob elements, the main swap is skipped. `hx-swap='none'` is not needed for that case.
- `<hx-partial hx-target='#x' hx-swap='beforeend'>...</hx-partial>` wraps arbitrary content and says where it goes. Use it when the content has no natural `id`.
- Debounce: `hx-trigger='input changed delay:300ms'`.
- Loading state: `hx-indicator='#spinner'`. htmx adds `htmx-request` to that element during the request. Style `.htmx-indicator { opacity: 0 } .htmx-request .htmx-indicator { opacity: 1 }`.
- Extra values: `hx-vals='{"id": 3}'`. In Astro: `hx-vals={JSON.stringify({ id })}`.
- Confirmation without Alpine: `hx-confirm='Sure?'`.
- Events use colons: `htmx:after:request`, `htmx:before:swap`. Inline handlers: `hx-on:click='...'`.
- Extensions load as separate scripts from `htmx.org@4.0.0/dist/ext/`. `hx-alpine-compat` exists for edge cases but is not needed for normal use.

### htmx 2 habits to drop

| htmx 2 | htmx 4 |
| --- | --- |
| `hx-target` on a parent applies to children | Add `:inherited` or set it on each child |
| `hx-swap='none'` for oob-only responses | Not needed |
| 4xx/5xx responses ignored | Swapped like any response |
| `htmx:afterRequest` | `htmx:after:request` |
| `hx-disabled-elt` | `hx-disable` |

## Alpine rules

- Every Alpine directive needs an `x-data` ancestor. A bare `@click` on an element outside any `x-data` does nothing.
- Keep `x-data` small and local to the widget: `{ open: false }`, `{ editing: false }`, `{ q: '' }`.
- Alpine initializes elements htmx swaps in. No manual `Alpine.initTree`.
- Add `x-cloak` to elements hidden on load and `[x-cloak] { display: none !important }` to the CSS.
- Alpine and htmx on the same element is fine and common: `<button hx-post='/api/reset' @click='confirming = false'>Yes</button>`.
- Focus after showing: `x-effect='editing && $nextTick(() => $refs.input.focus())'`.
- Never store application data (todos, products, the count) in Alpine. Alpine state is thrown away on the next swap.

## Patterns

### Counter with oob swap

```astro
<h1>Count: <span id='count'>{count}</span></h1>
<button hx-post='/api/counter/increment'>Increment</button>
```

Endpoint returns `<span id='count' hx-swap-oob='true'>{count}</span>`. The button is untouched.

### Active search

```astro
<input type='search' name='q'
  hx-get='/api/search'
  hx-trigger='input changed delay:300ms, search'
  hx-target='#results'
  hx-indicator='#spinner' />
<span id='spinner' class='htmx-indicator'>Searching…</span>
<div id='results'><SearchResults /></div>
```

`/api/search.astro` reads `Astro.url.searchParams.get('q')` and renders the same `<SearchResults q={q} />` component.

### List item CRUD

Each item is one component, `TodoItem.astro`, rendered on load and returned by every endpoint.

```astro
<li x-data='{ editing: false }'>
  <input type='checkbox' checked={todo.done}
    hx-post='/api/todos/toggle' hx-vals={JSON.stringify({ id: todo.id })}
    hx-target='closest li' hx-swap='outerHTML' />
  <span x-show='!editing' @dblclick='editing = true'>{todo.text}</span>
  <form x-show='editing' x-cloak hx-post='/api/todos/edit'
    hx-target='closest li' hx-swap='outerHTML' @keydown.escape='editing = false'>
    <input type='hidden' name='id' value={todo.id} />
    <input name='text' value={todo.text} x-ref='input' @blur='editing = false' />
  </form>
  <button hx-post='/api/todos/delete' hx-vals={JSON.stringify({ id: todo.id })}
    hx-target='closest li' hx-swap='delete'>×</button>
</li>
```

Add: form with `hx-target='#todos' hx-swap='beforeend'`. The endpoint returns the new `<li>` plus a fresh copy of the form with `hx-swap-oob='true'` and the same `id`, which clears the input.

### Inline validation

```astro
<input name='email' hx-post='/api/validate/email' hx-trigger='change' hx-target='next .error' />
<p class='error'></p>
```

Endpoint validates, sets `Astro.response.status = 422` on failure, returns `<span>{error}</span>` or `<span class='ok'>Looks good</span>`. Submit posts the whole form with `hx-swap='outerHTML'` and the endpoint re-renders the form with errors, or returns a success block.

### Confirm before a destructive action

```astro
<span x-data='{ confirming: false }'>
  <button x-show='!confirming' @click='confirming = true'>Delete</button>
  <span x-show='confirming' x-cloak>
    Sure?
    <button hx-delete='/api/items/3' hx-target='closest li' hx-swap='delete' @click='confirming = false'>Yes</button>
    <button @click='confirming = false'>No</button>
  </span>
</span>
```

## Data

Cloudflare: use D1 (SQLite). Bind it in `wrangler.jsonc` and query with `env.DB.prepare(sql).bind(...).first()` / `.all()` / `.run()`.

```jsonc
{
  "name": "my-app",
  "d1_databases": [
    { "binding": "DB", "database_name": "my-app", "database_id": "<id>" }
  ]
}
```

```sh
npx wrangler d1 create my-app
npx wrangler d1 execute my-app --local --file ./schema.sql
npx wrangler d1 execute my-app --remote --file ./schema.sql
```

`astro dev` runs in the real Workers runtime, so local D1 works without extra setup. Deploy with `npx astro build && npx wrangler deploy`.

Node: use `@astrojs/node` and `node:sqlite` or any driver. Same patterns, only the data layer changes.

Per-visitor data without auth: set a random cookie id and scope queries by it.

## Anti-patterns

- Building a JSON API and rendering it with client JavaScript. Return HTML.
- Fetching with `fetch()` in a `<script>` when an `hx-*` attribute would do.
- Holding app data in Alpine `x-data` and syncing it to the DOM by hand.
- Two templates for the same UI, one for the page and one for the response.
- Returning an oob element without an `id`, or with an `id` that is not on the page.
- Forgetting `export const partial = true`, which wraps the fragment in a full document.
- Relying on htmx 2 attribute inheritance.
- Reading a value, changing it in JavaScript, then writing it back, when SQL can do it atomically.

## Project layout

```
src/
├── layouts/Layout.astro        # <head> with htmx + Alpine, global CSS
├── components/                 # .astro components used by pages and partials
├── lib/                        # data access, validation
└── pages/
    ├── index.astro
    ├── todos.astro
    └── api/
        └── todos/
            ├── add.astro       # export const partial = true
            ├── toggle.astro
            ├── edit.astro
            └── delete.astro
schema.sql
wrangler.jsonc
```

## Further reading

- Concepts and reasoning: https://ahastack.dev
- Working code for every pattern above: https://github.com/flaviocopes/ahastack.dev/tree/main/demo
- htmx 4 docs: https://four.htmx.org/docs/
- Alpine docs: https://alpinejs.dev
- Astro Cloudflare adapter: https://docs.astro.build/en/guides/integrations-guide/cloudflare/
