← All posts
·16 min read

Claude Code with HTMX: Server-Driven Hypermedia Without the SPA Chaos

Claude CodeHTMXHypermediaWorkflow
Claude Code with HTMX: Server-Driven Hypermedia Without the SPA Chaos

Why HTMX needs its own CLAUDE.md

Claude Code is trained on the modern web. That means React, Next.js, JSON APIs, client-side state management, and the assumption that interactivity lives in JavaScript. When you open a session and ask Claude to add a feature to your Django or Rails app that uses HTMX, Claude's prior is wrong before you type a single word.

HTMX inverts the model. The server returns HTML fragments, not JSON. The browser swaps those fragments into the DOM. JavaScript stays minimal. There is no client store, no hydration step, no component tree. It is the hypermedia model that the web was built on, extended with a handful of attributes.

The problem is that Claude does not know you have made this choice. Without guidance it will:

  • Return a JSON payload from your Django view and add a fetch() call to parse it
  • Add an Alpine.js or Vanilla JS handler to manage UI state that HTMX already handles
  • Suggest a React component for a single interactive element
  • Generate a views.py that renders the full page template instead of a partial
  • Forget to read the HX-Request header on the server and send a full HTML document back in response to an HTMX trigger

None of this is a Claude failure. It is a context failure. HTMX is a minority choice, and without CLAUDE.md Claude plays the probability distribution of what server-side web code looks like across its training data. That distribution skews heavily toward SPA patterns.

The fix is a CLAUDE.md that declares the stack, names the mental model, and writes the hard rules explicitly. Once those rules are in place, Claude generates correct HTMX code consistently across every session. If you are new to the CLAUDE.md format, Claude Code custom instructions covers how it gets read at session start. For the broader workflow foundations, Claude Code best practices is the starting point.

The HTMX CLAUDE.md template

Place this at your project root or in the subdirectory where your server code lives. Adjust the stack section to match your framework. The rest stays largely the same regardless of whether you are on Django, Flask, Rails, Go, Laravel, or any other server-side stack.

# HTMX hypermedia rules

## Stack
- Python 3.12 / Django 5.x
- HTMX 2.x (CDN or bundled, no npm required)
- Jinja2 templates (or Django templates)
- No React, Vue, Svelte, or any SPA framework in this project
- Minimal JavaScript: Alpine.js permitted for local state only, nothing else

## Mental model, hypermedia first
- The server is the source of truth for all UI state
- Every interaction that changes state goes to the server as a form POST or GET
- The server responds with an HTML fragment, not JSON
- HTMX swaps that fragment into the DOM
- There is no client-side data store, no serialisation layer, no hydration step

## View response rules
- ALWAYS check the HX-Request header before deciding what to return
- If HX-Request is present: return the partial template fragment only
- If HX-Request is absent: return the full page layout wrapping the same fragment
- NEVER return JSON from a view that HTMX triggers, return HTML
- NEVER render the full base template in response to an HTMX request
- Partial templates live in templates/partials/ (Django) or app/views/partials/ (Rails)

## HTMX attribute conventions
- hx-get: read-only fetches, safe to call on input change or load
- hx-post / hx-put / hx-patch / hx-delete: mutations, always include CSRF token
- hx-target: always explicit, never rely on implicit self-swap for clarity
- hx-swap: default is outerHTML; use innerHTML for container updates; use none for side-effect-only requests
- hx-trigger: default is "click" for buttons, "change" for inputs, "submit" for forms
- hx-indicator: always set on slow operations (DB queries, external API calls)
- hx-push-url: set to true when the partial represents a navigable page state

## CSRF rules
- Django: {% csrf_token %} in every form, plus hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
  on any hx-post/put/patch/delete that does not use a form
- Flask: WTForms CSRF field in every form; for AJAX: X-CSRFToken header from meta tag
- Rails: authenticity_token in forms; ActionController::RequestForgeryProtection handles HTMX
  if the Content-Type header is set correctly, use data-hx-headers for JSON-excluded tokens
- Go (chi/echo/gin): pass csrf token via cookie + X-CSRF-Token header on mutations

## Locality of Behaviour (LoB) rule
- HTMX attributes belong on the HTML element that triggers the action
- Do NOT centralise HTMX logic in a separate JS file that adds attributes programmatically
- The intent of an interaction must be readable from the template alone
- If you find yourself writing document.querySelector('[hx-get]'), stop and move the logic to the template

## Out-of-Band (OOB) swaps
- Use OOB swaps when one action needs to update multiple DOM regions
- Pattern: primary response is the main fragment; OOB elements carry hx-swap-oob="true"
- Example uses: updating a notification badge after a form submit, refreshing a sidebar
  counter after an item is added to a list, syncing a header cart total after checkout
- OOB elements must have an id that matches the target in the DOM
- Keep OOB swaps to 2-3 regions maximum per response, more than that is a design smell

## hx-boost
- Add hx-boost="true" to <body> or nav elements to progressively enhance standard links
- hx-boost converts full-page navigations to HTMX fetches, keeping the page shell alive
- Server must still return full HTML on direct navigation (no HX-Request header)
- Do NOT add hx-boost to links that open external URLs or trigger file downloads

## Partials architecture
- Every interactive UI region has a corresponding partial template
- Partial template naming: _item.html, _list.html, _form.html, _search_results.html
- Views that serve partials are thin: query, pass context, render partial, return HttpResponse
- No business logic in partial views, logic lives in models, services, or interactors
- Reuse partials between the full-page render and the HTMX response to avoid drift

## What Claude should NOT generate in this project
- React components, JSX, useState, useEffect
- fetch() calls that parse JSON responses
- JSON serialisation in views (JsonResponse, render_json, json.dumps in templates)
- Alpine.js for anything that HTMX already handles natively
- Client-side routing (react-router, vue-router, SvelteKit routing)
- GraphQL queries from the browser

That block is 50 lines. Copy it, trim anything not applicable to your stack, and add it to your CLAUDE.md. Claude reads it at session start, and the rules hold for the entire session.

A few of these entries do more work than they appear to.

The HX-Request header rule is the single most important entry. Without it, Claude generates views that always render the full page. HTMX fires requests with the HX-Request: true header on every interaction. A view that checks for that header can serve a partial in one code path and the full layout in another, making the URL directly navigable while still supporting HTMX partial swaps. Claude generates this pattern every time when the rule is explicit, and ignores it every time when it is not.

The Locality of Behaviour rule prevents a subtle drift that happens in longer sessions. As a project grows, Claude starts factoring out repeated JavaScript to keep things DRY. In an HTMX project, that often means moving HTMX attributes into JavaScript that applies them dynamically, which defeats the entire point of HTMX's readability model. The rule names the anti-pattern directly so Claude avoids it.

The negative list at the bottom is just as important as the positive rules. Claude's prior is to use these patterns. Explicitly naming them as off-limits is faster than hoping the positive rules imply the negatives.

Partial rendering patterns across server stacks

The core HTMX server pattern is the same on every backend: check the HX-Request header, return a partial on HTMX requests and a full page on direct navigations. The implementation varies by framework. Here are the four patterns to put in your CLAUDE.md alongside the canonical block above.

Django

# views.py
from django.shortcuts import render
from django.http import HttpResponse

def search_results(request):
    query = request.GET.get("q", "")
    results = Product.objects.filter(name__icontains=query)[:20]

    if request.headers.get("HX-Request"):
        # Return the partial fragment only
        return render(request, "partials/_search_results.html", {"results": results})

    # Return full page for direct navigation
    return render(request, "search.html", {"results": results, "query": query})
<!-- templates/partials/_search_results.html -->
<ul id="search-results">
  {% for product in results %}
    <li>{{ product.name }}, {{ product.price }}</li>
  {% empty %}
    <li>No results for that search.</li>
  {% endfor %}
</ul>
<!-- templates/search.html (partial of the trigger element) -->
<input
  type="search"
  name="q"
  placeholder="Search products..."
  hx-get="/search/"
  hx-trigger="input changed delay:300ms"
  hx-target="#search-results"
  hx-swap="outerHTML"
  hx-indicator="#search-spinner"
>
<span id="search-spinner" class="htmx-indicator">Searching...</span>

The delay:300ms on the trigger debounces keystrokes. The hx-indicator shows a spinner while the server responds. The hx-target points at the result list by ID, and hx-swap="outerHTML" replaces the entire <ul> rather than its children. Claude generates all of this when the attribute conventions are in CLAUDE.md.

Flask

# routes.py
from flask import request, render_template

@app.route("/items/<int:item_id>/toggle", methods=["POST"])
def toggle_item(item_id):
    item = Item.query.get_or_404(item_id)
    item.done = not item.done
    db.session.commit()

    if request.headers.get("HX-Request"):
        return render_template("partials/_item.html", item=item)

    return redirect(url_for("index"))

Rails

# items_controller.rb
def toggle
  @item = Item.find(params[:id])
  @item.update!(done: !@item.done)

  respond_to do |format|
    format.html do
      if request.headers["HX-Request"]
        render partial: "items/item", locals: { item: @item }
      else
        redirect_to items_path
      end
    end
  end
end

Rails' respond_to block works cleanly here. The partial lives at app/views/items/_item.html.erb. Direct-navigation fallback is a redirect to the index page, keeping the URL navigable.

Go (chi router)

// handlers.go
func (h *Handler) SearchHandler(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query().Get("q")
    results, err := h.store.SearchProducts(r.Context(), query)
    if err != nil {
        http.Error(w, "search failed", http.StatusInternalServerError)
        return
    }

    if r.Header.Get("HX-Request") != "" {
        // Render partial template only
        h.templates.ExecuteTemplate(w, "search_results.html", results)
        return
    }

    // Full page layout for direct navigation
    h.templates.ExecuteTemplate(w, "search.html", map[string]any{
        "Results": results,
        "Query":   query,
    })
}

In Go, template execution is explicit. The partial template (search_results.html) is defined as a named template in a templates/partials/ directory and parsed at startup. Claude generates this correctly when the template organisation is documented in CLAUDE.md.

Common failure modes and how to prevent them

Failure mode 1: returning JSON instead of HTML

This is the most frequent Claude mistake in HTMX projects. A typical session looks like this: you ask Claude to add a "mark as done" button to a task list. Claude writes a route that returns {"done": true, "id": 42} and adds a JavaScript event listener to the HTMX htmx:afterSettle event to parse the response and update the DOM manually.

That is the SPA pattern applied to an HTMX request. The response should be the updated task HTML fragment, not JSON.

Prevent it with two CLAUDE.md entries:

## Anti-patterns
- NEVER return JsonResponse or json.dumps from a view triggered by HTMX
- If a mutation only needs to confirm success without updating UI, return an empty 204
  (hx-swap="none" on the trigger element), not JSON

The empty 204 case is a useful clarification. Some HTMX interactions are fire-and-forget: delete an item from a list, dismiss a notification, record an event. The correct server response is HTTP 204 No Content. The HTMX attribute is hx-swap="none". Claude generates this correctly once the rule is stated; without it, Claude defaults to returning JSON with a status code.

Failure mode 2: missing CSRF tokens on mutations

Claude frequently omits CSRF handling on HTMX POST requests, especially for non-form triggers. If you are using HTMX to trigger a POST from a button that is not inside a <form> element, the CSRF token does not travel automatically with the request.

The fix is in the CLAUDE.md CSRF section above, but it is worth stating the specific pattern for each framework.

For Django, the canonical approach is to set the CSRF token in an hx-headers attribute at the <body> level, so all HTMX requests carry it:

<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>

This one line on <body> means Claude never has to add CSRF handling per-element. Every hx-post, hx-put, hx-patch, and hx-delete in the document inherits the header. Add this to your base template and document it in CLAUDE.md so Claude never generates a mutation without it.

Failure mode 3: over-reaching JavaScript

Claude sometimes adds Alpine.js or Vanilla JS to handle something HTMX already covers natively. Common examples: using x-show to hide/show an element that should be controlled by a server response, adding a keyup event listener for a search field that already has hx-trigger="input changed delay:300ms", or writing a fetch() call inside an htmx:afterSettle handler.

The CLAUDE.md rule "no client-side routing, no fetch, no Alpine.js for anything HTMX handles" covers most of this. For Alpine.js specifically, the boundary is: Alpine.js for local UI state that never touches the server (a dropdown toggle, a collapsible accordion, a character counter). HTMX for anything that involves a server round trip.

Document that boundary explicitly:

## Alpine.js boundary
- Alpine.js: local UI state only (open/close, toggle, character count, focus management)
- HTMX: any interaction that reads from or writes to the server
- NEVER use Alpine.js x-data stores as a replacement for server state
- NEVER use Alpine $fetch or axios inside Alpine components in this project

Failure mode 4: full-page renders on HTMX requests

If Claude generates a view that always renders the full base template, HTMX will swap the entire <html> document into whatever DOM region hx-target points at. The result is a broken page with a <html> tag inside a <div>. Claude produces this when the HX-Request check is missing from the view.

Add a test assertion for this to your CLAUDE.md if you have a test suite:

## Test rule
- Every view that serves an HTMX partial must have a test asserting:
  1. With HX-Request header: response contains the partial template name, not base.html
  2. Without HX-Request header: response renders the full page layout

Claude generates these tests alongside the view when the rule is in place.

OOB swaps for multi-region updates

Out-of-Band swaps are how HTMX handles a common pattern: one user action needs to update more than one place on the page. A user adds an item to a cart. The cart drawer updates with the new item. The cart icon in the header updates its count badge. Two DOM regions, one server response.

Without OOB swaps, the naive fix is two separate HTMX requests or JavaScript. OOB keeps it server-driven.

The pattern: your primary HTMX response returns the fragment the hx-target expects. Additional OOB fragments are included in the same response body with hx-swap-oob="true" and an id matching the existing DOM element.

<!-- Server response body for POST /cart/add -->

<!-- Primary fragment: replaces hx-target="#cart-drawer" -->
<div id="cart-drawer">
  <!-- updated cart contents -->
  <ul>
    {% for item in cart_items %}
      <li>{{ item.name }} x{{ item.quantity }}</li>
    {% endfor %}
  </ul>
</div>

<!-- OOB fragment: independently replaces #cart-badge in the header -->
<span id="cart-badge" hx-swap-oob="true">{{ cart_count }}</span>

The server view builds both fragments and returns them in a single HttpResponse. HTMX processes the response, swaps the primary fragment into hx-target, and independently finds #cart-badge in the existing DOM and swaps it.

Add this pattern to CLAUDE.md with your specific use cases. Claude handles OOB correctly when the pattern is documented; without it, Claude either misses the OOB attribute or generates a JavaScript handler to do the second update.

## OOB swap use cases in this project
- Cart add: primary = #cart-drawer, OOB = #cart-badge
- Form submit with notification: primary = #form-container, OOB = #notification-toast
- Item delete from list: primary = deleted item (hx-swap="outerHTML" on the item itself),
  OOB = #item-count in the sidebar

Deciding when to use partials, full pages, and OOB

Every HTMX interaction falls into one of three response patterns. Getting the right one is a decision point Claude needs guidance on, because the wrong choice either breaks navigation or complicates the server unnecessarily.

Use a partial when:

  • The interaction updates a bounded region of the current page
  • The URL does not change (or changes but the current page context stays valid)
  • The user is mid-workflow and a full page reload would reset their state

Examples: search results filtering, inline form validation, "load more" pagination, toggling an item's status, expanding a detail row.

Use a full page response with hx-push-url when:

  • The interaction represents a navigation event the user expects to be in their history
  • The URL changes meaningfully (moving from /items/ to /items/42/)
  • The user might share the URL or use the back button

Examples: clicking into a detail view, switching between main sections of a dashboard, completing a multi-step form step that advances the URL.

Use an OOB swap when:

  • One action updates multiple independent DOM regions
  • The regions are far apart in the DOM (header + main content)
  • You want to avoid a full page response but need more than one target

Add a decision rule to CLAUDE.md:

## Response pattern decision
- Single-region update, same URL context: partial fragment
- Navigation event, URL changes: full-page render with hx-push-url="true" on the trigger
- Multi-region update, single request: OOB swaps (max 3 regions)
- Mutation with no UI update needed: 204 No Content, hx-swap="none"

Claude uses this rubric to pick the right response type when generating new views, rather than defaulting to whatever pattern was used most recently in the session.

hx-boost for progressive enhancement

hx-boost is the simplest HTMX feature and the most overlooked. Add it to your <body> tag and every standard <a> link and <form> in the document is automatically enhanced: clicks become HTMX fetches that swap <body> content without full page reloads, keeping JavaScript state and CSS animations alive across navigations.

<body hx-boost="true" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>

Two things on <body>. One enables progressive enhancement for all links. The other ensures every HTMX request carries the CSRF token.

The server still returns full HTML for boosted requests, because direct navigation (bookmark, share, back button) still arrives without the HX-Request header. This is the progressive enhancement property: HTMX enhances when available, falls back to standard navigation when not. No JavaScript required to access any URL.

Add this to CLAUDE.md:

## hx-boost setup
- hx-boost="true" is set on <body> in base.html
- hx-headers on <body> passes CSRF token to all requests
- Views must still return full HTML for direct navigation (no HX-Request header)
- Do NOT add hx-boost to: external links, download links, links with target="_blank"
- Exclude specific links with hx-boost="false" on the element

Claude adds hx-boost="false" to external links when it generates navigation elements, once the rule is in place.

Connecting HTMX to your Claude Code workflow

The HTMX CLAUDE.md template above keeps Claude in the hypermedia mental model across sessions. A few workflow practices reinforce it.

Start each session by naming the stack: "We are using Django with HTMX. Read CLAUDE.md." Claude confirms the context and applies the rules. This is faster than discovering mid-session that Claude has started writing a React component.

When Claude generates a view, check two things before accepting: does the view check HX-Request, and does the template return a partial for HTMX responses. These are the two most common omissions. A quick review of the generated view takes 30 seconds and prevents a class of bugs.

For server-side stacks specifically, the CLAUDE.md patterns in Claude Code with Django and Claude Code with FastAPI cover the underlying view and ORM conventions that HTMX builds on. For Go specifically, the project structure guidance in Claude Code with Go covers template organisation and handler patterns that compose with the HTMX rules above. The Rails partial and respond_to conventions sit on the same foundation as Claude Code with Rails.

For permissions, the .claude/settings.local.json approach lets you gate destructive operations while allowing the standard development loop. The pattern is documented in Claude Code permissions, and it applies cleanly to HTMX projects: allow python manage.py runserver, allow test commands, gate database destructive operations behind explicit confirmation.

The case for writing these rules down once

HTMX is a principled bet. You are trading client-side complexity for server-side simplicity, betting that the hypermedia model is easier to maintain long-term than a client store and a JSON API layer. That bet pays off when the code stays consistent with the model.

Claude Code accelerates HTMX development significantly once the CLAUDE.md rules are in place. The attribute conventions, the partial rendering pattern, the CSRF handling, the OOB swap structure, and the HX-Request check are all boilerplate that Claude generates correctly and quickly when the rules are documented. What takes a developer five minutes of mental overhead per feature becomes a one-line prompt.

The failure modes in this guide all share the same root: Claude defaults to the SPA model because that is what most of the web uses. A CLAUDE.md that explicitly names the hypermedia model, bans JSON responses from HTMX views, requires partial templates, and names the anti-patterns overrides that prior completely. Write the rules once. Every session after that stays on the path you chose.

For the overall Claude Code setup that this CLAUDE.md sits inside, Claude Code setup guide covers installation and the project structure that makes these rules work. Claudify includes a pre-built HTMX CLAUDE.md template for Django, Flask, Rails, and Go, alongside the other stack-specific configurations that keep Claude Code on track.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir