Server-driven interactions — the HATEOAS way

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.

red 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.

The JSON reflex, and the HTML answer

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 reflexThe HTML answer
POST JSON, then re-render the list in JSPOST a form; the server returns the list fragment; morph it in
GET /api/items?q= returns a JSON arrayGET returns rendered <li>s; swap them into the list
A WebSocket pushes JSON patchesPoll or reveal-trigger a swap; the fragment carries — or drops — its own next trigger
Client validation mirrors the server rulesThe server returns the field re-rendered with its error
Keep an in-memory model in sync with the DOMThe DOM is the model; re-request the region to re-render true state
Serialize the form to JSON, fetch, patch the DOMLet the form submit; the response fragment replaces the stale region

1. Drag-and-drop list reorder

jst-nav POST · morph

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.

    Wire log 0
    Server sketch (Rails)
    # 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.

    2. Kanban card move across columns

    jst-nav POST · morph · transition

    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.

    Wire log 0
    Server sketch (Rails)
    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.

    3. Click to edit (inline edit)

    jst-nav GET / POST · outerHTML

    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.

      Wire log 0
      Server sketch (Rails)
      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.

      4. Toggle (star), with a dependent counter

      jst-nav POST · morph

      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.

      Wire log 0
      Server sketch (Rails)
      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.

      5. Server-side validation on blur

      jst-nav POST · morph

      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.

      Wire log 0
      Server sketch (Rails)
      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.

      6. Bulk actions

      jst-nav POST · morph

      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.

      Wire log 0
      Server sketch (Rails)
      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.

      7. Delete, with undo carried in the HTML

      jst-nav POST · morph

      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.

      Wire log 0
      Server sketch (Rails)
      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.

      8. Autosave

      jst-nav POST · morph

      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?.

      Wire log 0
      Server sketch (Rails)
      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.

      9. Long job with progress (polling that stops itself)

      jst-nav POST / GET · innerHTML

      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.

      Wire log 0
      Server sketch (Rails)
      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.

      10. Search as you type

      jst-nav GET · innerHTML

      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.

      Server sketch (Rails)
      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.

      Takeaway

      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.