Server side
Affordant is symmetric. The client reads the _self / _actions envelope; the server emits it. Any backend — any language, any framework — that emits the envelope works, but in Node you don't have to hand-roll it: @affordant/server is the server-side mirror of the client.
Two rules make it worth the effort.
1. Link visibility is authorization
Only emit an action the caller may execute, decided server-side, per response. The endpoint that builds the resource already knows the caller's identity, the resource's state, and the active feature flags — so it is the right place to decide which actions to expose.
With @affordant/server, you declare each affordance once and gate it with when:
import { resource } from '@affordant/server'
function serializeOrder(order, caller, route) {
return resource(order)
.self(route('orders.show', order.id))
.action('track', route('orders.tracking', order.id))
.action('cancel', route('orders.cancel', order.id), {
method: 'POST',
when: caller.id === order.ownerId && order.status !== 'shipped',
})
.build()
}When when is false, the rel is not emitted — so the client's can(order, 'cancel') returns false. The frontend never re-implements that if. Presence of the link is the permission.
2. URLs come from your router
Generate every href from a named route, never a hardcoded string. Renaming or remounting a route then updates every link automatically, and clients follow along without a deploy.
That route(...) function is the one framework-coupled piece, so it stays injected — keeping @affordant/server framework-agnostic. Thin adapters wire it up:
@affordant/expresssends the envelope from a controller and builds absolute URLs from the request.- More adapters (Fastify, Nest, Hono, …) follow the same shape.
Doing it by hand
You never have to use @affordant/server. Any code that returns the shape below is a valid producer:
return {
...order,
_self: { href: route('orders.show', order.id), method: 'GET' },
_actions: caller.id === order.ownerId
? { cancel: { href: route('orders.cancel', order.id), method: 'POST' } }
: {},
}Any language, any stack
The envelope is the only contract — @affordant/server is a convenience, not a requirement. A backend in pure Node JS can emit it with a plain object literal and no Affordant dependency at all. So can a backend in any other language: Python, Go, Ruby, .NET. The client only cares about the _self / _actions JSON it receives, so a companion helper on any stack is just ergonomics over the same wire contract.
Checklist for emitting the envelope
- Every resource carries
_self(so clients can refresh it) and_actions(possibly empty). - Each action is
{ href, method, accepts? }. Setacceptswhen the body is notapplication/json. - Decide each action's presence from authoritative server state — identity, resource state, feature flags.
- Build
hreffrom named routes; never concatenate strings on the client side of the wire.
That's the whole contract. The client side is documented in the wire contract.