Implementers Guide¶
This guide walks through building a working VDP implementation. The specification defines what conforms; this page is about how — the order to build things in, the decisions you will hit, and the mistakes that are easy to make.
The spec defines three conformance classes, and an implementation may belong to more than one:
| Class | Role | You are building… |
|---|---|---|
| VDP Server | Produces view descriptors | an API that tells clients which templates render its responses |
| VDP Client | Consumes view descriptors | a renderer that resolves descriptors into template trees |
| VDP BFF | A client that renders server-side | a backend-for-frontend returning finished markup |
The Go demo
implements all three in one process and is the reference to read alongside this
guide — its vdp package is a complete client, its server package a complete
server and BFF.
Implementing a VDP Server¶
A server's job is small by design: attach a descriptor to each response. It never fetches templates and never renders.
1. Shape your descriptors¶
A view descriptor names a root template and, optionally, the sub-templates filling its named slots:
{
"template": "example.com/templates/layouts/sidebar",
"slots": {
"mainContent": { "template": "example.com/templates/dashboard" },
"sidebarNav": { "descriptor": "/views/nav.json" }
}
}
Every descriptor you emit MUST validate against the published schema. Wire that into CI from day one — it is the cheapest conformance test you will ever write:
npx ajv-cli test --spec=draft2020 \
-s vdp.v0-2.schema.json -d 'views/*.json' --valid -c ajv-formats
Adapt data with transforms (0.2). When a response's shape differs from a
template's model, declare a transform on
that node — RFC 6901 pointers reshaping the representation into the contract
the template URI implies:
{
"template": "example.com/templates/data-table",
"transform": { "heading": "/dataset/title", "rows": "/data" }
}
Keep two rules in mind. The transform belongs to the descriptor node, never
to the template — the same template URI must mean the same model everywhere.
And the grammar is deliberately just reshaping: filtering, derivation, and
formatting stay in your handlers, your templates, or client-registered
$mapper code, whose URIs your
discovery document should declare under
mappers. Where you control the representation, prefer fixing its shape at
the source over transforming it
(Section 3.8.4).
2. Choose an identifier form¶
A template URI is an identity first — a stable name and cache key — and a fetchable location only secondarily (Section 6.3). The spec defines three forms (Section 5.4):
| Form | Example | Behavior |
|---|---|---|
| (a) Absolute URI | https://example.com/templates/card |
Identity as written |
(b) Relative reference — begins with / or // |
/templates/card |
Resolved against the transport's base URL |
| (c) Scheme-less opaque identifier | example.com/templates/card |
Identity as written; never resolved against a base |
Guidance:
- Use absolute URIs when descriptors may be consumed from multiple base URL contexts (the spec's own SHOULD).
- Use path-absolute references when templates live on the same origin as the API — descriptors stay portable across your environments (dev, staging, production) because the base URL travels with the transport.
- Use opaque identifiers when the URI is a name more than an address — package-import-style identities that clients look up in a bundle or registry, supplying a scheme only if they actually fetch.
Whatever you choose, remember that dot-relative values (../templates/card)
fit no form: anything scheme-less that does not begin with / is an opaque
identifier, so a conforming client will treat .. as a host and reject it. Do
not emit them.
3. Choose a transport¶
Any one of the Section 4 transports satisfies conformance; which fits depends on how much you control the response body:
| Your response body is… | Use | Example |
|---|---|---|
| Flexible JSON (HAL, custom) | Inline _view / _views |
{"_view": {...}, "revenue": 48200} |
| Rigid (OData4, third-party formats) | Link header to a standalone descriptor resource |
Link: <https://example.com/views/dashboard.json>; rel="view-descriptor" |
| Rendered by exactly one template | View-Template header shorthand |
View-Template: example.com/templates/login |
Two transport rules trip people up:
- Emit at most one
Linkvalue withrel="view-descriptor"per response (Section 4.4); clients take the first. - When both an inline descriptor and a
Linkheader are present, the body wins — so do not send both expecting the header to override.
4. Serve descriptor resources well¶
A standalone descriptor is an ordinary cacheable resource
(Section 5). Serve it as
application/vdp+json with real caching headers, and advertise the protocol
version:
HTTP/1.1 200 OK
Content-Type: application/vdp+json
Cache-Control: public, max-age=3600
ETag: "v1-dashboard"
VDP-Version: 0.2
Independent cacheability is the point: the descriptor changes when the presentation changes, the data endpoint when the data does.
5. Publish discovery¶
A discovery document at /.well-known/vdp
(Section 13.2) lets clients prefetch
descriptors and learn your template allowlist:
{
"version": "0.2",
"endpoints": {
"/api/dashboard": { "descriptor": "/views/dashboard.json" },
"/api/products/{id}": { "descriptor": "/views/product-detail.json" }
},
"trustedTemplateUrls": [
"https://example.com/templates/",
"example.com/templates/"
]
}
- Serve it as
application/vdp-discovery+json— never asapplication/vdp+json. - End allowlist entries with a trailing slash, so
…/templates/cannot match…/templates-evil/. - Allowlist matching never crosses identifier forms: an absolute entry matches only absolute URIs, a scheme-less entry only opaque identifiers. List each form you actually emit — the example above lists both.
- Endpoint keys may be Level 1 URI Templates; each
{expression}matches one path segment, and literal keys win over templated ones.
6. Add template metadata where it earns its keep¶
type is an advisory media-type hint; integrity is W3C Subresource
Integrity for the template bytes
(Section 3.6). Publish
integrity for templates hosted on infrastructure you don't control — it
authenticates the content where the allowlist only authenticates the
origin:
{
"template": "https://cdn.example.net/templates/chart-legend",
"type": "text/x-qute",
"integrity": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
}
The digest is computed over the exact bytes clients will fetch —
base64(sha384(body)).
Server checklist¶
- [ ] Every descriptor validates against the published JSON Schema
- [ ] At least one Section 4 transport, following its rules (one
Linkvalue max) - [ ] Descriptor resources served as
application/vdp+jsonwith caching headers - [ ] Absolute template URIs where descriptors cross base-URL contexts
- [ ] Discovery document valid per Section 13.2, served as
application/vdp-discovery+json - [ ] Allowlist entries end with
/and cover every identifier form emitted - [ ] Transforms valid per the Section 3.8.1 grammar; every
$mapperURI declared in discovery
Implementing a VDP Client¶
A client does the real work: extract, resolve, obtain, compose, render — the Section 8 algorithm. Build it in this order.
1. Extract the descriptor¶
Check the response in precedence order:
_view / _views in the body first, then the Link header
(rel="view-descriptor", first value), then View-Template. Record which
transport won — it determines the base URL for relative references:
| Transport | Base URL for /… references |
|---|---|
Standalone descriptor resource (Link) |
the descriptor resource's own URL |
Inline _view / _views |
the API response's URL |
View-Template header |
the API response's URL |
A malformed descriptor is rejected outright (Section 9.3) — fall back to raw data rather than guessing at partial meaning.
2. Classify every template URI¶
This is the step implementations get wrong. Classify each value before touching a URL library:
has a scheme → form (a): identity = the value, as written
begins with "/" → form (b): identity = resolve(value, base)
anything else → form (c): identity = the value, as written — opaque
Never resolve an opaque identifier against the base URL
General-purpose URL resolution treats example.com/templates/card as a
relative path. Resolved against https://api.example.org/dashboard, it
silently becomes https://api.example.org/example.com/templates/card — a
corrupted identity that may even pass a sloppy allowlist before 404ing.
The spec forbids this: a scheme-less value not beginning with / names
the template directly and is compared and cached verbatim.
Watch your standard library, too: Go's url.Parse errors on
127.0.0.1:8080/templates/card ("first path segment in URL cannot
contain colon"), and JavaScript's new URL(value, base) happily
mis-resolves it. Branch on the form first; parse afterwards.
The identity from this step — resolved absolute URL or verbatim opaque identifier — is the template's cache key and comparison key everywhere downstream. A scheme is supplied only if and when you fetch (Section 6.3).
3. Obtain templates — from anywhere¶
The identifier tells you which template; your deployment decides where its source text comes from. All of these are equally conforming (Section 6.3, and see the deployment scenarios):
- a bundle shipped inside the application package,
<template>elements delivered with the page,- a store local to the BFF or a template service,
- a network fetch of the template URI itself.
Select by identity, whatever the source — a template satisfied locally is indistinguishable, to the rest of the algorithm, from a fetch whose cache was warm. Network fetch is the interoperable default when no local source has the template, and every network retrieval is subject to Section 10.
4. Enforce trust before any fetch¶
Rendering arbitrary templates is code injection. Before fetching, validate the identity against the Section 10 allowlist chain — first source available wins:
- Local configuration — your own allowlist, when present.
- Discovery document — the API's
trustedTemplateUrls. - Same-origin default — only identities sharing the descriptor's origin.
Matching (Section 13.2) is prefix matching after normalization. Practical notes:
- Compare on path-segment boundaries, so a
…/templatesentry does not match…/templates-evil. - Lowercase the scheme and host before comparing; paths stay case-sensitive.
- Forms never cross-match: opaque identifiers match only scheme-less entries, absolute URIs only absolute entries.
- Reject before fetching. An untrusted template must never be fetched-then-discarded.
And the transport rule: network retrieval MUST use HTTPS, with plain HTTP acceptable only for loopback during development. Templates from inside your own trust boundary (a bundle, the page) are exempt from all of Section 10.
5. Verify integrity¶
When a descriptor carries integrity, verify any template you fetched over
the network against it — W3C SRI semantics: strongest algorithm present wins,
any one matching digest of that algorithm passes, unknown algorithms are
ignored. A mismatch is a fetch failure for that slot
(Section 9.1), not a warning.
6. Compose, and fail small¶
Walk the descriptor recursively — obtain the root template, fill each slot, recurse (Section 8) — under the principle prefer partial rendering over total failure (Section 9):
- A slot whose template cannot be obtained is skipped; the template's default content shows instead. The rest of the tree still renders.
- A failed element of a slot array is skipped; the remaining elements render in declared order.
- Only a root template failure fails the render — fall back to raw data or an error template.
- Slot names with no matching insertion point are ignored (log them).
- Render per node (0.2). A node with a
transformreceives exactly the transform result; without one, the representation unchanged. Evaluate every node's transform against the original response (with_view/_viewsstripped) — never against an ancestor's output (Section 3.8.2). Missing pointers and wrong-type targets yieldnulland are not errors. - Never render past a failed transform. An unrecognized
$mapperURI is a slot failure; on the root node, show an error template only — falling back to untransformed data would be silently wrong output, worse than an error (Section 9.6). - Impose a recursion depth limit (10 is the recommendation); descriptor references count toward it, and a reference chain that revisits a URL is a cycle that abandons just that slot.
Cache aggressively (Section 5.2): descriptors and templates are ordinary HTTP resources, keyed by identity.
Client checklist¶
- [ ] Extraction follows Section 4.4 precedence
- [ ] The three Section 5.4 identifier forms classified correctly — opaque ids never base-resolved
- [ ] Templates selected and cached by identity, from any Section 6.3 source
- [ ] Allowlist chain enforced before every network fetch; no cross-form matching
- [ ] HTTPS enforced for network retrieval (loopback excepted)
- [ ]
integrityverified when present; mismatch treated as fetch failure - [ ] Partial rendering on slot failure; fallback on root failure; depth limit and cycle detection
- [ ] Invalid descriptors rejected — including malformed transforms and unrecognized members not prefixed
x-(Section 3.10); unrecognized discovery members ignored - [ ] Inline transform evaluation implemented in full;
$mapperoptional, matched verbatim against your registry - [ ] Transforms evaluated against the original representation; templates receive exactly the transform result
- [ ] JSON objects parsed order-preservingly where
$entriesresults render
Implementing a VDP BFF¶
A BFF is a VDP Client that runs server-side (Section 7.5): it meets every client requirement above in its role as consumer of upstream APIs, then returns finished markup. VDP places no constraints on the interface it exposes downstream — the browser never sees a descriptor.
What changes in practice:
- Negotiate for your platform. Send
VDP-Platform(and standard content negotiation) on descriptor fetches so the API can select the right template tree (Section 5.5). - Cache across users. Descriptors and templates are shared, cacheable state; per their HTTP headers, one warm cache serves every request.
- Keep failure behavior. Section 9's partial rendering applies to the page you assemble: a dead slot ships as the template's default, not as a 500.
Testing your implementation¶
Servers: schema-validate every descriptor and discovery document you emit
(the ajv commands mirror the spec repo's CI), then
check the transport rules — exactly one Link value, correct media types,
VDP-Version where you advertise support.
Clients: the hard cases are the failure paths and the identifier forms. The Go demo serves ready-made vectors — run it locally and point your client at:
| Endpoint | Exercises | Your client should |
|---|---|---|
/api/dashboard |
Link transport, form (b) refs, §3.7 reference, integrity, per-slot transforms | Render a four-level tree, each node against its own model |
/api/dashboard?fail=chart |
One slot's template 404s | Skip the slot, render the rest |
/api/dashboard?fail=root |
Root template 404s | Fall back to raw data |
/api/dashboard?fail=integrity |
SRI mismatch | Treat as fetch failure, skip the slot |
/api/dashboard?untrusted |
Off-allowlist template | Reject without fetching |
/api/odata/products |
Link + OData annotation, form (c) opaque identifier | Keep the identifier verbatim as cache key |
/api/login |
View-Template shorthand |
Render the single template |
/api/products/42?view=compact |
Multiple named views, per-view transform | Select the requested view, default otherwise |
/api/summary |
$mapper transform, discovery mappers |
Dispatch to registered mapper code |
/api/summary?fail=mapper |
Unregistered $mapper on the root |
Error template only — never the raw data (§9.4 rule 2) |
/api/dashboard/stats |
Identity default — representation already matches the contract | Render with no transform declared |
And remember what is deliberately not VDP's job — do not build it into
your implementation: conditional slot logic and template parameters belong to
the server's descriptor choice or the template engine, never to the protocol.
Data-to-template mapping is in scope as of 0.2, but only as declarative
reshaping — if you find yourself wanting filtering, computation, or an
expression language in a descriptor, that logic belongs server-side or in
client-registered $mapper code
(Design Decisions).