Rails
This guide pairs Hypermedia Components with Rails 7+ (Propshaft + Importmap) and htmx. The patterns translate to Sprockets-based apps.
Asset loading
Section titled “Asset loading”Put the HC dist files under app/assets/builds/hc/ (Propshaft) or
vendor/javascript/ (Importmap). The example below uses Propshaft:
app/assets/builds/hc/ hc.css hc.behaviors.min.js macros/ index.min.jsvendor/javascript/ htmx.min.jsPin htmx via Importmap, or simply reference it from vendor:
pin "htmx", to: "htmx.min.js"pin "hc", to: "hc/hc.behaviors.min.js"pin "hc-macros", to: "hc/macros/index.min.js"app/views/layouts/application.html.erb:
<!DOCTYPE html><html lang="en"><head> <title><%= content_for(:title) || "My app" %></title> <%= csrf_meta_tags %>
<%= stylesheet_link_tag "hc/hc.css" %> <%= javascript_importmap_tags %> <script type="module"> import "htmx" import "hc" import "hc-macros" </script></head><body> <%= yield %>
<div class="hc-toast-region" data-hc-toast-region role="region" aria-label="Notifications"></div>
<script> // Hand the CSRF token to htmx so every request carries it. document.body.addEventListener("htmx:configRequest", (e) => { const token = document.querySelector('meta[name="csrf-token"]')?.content if (token) e.detail.headers["X-CSRF-Token"] = token }) </script></body></html>Rendering components
Section titled “Rendering components”Rails partials map naturally to HC’s part-class shape.
<%# app/views/shared/_field.html.erb %><%# locals: (name:, label:, value: nil, message: nil, invalid: false) %>
<div class="hc-field"<%= " data-invalid=\"true\"".html_safe if invalid %>> <%= label_tag name, label, class: "hc-field__label" %> <%= text_field_tag name, value, class: "hc-input", aria: invalid ? { invalid: true, describedby: "#{name}-error" } : {} %> <% if message.present? %> <p id="<%= name %>-error" class="hc-field__message"><%= message %></p> <% end %></div>Use it from a form:
<%= form_with url: users_path, method: :post, data: { hx_post: users_path, hx_target: "this", hx_swap: "outerHTML" } do %> <%= render "shared/field", name: "email", label: "Email", value: @user.email, message: @user.errors[:email].first, invalid: @user.errors[:email].any? %>
<button class="hc-button" data-variant="primary" type="submit">Create</button><% end %>form_with helpers and HC classes
Section titled “form_with helpers and HC classes”To add hc-input to standard form helpers, pass class: explicitly:
<%= form.email_field :email, class: "hc-input" %><%= form.select :status, statuses_for_select, {}, class: "hc-input" %>For repeatable styling, wrap the helpers in a view helper:
module HcFormHelper def hc_email_field(form, attr, **opts) form.email_field(attr, { class: "hc-input" }.merge(opts)) endendReturning HTML fragments
Section titled “Returning HTML fragments”For htmx swaps the controller renders a partial directly.
class ItemsController < ApplicationController def create @item = Item.new(item_params) if @item.save response.headers["HX-Trigger"] = { "hc:toast" => { message: %(Saved "#{@item.name}"), variant: "success" } }.to_json render partial: "items/row", locals: { item: @item }, status: :created else render partial: "items/form", locals: { item: @item }, status: :unprocessable_entity end end
def destroy item = Item.find(params[:id]) item.destroy response.headers["HX-Trigger"] = { "hc:toast" => { message: %(Deleted "#{item.name}"), variant: "success" } }.to_json head :ok # empty body — htmx removes the row via outerHTML swap endendapp/views/items/_row.html.erb:
<tr id="item-<%= item.id %>"> <td><%= item.name %></td> <td> <span class="hc-badge" data-variant="<%= item.status %>"> <%= item.status_label %> </span> </td> <td> <span class="hc-action"> <%= button_tag "Delete", type: "button", class: "hc-button", data: { size: "sm", variant: "error", hc_confirm: "Delete #{item.name}?", hx_delete: item_path(item), hx_trigger: "hc:confirmed", hx_target: "closest tr", hx_swap: "outerHTML", hx_disabled_elt: "this", hx_indicator: "closest .hc-action", } %> <span class="hc-spinner htmx-indicator" aria-hidden="true"></span> </span> </td></tr>Rails converts data: { hc_confirm: "..." } into data-hc-confirm="..." —
underscores become hyphens, which matches HC’s attribute convention
exactly.
Toasts via HX-Trigger
Section titled “Toasts via HX-Trigger”A small concern keeps controllers tidy:
module HxTriggers extend ActiveSupport::Concern
def hx_trigger(events) response.headers["HX-Trigger"] = (existing_hx_trigger || {}).merge(events.stringify_keys).to_json end
private
def existing_hx_trigger raw = response.headers["HX-Trigger"] raw ? JSON.parse(raw) : {} rescue JSON::ParserError {} endendclass ItemsController < ApplicationController include HxTriggers
def create # … hx_trigger("hc:toast" => { message: "Saved.", variant: "success" }) render partial: "items/row", locals: { item: @item }, status: :created endendCSRF tips
Section titled “CSRF tips”csrf_meta_tagsin the layout plus thehtmx:configRequestlistener (see the layout snippet above) covers every htmx verb.- For PUT / PATCH / DELETE from forms, Rails normally smuggles
_methodin a hidden input — but htmx requests use the real verb viadata-hx-{verb}. TheX-CSRF-Tokenheader still validates correctly. - For SPA-style cross-origin htmx calls, configure
protect_from_forgery with: :null_sessionor skip the check on specific actions.
Detecting htmx
Section titled “Detecting htmx”class ItemsController < ApplicationController def index @items = Item.all if request.headers["HX-Request"] == "true" render partial: "items/rows", locals: { items: @items } else render :index end endendA common pattern is to extract a respond_to_htmx helper.
- Importmap vs Propshaft — both work. The example above uses
Importmap for JS pins and Propshaft for the CSS file. Sprockets is
fine too if you precompile
hc.css. - Hotwire alongside — Hypermedia Components can coexist with Hotwire (Turbo + Stimulus). HC handles styling and small behaviors; Hotwire / htmx are orthogonal transport choices.
- Form errors — pair
errors.full_messages_for(:email)(orerrors[:email]) withdata-invalid="true"on the wrapper andaria-invalid="true"on the input.