Ask a coding agent for a drag-to-reorder list, an inline edit, or a progress bar and it will reach, by reflex, for a JSON endpoint and a pile of client-side re-rendering. This page is the other habit. The server renders application state as HTML; the browser asks for a state transition through a link or a form; the response is a rendered HTML fragment that replaces the stale region. That is HTML over the wire, and it is what HATEOAS — hypermedia as the engine of application state — feels like in practice.
The request is a form body or a query string. The response is markup a
partial produced. jst-nav swaps or morphs it into the page.
There is no client model to keep in sync, because the DOM is the
state: re-request a region and you get the true current state,
rendered. Every demo below is a real link or form — with JavaScript off
it would still describe the same request; with it on, jst-nav
fetches the fragment and lands it in place instead of reloading the page.
Each recipe carries a live wire log. Open it and watch what actually crosses the wire: a form body or a query string going out, a rendered HTML fragment coming back. No JSON, anywhere. That is the whole lesson — most of the interactions an agent builds with a JSON API and a client renderer are a form and a fragment.
jst-nav — the response is an HTML fragment, swapped or morphed into the page
green — the browser does the rest (native drag events, form encoding, history)
POST · morphthe verb and swap mode the recipe uses
The whole page is hermetic: a fetch interceptor plays the server, holding its state in memory. Serve the repo root and open this file; every demo runs with no network.
A quick map from the habit to the hypermedia move. Read it top to bottom before the recipes: nearly every row an agent would build as a JSON endpoint is a form and a rendered fragment.
| The JSON reflex | The HTML answer |
|---|---|
| POST JSON, then re-render the list in JS | POST a form; the server returns the list fragment; morph it in |
GET /api/items?q= returns a JSON array | GET returns rendered <li>s; swap them into the list |
| A WebSocket pushes JSON patches | Poll or reveal-trigger a swap; the fragment carries — or drops — its own next trigger |
| Client validation mirrors the server rules | The server returns the field re-rendered with its error |
| Keep an in-memory model in sync with the DOM | The DOM is the model; re-request the region to re-render true state |
| Serialize the form to JSON, fetch, patch the DOM | Let the form submit; the response fragment replaces the stale region |
Drag a task to a new spot. On drop, the handler POSTs a form
body — which task moved, and which task it should land before — and the
server reorders its list and returns the whole re-rendered list.
morph reconciles it by jst-key, so the drag
focus and scroll survive. The client never splices an array; its only job
is to describe the transition.
# config/routes.rb
post "/tasks/reorder", to: "tasks#reorder"
# app/controllers/tasks_controller.rb
def reorder
task = Task.find(params[:id])
task.insert_before(params[:before]) # reorder server state
render partial: "tasks/task", collection: Task.ordered
end
The JSON reflex: POST {id, index} as JSON and re-sort a client array, then re-render every row by hand. Here the server sorts and renders; the browser just morphs.
Drag a card into another column. The drop POSTs the card, the
destination column, and a position. The move touches two regions — the
source and the destination column — so the server answers with their
smallest common ancestor: the whole board fragment, re-rendered.
morph keeps every untouched card in place (keyed), and the
bare jst-transition wraps the swap in a View Transition.
Rule of thumb: when a transition changes more than one region, respond with the smallest fragment that contains all of them, and morph it.
post "/cards/:id/move", to: "cards#move"
def move
card = Card.find(params[:id])
card.move_to(params[:to], params[:position])
render partial: "board/board", locals: { board: current_board }
end
The JSON reflex: PATCH the card as JSON, then mutate two client column arrays and re-render both. Here one fragment — the board — carries both changes.
Click Edit: a GET fetches the edit-form fragment for
that row and replaces the display fragment (outerHTML). Submit
POSTs the field; the server returns the display fragment, updated — or the
form re-rendered with a validation error. Clear the name and save to see
the error come back as HTML.
get "/contacts/:id/edit", to: "contacts#edit"
get "/contacts/:id", to: "contacts#show"
post "/contacts/:id", to: "contacts#update"
def edit; render partial: "contacts/form", locals: { contact: @contact }; end
def show; render partial: "contacts/contact", locals: { contact: @contact }; end
def update
if @contact.update(name: params[:name])
render partial: "contacts/contact", locals: { contact: @contact }
else
render partial: "contacts/form", locals: { contact: @contact } # with errors
end
end
The JSON reflex: a client-side edit state flag, a JSON PATCH, and a hand-built error object mirrored into the DOM. Here the two states — display and form — are just two fragments the server swaps between.
Click a star: the form submits immediately, the server flips
the state, and it returns the re-rendered control and the
dependent region in the same fragment — the N starred
counter. The
counter updates for free, because the server rendered it alongside the
control. No client subscription wires one to the other.
post "/stars/:id/toggle", to: "stars#toggle"
def toggle
doc = Document.find(params[:id])
doc.update!(starred: !doc.starred)
render partial: "stars/panel", locals: { docs: Document.all } # control + counter
end
The JSON reflex: toggle via JSON, then remember to also recompute and re-render the counter in client code. Here they arrive together; nothing to forget.
Type a username and tab away. The blur POSTs the single field;
the server applies its real rules and returns the field's wrapper fragment
re-rendered valid or invalid, with the message. Try ada or a
two-letter name. The rule lives once, on the server.
post "/signup/validate", to: "signups#validate"
def validate
user = User.new(username: params[:username])
user.valid? # runs the real model validations
render partial: "signups/username_field", locals: { user: user }
end
The JSON reflex: re-implement the uniqueness and length rules in client JavaScript so they can drift from the model. Here the server is the only place the rule exists.
A checkbox per row, all inside one form. Archive selected submits every checked id at once; the server archives them and returns the re-rendered table and toolbar, count badge included. The form scales to any number of selections with zero client bookkeeping — the checked boxes are the selection.
post "/rows/archive", to: "rows#archive"
def archive
Row.where(id: params[:id]).update_all(archived: true) # params[:id] is an array
render partial: "rows/region", locals: { rows: Row.active }
end
The JSON reflex: track a Set of selected ids in client state, POST it as a JSON array, then splice the archived rows out by hand. Here the form carries the set and the server returns the new table.
Delete a note. The response fragment carries two things: the updated list, and a toast whose form's hidden fields describe the inverse action — the id, text and position needed to put the note back. The undo affordance travels inside the returned HTML. This is the most literally HATEOAS recipe: the available next transition arrives as hypermedia, and clicking Undo POSTs it.
post "/notes/:id/delete", to: "notes#destroy"
post "/notes/restore", to: "notes#restore"
def destroy
@note = Note.find(params[:id]); pos = @note.position; @note.destroy
render partial: "notes/region",
locals: { notes: Note.all, undo: { id: @note.id, text: @note.text, position: pos } }
end
def restore
Note.create!(id: params[:id], text: params[:text], position: params[:position])
render partial: "notes/region", locals: { notes: Note.all, undo: nil }
end
The JSON reflex: stash the deleted record in client memory to power an undo button wired up in JS. Here the inverse action ships inside the toast — the server told the client what it may do next.
Type in the textarea. Input is debounced, then POSTs the draft.
The response is a tiny status fragment — Saved · save #3
— so even a
fire-and-forget mutation answers with renderable state, not a silent
204. Keep the message server-rendered and it stays the single
source of truth for did it save?
.
post "/drafts", to: "drafts#save"
def save
@draft = current_user.draft.update!(body: params[:text])
render partial: "drafts/status", locals: { draft: @draft }
end
The JSON reflex: POST JSON and interpret a status code in client code to flip a saved
flag. Here the status text is the response body.
Generate report POSTs to start a job. The response is a
progress fragment that carries its own re-poll trigger — a
data-poll URL. The client keeps hitting
GET /report/:id, swapping the fresh progress fragment in, until
the final response is the finished result without the trigger.
Polling stops because the hypermedia stopped asking for it.
post "/report", to: "reports#create"
get "/report/:id", to: "reports#show"
def create; job = ReportJob.start; render partial: "reports/progress", locals: { job: job }; end
def show
job = ReportJob.find(params[:id])
# the progress partial renders data-poll="/report/#{job.id}" only while running;
# the done partial omits it, so the client stops polling on its own.
render partial: (job.done? ? "reports/done" : "reports/progress"), locals: { job: job }
end
The JSON reflex: open a WebSocket and stream JSON progress patches, plus the client logic to apply them and to know when to stop. Here a poll that the fragment itself turns off replaces it, for the common case.
Same pattern, already demonstrated elsewhere: a keystroke, a
debounced GET with the query in the URL, and rendered
<li>s or <option>s back — never a JSON
array the client turns into DOM. See the
active search in the jst-nav demo and the async
combobox in the component gallery.
Both are this recipe: GET, HTML options back, swapped in.
get "/people", to: "people#index"
def index
@people = Person.where("name ILIKE ?", "%#{params[:q]}%")
render partial: "people/option", collection: @people # rendered <li>/<option>
end
The JSON reflex: GET /api/people?q= returns JSON, and the client maps it to option elements. Here the server renders the options.
Ten interactions an agent would reach for a JSON API to build, and not
one of them needed JSON. A link or a form describes the transition, the
server renders the new state as HTML, and jst-nav swaps or
morphs it into place. The affordances that would live in client state —
an undo action, the next poll, a validation message — travel inside the
returned markup instead. Before you write an endpoint that returns JSON
for the browser to re-render, ask whether a fragment already says it.