Pattern: progressive enhancement with Unpoly — hypermedia, no SPA

When to reach for this. Your dynamic space has links and forms and you want them to feel instant — swap a fragment instead of a full page reload — without a client framework, a build step, or a JSON API. Unpoly is the platform's default interactivity layer: you write plain server-rendered HTML, and Unpoly upgrades it in place. This is the pattern the moment you add a second navigable route or a form that should update part of the page.

The stance. The page is hypermedia first. Every link has a real href; every form has a real method + action and works as a normal submit. With JavaScript off it's a plain multi-page app. Unpoly enhances — it intercepts those same links/forms, fetches over fetch, and swaps a named fragment. You never hand-write fetch/addEventListener for navigation. Nothing you build depends on the swap succeeding; the swap is gravy on top of working HTML.

Load it — npm dep bundled from src/client.ts

Declare unpoly in package.json and import both the library and its CSS in the client entry (bundled to /client.js — see bundling; .css imports bundle as strings):

// src/client.ts — bundled for the browser, served at /client.js
import unpolyCss from "unpoly/unpoly.min.css";   // root path, NOT /dist/ — a wrong path fails the build
import up from "unpoly";                         // auto-boots on DOMContentLoaded

const style = document.createElement("style");
style.textContent = unpolyCss;
document.head.prepend(style);
window.up = up;                                  // expose for inline up:* handlers + console
<head>
  <script type="module" src="/client.js"></script>
</head>

Links vs forms — the distinction that silently breaks everything

This is the one rule that, gotten wrong, produces the most confusing Unpoly error there is. Unpoly has two different attributes for two different elements:

Element Attribute What Unpoly does
<a> (a link) up-follow fetches the link's href and swaps the target
<form> (a form) up-submit submits the form's method+action and swaps the target
<a    href="/archive" up-follow up-target="#main">Archive</a>          <!-- link  → up-follow -->
<form method="post" action="/posts" up-submit up-target="#main">…</form>  <!-- form  → up-submit -->

Never put up-follow on a <form>. A form has no href, so Unpoly's follow logic calls up.render() with no URL and throws:

up.: up.render() needs either { url, response, content, fragment, document } option
    at assertContentGiven … at HTMLFormElement.<anonymous>

That HTMLFormElement.<anonymous> in the stack is the tell: a form triggered a follow. The fix is always up-followup-submit on that form. (The symmetric mistake — up-submit on an <a> — just no-ops.) This single swap is the most common "form submission doesn't work" bug on the platform.

The swap target must exist in the response

up-target="#main" names the fragment to replace. Unpoly finds #main in the server's response and swaps just that node in. So:

  • Pick one target id per surface and put it in that surface's shell so it's always present: #main for the public site, #manage-main for an admin shell. <main id="main">{children}</main>.
  • For links, #main must exist on every destination page too, or the swap silently no-ops (the click appears dead).
  • Returning the whole page (full <html> with the shell) is fine — Unpoly extracts #main from it. You don't have to return a bare fragment.

Server handlers — return the fragment, never c.redirect

A handler behind an up-submit/up-follow must answer with HTML that contains the target (200). A c.redirect(...) is a bodyless 303 — Unpoly has nothing to render and throws the same up.render() needs … content error. So this is the POST-then-render loop, not POST-then-redirect:

// classic HTML would 303 here; behind up-submit, RE-RENDER and return the fragment.
this.app.post("/posts", async (c) => {
  const saved = await savePost(await c.req.formData());
  return c.html(String(<Shell><Editor post={saved} /></Shell>), 200, {
    "X-Up-Location": `/posts/${saved.id}/edit`,   // move the address bar (optional)
    "X-Up-Method": "GET",                          // …to a GET URL, without a round-trip
  });
});

X-Up-Location (+ X-Up-Method: GET) is how a create/save "navigates" the URL while still returning the fragment inline — the durable replacement for the redirect you'd write in a plain MPA.

(Optional optimization — content negotiation. Unpoly sends an X-Up-Target header on its requests. You may branch on it to return just the fragment for Unpoly and the full shell for a no-JS request — but returning the full shell in both cases already works, so reach for this only when the shell is expensive.)*

Re-run enhancements after a swap — event delegation + up:fragment:inserted

A fragment swap replaces DOM nodes, so anything you wired to those nodes is gone. Two rules keep enhancement alive across swaps:

  • Delegate document-level listeners, not per-node ones — a click handler on document (matching with event.target.closest(...)) survives every swap:
    document.addEventListener("click", (e) => {
      const chip = e.target.closest("[data-md-path]");
      if (chip) { /* insert media into the textarea, etc. */ }
    });
    
  • Re-apply one-shot enhancements on insert — syntax highlighting, etc., only ran on first paint; run them again on the new fragment:
    import hljs from "highlight.js";   // npm dep, bundled in src/client.ts
    const highlight = (root) => root.querySelectorAll("pre code").forEach((el) => {
      if (!el.dataset.hl) { hljs.highlightElement(el); el.dataset.hl = "1"; }
    });
    highlight(document);
    up.on("up:fragment:inserted", (_e, fragment) => highlight(fragment));
    

The small, high-value attributes

  • up-nav on a nav → Unpoly adds aria-current/.up-current to the active link automatically. <nav up-nav>…</nav>.
  • up-confirm="Delete this?" on a link/button → a native confirm before the request. Great on a delete form's submit button.
  • up-layer="new" → open the target in an overlay (modal/drawer) instead of swapping in place; up-accept/up-dismiss hand a value back to the opener. The basis of a confirm dialog — for picking media, borrow the platform's own picker over postMessage instead of building one (see the place.* section of the run-code doc).

Debugging up.render() needs … content — three questions, in order

  1. Is it a <form> with up-follow? → change it to up-submit. (Most common.)
  2. Does the handler c.redirect(...)? → return the target fragment (200) instead; use X-Up-Location if the URL must change.
  3. Is #target actually in the response? → wrong id, or a different layout that omits the shell. Make the response include <… id="target">.

Ship it

Author real links (href) and forms (method+action) first — verify they work with JS disabled. Then add up-follow to links, up-submit to forms, one up-target, and a shell that always renders it. Publish, click around, and watch the console: a clean console with in-place swaps means the enhancement layer is sound.

Next: unfolder.design-system (the components these routes render), and a long author Cache-Control on the pages that rarely change (or ship them as plain files with unfolder.static-site if they never change per request).