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-follow → up-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:
#mainfor the public site,#manage-mainfor an admin shell.<main id="main">{children}</main>. - For links,
#mainmust 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#mainfrom 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 withevent.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-navon a nav → Unpoly addsaria-current/.up-currentto 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-dismisshand a value back to the opener. The basis of a confirm dialog — for picking media, borrow the platform's own picker overpostMessageinstead of building one (see theplace.*section of therun-codedoc).
Debugging up.render() needs … content — three questions, in order
- Is it a
<form>withup-follow? → change it toup-submit. (Most common.) - Does the handler
c.redirect(...)? → return the target fragment (200) instead; useX-Up-Locationif the URL must change. - Is
#targetactually 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).