Status: Working Draft Version: 0.1.0
The View Descriptor Protocol (VDP) defines a standard mechanism for associating API data responses with the templates that should render them. A view descriptor is a JSON structure that identifies a root template by URL and declares how sub-templates compose into named slots, forming a recursive template tree. View descriptors can be transported via HTTP headers (for constrained formats like OData4) or inline in the response body (for flexible formats like HAL+JSON). The protocol is framework-agnostic — templates can be HTML/Qute, SwiftUI views, Compose layouts, or any other rendering format.
REST APIs return structured data (JSON, XML) that carries no presentation information. The client must independently decide how to render this data — typically by hardcoding template choices into client logic. This creates tight coupling between API consumers and their rendering layer.
VDP solves this by letting the server declare:
- Which template(s) to use for rendering a response
- How templates compose together (which sub-template fills which slot)
VDP explicitly does NOT define:
- How templates bind to data (that is the template engine's job — Qute expressions, JSONPath, etc.)
- Styling or CSS class information (that belongs in the template itself)
- Client-side state management
- View Descriptor: A JSON object that describes a template tree — a root template URL and its slot assignments.
- Template URL: A URL identifying a template resource. The URL MUST resolve to a renderable template in the client's rendering framework.
- Slot: A named insertion point in a template where a sub-template can be composed. Slot names correspond to the template's own insertion point identifiers (e.g., Qute's
{#insert slotName}, HTML's<slot name="slotName">). - View Descriptor Resource: A standalone JSON document containing a view descriptor, addressable by its own URL, cacheable independently of the data it describes.
- Static Composition: Template includes that are hardcoded within the template itself (e.g., a layout always including its
_head.htmlpartial). VDP does not manage these — they are the template's internal concern. - Dynamic Composition: Template slots whose content varies per API response. VDP manages these.
The simplest view descriptor points to a single template with no slots:
{
"template": "https://example.com/templates/article.html"
}When a template has named insertion points that should be filled dynamically, the view descriptor declares a slots object. Each key is a slot name matching an insertion point in the template, and each value is itself a view descriptor:
{
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/layouts/sidebar.html",
"slots": {
"mainContent": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/card.html"
},
"sidebarNav": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/navigation/nav.html"
}
}
}This tells the client: "Render sidebar.html, and fill its mainContent slot with card.html and its sidebarNav slot with nav.html."
Since each slot value is itself a view descriptor, composition nests to arbitrary depth:
{
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/layouts/sidebar.html",
"slots": {
"mainContent": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/demos/dashboard.html",
"slots": {
"statsRow": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/card.html"
},
"activityTable": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/table.html"
},
"chart": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/charts/chart.html",
"slots": {
"legend": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/charts/chart-legend.html"
}
}
}
}
},
"sidebarNav": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/navigation/nav.html"
}
}
}A single API response may offer multiple views (e.g., a summary view and a detail view, or views for different device classes). Use a named object at the top level:
{
"views": {
"default": {
"template": "https://example.com/templates/product-detail.html"
},
"compact": {
"template": "https://example.com/templates/product-card.html"
},
"mobile": {
"template": "https://example.com/templates/product-mobile.html",
"slots": {
"gallery": {
"template": "https://example.com/templates/components/swipe-gallery.html"
}
}
}
}
}When only a single view is needed, the top-level object IS the view descriptor (no views wrapper). When multiple views are present, the views key wraps them. A client SHOULD use default when no specific view is requested.
A single slot can accept multiple templates, rendered in sequence within the insertion point. This is useful when composing multiple independent components into a single region:
{
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/layouts/sidebar.html",
"slots": {
"mainContent": [
{
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/card.html"
},
{
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/charts/chart.html"
},
{
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/table.html"
}
],
"sidebarNav": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/navigation/nav.html"
}
}
}Each element in the array is a full view descriptor and can itself have nested slots. The client MUST render array elements in order.
ViewDescriptor = { "template": TemplateURL, "slots"?: Slots }
TemplateURL = URI (RFC 3986)
Slots = { SlotName: SlotValue, ... }
SlotName = string (matches an insertion point in the template)
SlotValue = ViewDescriptor | ViewDescriptor[]
MultiViewDescriptor = { "views": { ViewName: ViewDescriptor, ... } }
ViewName = string
A valid VDP payload is either a ViewDescriptor or a MultiViewDescriptor.
VDP supports two transport modes. Servers MAY use either or both.
The server responds with a Link header pointing to a view descriptor resource:
HTTP/1.1 200 OK
Content-Type: application/json
Link: <https://example.com/views/dashboard.json>; rel="view-descriptor"
{"revenue": 48200, "users": 1847, "orders": 312}The client fetches https://example.com/views/dashboard.json to get the view descriptor. This approach:
- Keeps the data payload completely clean
- Works with any data format (JSON, XML, OData4, GraphQL, Protocol Buffers)
- The view descriptor resource is independently cacheable
- Uses existing web standards (RFC 8288 Link Relations)
For simple cases (single template, no composition), a shorthand header is also defined:
View-Template: https://example.com/templates/article.htmlWhen View-Template is present, it is equivalent to {"template": "<URL>"}. If both Link (with rel="view-descriptor") and View-Template are present, the Link header takes precedence.
When the data format is flexible (e.g., HAL+JSON, custom APIs), embed the view descriptor directly using the _view key:
{
"_links": {
"self": { "href": "/api/dashboard" }
},
"_view": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/demos/dashboard.html",
"slots": {
"statsRow": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/card.html"
}
}
},
"revenue": 48200,
"users": 1847,
"orders": 312
}The _view key follows HAL's underscore convention for protocol-level metadata.
For multiple views, use _views:
{
"_views": {
"default": {
"template": "https://example.com/templates/dashboard-full.html",
"slots": { ... }
},
"widget": {
"template": "https://example.com/templates/dashboard-widget.html"
}
},
"revenue": 48200,
"users": 1847
}OData4 responses have a rigid structure but support custom instance annotations. Use an annotation to reference a view descriptor resource:
{
"@odata.context": "https://example.com/odata/$metadata#Products",
"@View.descriptor": "https://example.com/views/product-list.json",
"value": [
{ "ProductID": 1, "Name": "Widget", "Price": 9.99 },
{ "ProductID": 2, "Name": "Gadget", "Price": 24.99 }
]
}Alternatively, use the Link header approach (Section 4.1) to avoid touching the OData body entirely.
When a view descriptor is provided via multiple mechanisms, precedence is:
- Inline body (
_view/_views) — most specific Linkheader withrel="view-descriptor"View-Templateheader
View descriptor resources SHOULD be served with:
Content-Type: application/vdp+json
View descriptor resources are independently cacheable. Servers SHOULD provide standard HTTP caching headers:
HTTP/1.1 200 OK
Content-Type: application/vdp+json
Cache-Control: public, max-age=3600
ETag: "v2-dashboard"
{
"template": "https://example.com/templates/dashboard.html",
"slots": { ... }
}Template URLs themselves are also cacheable resources. Clients SHOULD cache resolved templates according to their HTTP caching headers.
View descriptors can be versioned by URL convention:
https://example.com/views/v2/dashboard.json
https://example.com/views/dashboard.json?v=2
Or by content negotiation using the Accept header with a version parameter:
Accept: application/vdp+json; version=2
VDP is agnostic to the template language. However, templates used with VDP MUST satisfy one requirement: named insertion points (slots) that can be filled externally.
| Framework | Slot Mechanism | Example |
|---|---|---|
| Qute | {#insert slotName}{/insert} |
{#insert mainContent}Default{/insert} |
HTML <template> |
<slot name="slotName"> |
<slot name="mainContent"></slot> |
| HTMT | ht-template="slotName" |
<div ht-template="mainContent"></div> |
| Thymeleaf | th:fragment / th:replace |
<div th:replace="~{slotName}"></div> |
| JSX/React | props.children or named props |
{props.mainContent} |
| SwiftUI | @ViewBuilder parameters |
var mainContent: () -> Content |
| Jetpack Compose | @Composable slot parameters |
mainContent: @Composable () -> Unit |
Not all insertion points in a template need to be managed by VDP. Templates commonly include static partials (like a shared _head.html or a footer) that are hardcoded. Only slots that vary per API response need to appear in the view descriptor.
API Response:
HTTP/1.1 200 OK
Content-Type: application/json
View-Template: https://github.com/SiteNetSoft/quarkus-pha/templates/components/forms/form.html
{
"csrfToken": "abc123",
"loginUrl": "/auth/login",
"fields": [
{ "name": "username", "type": "text", "label": "Username", "required": true },
{ "name": "password", "type": "password", "label": "Password", "required": true }
]
}API Response:
HTTP/1.1 200 OK
Content-Type: application/hal+json
Link: <https://github.com/SiteNetSoft/quarkus-pha/views/dashboard.json>; rel="view-descriptor"
{
"_links": { "self": { "href": "/api/dashboard" } },
"stats": { "revenue": 48200, "users": 1847, "orders": 312 },
"recentActivity": [
{ "user": "alice", "action": "purchase", "item": "Widget Pro", "time": "2m ago" },
{ "user": "bob", "action": "signup", "time": "15m ago" }
],
"chartData": { "labels": ["Mon","Tue","Wed","Thu","Fri"], "values": [12,19,3,5,2] }
}View Descriptor Resource (dashboard.json):
{
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/layouts/sidebar.html",
"slots": {
"sidebarNav": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/navigation/nav.html"
},
"mainContent": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/demos/dashboard.html",
"slots": {
"statsCards": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/card.html"
},
"activityTable": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/table.html"
},
"revenueChart": {
"template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/charts/chart.html"
}
}
}
}
}HTTP/1.1 200 OK
Content-Type: application/json;odata.metadata=minimal
Link: <https://example.com/views/product-list.json>; rel="view-descriptor"
{
"@odata.context": "https://example.com/odata/$metadata#Products",
"value": [
{ "ProductID": 1, "Name": "Widget", "Price": 9.99 },
{ "ProductID": 2, "Name": "Gadget", "Price": 24.99 }
]
}Data payload is pure OData4. The view descriptor is communicated entirely via the Link header.
{
"_views": {
"default": {
"template": "https://example.com/templates/product-detail.html",
"slots": {
"gallery": {
"template": "https://example.com/templates/components/image-carousel.html"
},
"reviews": {
"template": "https://example.com/templates/components/review-list.html"
}
}
},
"compact": {
"template": "https://example.com/templates/product-card.html"
}
},
"id": 42,
"name": "Widget Pro",
"price": 29.99,
"images": ["front.jpg", "side.jpg", "back.jpg"],
"reviews": [
{ "author": "Alice", "rating": 5, "text": "Excellent!" }
]
}A BFF receives an API response and a view descriptor. Instead of forwarding both to the browser, the BFF resolves the template tree server-side and returns rendered HTML:
Browser → GET /dashboard
BFF → GET /api/dashboard (receives data + Link header with view descriptor)
BFF → Fetches view descriptor
BFF → Fetches templates (with caching)
BFF → Renders composed template tree with data (using Qute, Thymeleaf, etc.)
BFF → Returns rendered HTML to browser
This is the pattern used by quarkus-pha: Quarkus acts as the BFF, fetching data and resolving Qute templates server-side.
- Extract view descriptor from the response (check
_view/_viewsbody key, thenLinkheader, thenView-Templateheader). - Fetch the view descriptor if it is a URL reference (cache as appropriate).
- Fetch the root template from the
templateURL. - Identify slot insertion points in the template.
- For each slot declared in the view descriptor:
a. Fetch the sub-template from its
templateURL. b. If the sub-template's view descriptor hasslots, recurse (go to step 4). c. Insert the resolved sub-template into the slot. - Render the composed template tree with the API response data.
Clients SHOULD impose a maximum recursion depth (RECOMMENDED: 10 levels) to prevent unbounded nesting.
- Template URL validation: Clients MUST validate template URLs against an allowlist of trusted domains. Rendering arbitrary templates from untrusted sources is a code injection risk.
- CORS: Template resources served cross-origin MUST include appropriate CORS headers.
- Content Security Policy: Template URLs SHOULD be included in the
script-srcorstyle-srcCSP directives as appropriate. - Template sandboxing: Clients SHOULD render templates in a sandboxed context to prevent template injection attacks.
- HTTPS: Template URLs MUST use HTTPS in production. Clients SHOULD reject HTTP template URLs.
| Standard | Relationship |
|---|---|
| REST | VDP extends REST responses with view metadata without modifying the resource representation itself |
| HAL (RFC draft) | VDP uses HAL's underscore convention (_view) for inline transport. Compatible with _links and _embedded |
| JSON-LD | VDP can coexist with @context/@type annotations. Template URLs could be expressed as JSON-LD @id values |
| OData4 | VDP uses OData4 instance annotations (@View.descriptor) or HTTP headers for compatibility |
| RFC 8288 (Web Linking) | VDP defines the view-descriptor link relation type for the Link header |
| HATEOAS | VDP is complementary — HATEOAS tells clients what actions are available, VDP tells clients how to render the result |
This specification requests registration of:
- Relation Name:
view-descriptor - Description: Refers to a VDP view descriptor resource that describes how to render the linked resource.
- Reference: This specification
- Type name: application
- Subtype name: vdp+json
- Required parameters: None
- Optional parameters:
version - Reference: This specification
APIs SHOULD advertise VDP support so clients can detect it programmatically.
An API endpoint supporting VDP MUST include the VDP token in the Allow or a custom header in its OPTIONS response:
OPTIONS /api/dashboard HTTP/1.1
HTTP/1.1 204 No Content
Allow: GET, HEAD, OPTIONS
VDP-Support: true
VDP-Version: 0.1APIs MAY expose a discovery document at /.well-known/vdp:
GET /.well-known/vdp HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/vdp+json
{
"version": "0.1",
"endpoints": {
"/api/dashboard": {
"template": "https://example.com/views/dashboard.json"
},
"/api/products": {
"template": "https://example.com/views/product-list.json"
}
},
"trustedTemplateDomains": [
"https://github.com/SiteNetSoft/quarkus-pha"
]
}This allows clients to prefetch view descriptors and preload templates before making data requests. The trustedTemplateDomains field provides the template URL allowlist referenced in Section 9.
For APIs documented with OpenAPI 3.x, VDP metadata can be declared using the x-vdp extension:
paths:
/api/dashboard:
get:
summary: Get dashboard data
x-vdp:
view-descriptor: "https://example.com/views/dashboard.json"
responses:
'200':
description: Dashboard data
headers:
Link:
description: View descriptor reference
schema:
type: stringThe following questions were considered and resolved during the design of this specification:
-
Conditional slots (e.g., "use template A for admins, B for guests"): Not in scope. Authorization logic belongs on the server. The server sends different view descriptors based on the user's role. VDP is purely declarative — it describes what to render, not when or for whom.
-
Template parameters (e.g., passing
{"compact": true}to a template): Not in scope. VDP declares which templates to use, nothing more. Configuration, styling, and data binding are the template engine's responsibility. Keeping VDP minimal ensures it works across all rendering frameworks without making assumptions about their capabilities. -
Data-to-template mapping (e.g., specifying which JSON fields feed which template): Not in scope. Templates are responsible for extracting data from the API response using their own mechanisms (Qute expressions, JSONPath, data attributes, etc.). VDP maintains a clean separation between template selection and data binding.
Gaps in the spec itself:
Connections to your other projects:
Not missing but could come later (v0.2+):
My honest assessment: What you have is a publishable v0.1 working draft. The spec does one thing well and stays out of everything else. The biggest value-add from here would be a reference implementation in quarkus-pha — that would make the spec concrete and expose any design issues faster than more spec writing
would.