Actions Are Hyperlinks (HATEOAS-Lite)
Each section arrives carrying the exact URLs it may call as `{ url, method }` links; the frontend follows them and never builds a route by hand.
Actions are hyperlinks
A href (short for hypertext reference) is simply a URL the server hands you to follow — the same way a link on a web page is a URL your browser follows when you click it. HATEOAS — Hypermedia As The Engine Of Application State — is the idea that the server sends the client not just data but also the links it is allowed to follow next, so the client navigates by following links rather than by constructing addresses from rules it has memorized. Server-Driven UI (the pattern where the server sends a list of typed UI descriptions and the client just renders them) uses a lightweight version of this.
Every section in the page comes with the precise set of URLs it may call, and the frontend's only job is to follow them. The client never assembles an endpoint string like `/folders/${id}/images` by concatenating pieces it knows; it takes the URL the backend already wrote and sends the request. That single boundary is what keeps the client dumb and the backend in charge: the backend owns the API design, and the client owns only how a section looks.
Rule of thumb: if you catch the frontend building a URL from string pieces, you have leaked API design into the client — the point of the pattern is that the backend hands over every link.
The actions map: a named set of hrefs
Each section carries an actions object: a named map where each key is an intent (load, read_multiple, create_one, update_one, delete_one) and each value is a { url, method } href. This is the whole data-fetching contract for the section — nothing more, nothing less.
{
"id": "images_section",
"options": { "selectable": true },
"actions": {
"load": { "url": "/folders/42/sections/images", "method": "GET" },
"read_multiple": { "url": "/folders/42/images", "method": "GET" },
"create_one": { "url": "/folders/42/images", "method": "POST" },
"update_one": { "url": "/images/<int:id>", "method": "PATCH" },
"delete_one": { "url": "/images/<int:id>", "method": "DELETE" }
}
}
The load href fetches the section itself; then the read/create/update/delete hrefs drive the data inside it. Because the names are stable intents, the frontend component asks for actions.read_multiple without ever knowing or caring what the URL behind it is — the backend can move the route and the client does not change.
| Intent | Method | What it does |
|---|---|---|
load | GET | Fetch the section and its option bag |
read_multiple | GET | Read the list of items the section shows |
create_one | POST | Add one item |
update_one | PATCH | Change one item (URL carries a placeholder) |
delete_one | DELETE | Remove one item (URL carries a placeholder) |
images_section ├── load GET /folders/42/sections/images ├── read_multiple GET /folders/42/images ├── create_one POST /folders/42/images ├── update_one PATCH /images/<int:id> └── delete_one DELETE /images/<int:id>
Typed placeholders and parseActions
Some hrefs cannot be complete when the backend writes them, because they need a value only the running page knows — the id of the row you clicked. So the backend leaves a typed placeholder in the URL: <int:folder_id> means "an integer named folder_id goes here". A placeholder is a labeled hole in a template; the frontend fills it from the current route's parameters before sending the request.
The rule is one line and it is the same on every page: take the route's params, replace each matching placeholder in the URL, and whatever param has no matching placeholder becomes a query-string argument.
// UPDATE one — inject the row id into the href's placeholder first
const url = actions.image_update_one.url.replace(RoutePlaceholder.Id, id);
await httpClient.request({ ...actions.image_update_one, url, data });
Because this resolution rule is shared, a section author never thinks about it. They reference actions.image_update_one, and parseActions guarantees the placeholder is filled the same way it is on every other page.
Write, then revalidate with mutate()
A write changes data on the server, but the list the user is looking at was fetched earlier and is now stale. To revalidate means to re-fetch that data so the on-screen view matches the server again. The convention is mutate(): after a successful write, call it to re-read the read_multiple href and refresh the list.
await httpClient.request({ ...actions.image_create_one, data: payload });
await mutate(); // re-read read_multiple so the new item appears
This keeps the flow honest: the client does not optimistically guess what the server now holds and paint it locally; it writes, then asks the server again by following the same read href it already had. One write, one revalidate — the list is always what the backend actually returned.
The end-to-end round trip
Putting it together, a whole screen is one conversation over the JSON seam. The frontend follows the page's load href; the backend answers with sections, each carrying its own action hrefs; each section follows its own hrefs to fill itself; and a write is a POST followed by a mutate() re-read. The frontend owns only how each section looks — the backend owns what to render and where the data lives.
Every arrow in that diagram is the client following a link the backend handed it. There is no place where the frontend invented a route — which is exactly why adding a new endpoint on the backend, or moving an existing one, changes nothing on the client.
Key takeaways
- A href is a
{ url, method }link the server hands the client to follow; the client follows links instead of constructing URLs (HATEOAS-lite). - Every section carries an
actionsmap of named intents —load,read_multiple,create_one,update_one,delete_one— each an href. - Section code references intents (
actions.read_multiple), never raw URLs, so the backend can move a route without touching the client. - One shared
httpClientattaches auth, unwraps the envelope, and routes errors centrally, so section components stay tiny. - Typed placeholders like
<int:folder_id>are filled from route params by one shared rule (url.replace); leftover params become a query string. - After a write,
mutate()re-reads the list so the view matches the server — write, then revalidate. - A whole screen is one round trip over the JSON seam: follow
page.load, get sections + hrefs, follow them, and POST-then-mutate to write.
Checklist
- [ ] I can explain what a href is and what HATEOAS-lite means in this context.
- [ ] I can read a section's
actionsmap and name what each intent does. - [ ] I understand why section code references intents instead of raw URLs.
- [ ] I can describe the three jobs of the shared
httpClient(auth, envelope, errors). - [ ] I can resolve a typed placeholder like
<int:folder_id>from route params and explain where leftover params go. - [ ] I know why a write is followed by
mutate()and what revalidation means. - [ ] I can trace the end-to-end round trip from
page.loadto a POST-then-mutate write.