Skip to content

Collection+JSON — Hypermedia Format for Collection-Oriented APIs

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Collection+JSON. We cover key concepts, practical examples, and best practices to help you master this topic.

Collection+JSON is a hypermedia JSON format designed for collection-oriented APIs, providing standardized structures for listing, searching, creating, updating, and deleting items with hypermedia controls.

What You'll Learn

  • Collection+JSON document structure
  • Query templates for search/filter
  • Write templates for create/update
  • Pagination with collection links
  • Error representation
  • Implementing Collection+JSON in Node.js

Why It Matters

Many REST APIs are fundamentally about collections — devices, threats, users. Collection+JSON provides a standardized way to represent these collections with hypermedia controls, making CRUD operations discoverable. DodaTech's Durga Antivirus Pro uses Collection+JSON for its threat list API, letting clients discover filtering, sorting, and creation options dynamically.

Real-World Use

A client fetches the threat collection and discovers a query template with fields for severity, deviceId, and dateRange. The dashboard renders a filter form from the template. When the server adds a new filter field, the dashboard updates automatically.

flowchart TB
    A["GET /threats"] --> B["Collection+JSON"]
    B --> C["collection.links: [self, next, prev]"]
    B --> D["collection.items: [threat, threat, ...]"]
    B --> E["collection.queries: [search, filter]"]
    B --> F["collection.template: { create data }"]
    E --> G["search: { fields: [severity, deviceId] }"]
    F --> H["create: { fields: [name, severity, deviceId] }"]
    style B fill:#dbeafe,stroke:#2563eb

Code Examples

Example 1: Collection+JSON Document

{
  "collection": {
    "version": "1.0",
    "href": "https://api.dodatech.com/threats",
    "links": [
      { "rel": "self", "href": "/threats?page=1" },
      { "rel": "next", "href": "/threats?page=2" },
      { "rel": "last", "href": "/threats?page=10" },
      { "rel": "devices", "href": "/devices" }
    ],
    "items": [
      {
        "href": "/threats/101",
        "data": [
          { "name": "threatId", "value": "101", "prompt": "Threat ID" },
          { "name": "name", "value": "Ransomware-X", "prompt": "Threat Name" },
          { "name": "severity", "value": "critical", "prompt": "Severity" },
          { "name": "deviceId", "value": "1", "prompt": "Device ID" },
          { "name": "detectedAt", "value": "2026-06-28T10:00:00Z", "prompt": "Detected At" }
        ],
        "links": [
          { "rel": "self", "href": "/threats/101" },
          { "rel": "device", "href": "/devices/1" },
          { "rel": "analyze", "href": "/threats/101/analyze" }
        ]
      }
    ],
    "queries": [
      {
        "rel": "search",
        "href": "/threats",
        "prompt": "Search Threats",
        "data": [
          { "name": "severity", "value": "", "prompt": "Filter by severity" },
          { "name": "deviceId", "value": "", "prompt": "Filter by device" },
          { "name": "from", "value": "", "prompt": "Start date" },
          { "name": "to", "value": "", "prompt": "End date" }
        ]
      }
    ],
    "template": {
      "data": [
        { "name": "name", "value": "", "prompt": "Threat name" },
        { "name": "severity", "value": "medium", "prompt": "Severity" },
        { "name": "deviceId", "value": "", "prompt": "Device ID" }
      ]
    },
    "error": null
  }
}

Example 2: Collection+JSON Server

const express = require('express');
const app = express();

app.use(express.json());

function cjResponse(href, options = {}) {
  const collection = {
    version: '1.0',
    href,
    links: options.links || [],
    items: options.items || [],
    queries: options.queries || [],
    template: options.template || null,
    error: options.error || null,
  };
  
  if (!options.error) delete collection.error;
  if (!options.template) delete collection.template;
  
  return { collection };
}

function cjItem(href, data, links) {
  return {
    href,
    data: data.map(d => ({
      name: d.name,
      value: d.value,
      prompt: d.prompt || d.name,
    })),
    links: links || [],
  };
}

// Collection endpoint
app.get('/threats', (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const { severity, deviceId } = req.query;
  
  const threats = queryThreats({ page, severity, deviceId });
  const totalPages = getTotalPages();
  
  const paginationLinks = [
    { rel: 'self', href: `/threats?page=${page}` },
  ];
  if (page < totalPages) {
    paginationLinks.push({ rel: 'next', href: `/threats?page=${page + 1}` });
  }
  if (page > 1) {
    paginationLinks.push({ rel: 'prev', href: `/threats?page=${page - 1}` });
  }
  paginationLinks.push({ rel: 'last', href: `/threats?page=${totalPages}` });
  
  const response = cjResponse('/threats', {
    links: paginationLinks,
    items: threats.map(t => cjItem(
      `/threats/${t.id}`,
      [
        { name: 'threatId', value: t.id, prompt: 'ID' },
        { name: 'name', value: t.name, prompt: 'Name' },
        { name: 'severity', value: t.severity, prompt: 'Severity' },
      ],
      [
        { rel: 'self', href: `/threats/${t.id}` },
        { rel: 'device', href: `/devices/${t.deviceId}` },
      ],
    )),
    queries: [
      {
        rel: 'search',
        href: '/threats',
        prompt: 'Search',
        data: [
          { name: 'severity', value: severity || '', prompt: 'Severity' },
          { name: 'deviceId', value: deviceId || '', prompt: 'Device' },
        ],
      },
    ],
    template: {
      data: [
        { name: 'name', value: '', prompt: 'Threat name (required)' },
        { name: 'severity', value: 'medium', prompt: 'Severity' },
        { name: 'deviceId', value: '', prompt: 'Device ID' },
      ],
    },
  });
  
  res.json(response);
});

// Create via template
app.post('/threats', (req, res) => {
  const threat = createThreat(req.body);
  
  res.status(201).json(cjResponse('/threats', {
    items: [cjItem(`/threats/${threat.id}`, [
      { name: 'threatId', value: threat.id },
      { name: 'name', value: threat.name },
    ])],
  }));
});

Example 3: Collection+JSON Client

class CollectionJsonClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
  }
  
  async get(href) {
    const res = await fetch(`${this.baseUrl}${href}`);
    return res.json();
  }
  
  async query(collection, queryRel, params) {
    const queryDef = collection.collection.queries
      .find(q => q.rel === queryRel);
    
    if (!queryDef) throw new Error(`Query '${queryRel}' not found`);
    
    const url = new URL(queryDef.href, this.baseUrl);
    for (const [key, value] of Object.entries(params)) {
      if (value) url.searchParams.set(key, value);
    }
    
    return this.get(url.pathname + url.search);
  }
  
  async create(collection, data) {
    const template = collection.collection.template;
    if (!template) throw new Error('No write template');
    
    // Validate template fields
    const body = {};
    for (const field of template.data) {
      body[field.name] = data[field.name] || field.value;
    }
    
    const res = await fetch(`${this.baseUrl}/threats`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    
    return res.json();
  }
  
  async paginate(collection, direction = 'next') {
    const link = collection.collection.links
      .find(l => l.rel === direction);
    
    if (!link) return null; // No more pages
    return this.get(link.href);
  }
  
  async searchThreats() {
    const collection = await this.get('/threats');
    
    // Discover search from queries
    const results = await this.query(collection, 'search', {
      severity: 'critical',
    });
    
    // Paginate through results
    let page = results;
    while (page) {
      for (const item of page.collection.items) {
        console.log('Threat:', item.data.find(d => d.name === 'name').value);
      }
      page = await this.paginate(page, 'next');
    }
  }
}

Common Mistakes

  1. Not including self link — the collection must have a self link pointing to its own URL. This is required for cache validation and identity.
  2. Using flat data arrays without prompts — prompts tell clients what each field means. Without prompts, the data array is meaningless.
  3. Not providing a write template — a POST endpoint without a template forces clients to guess the required fields. Include the template with all fields.
  4. Confusing query templates with write templates — queries filter the collection (GET), templates create new items (POST). They serve different purposes.
  5. Omitting error field on errors — when an error occurs, include the error object with code and message instead of returning a non-Collection+JSON response.

Practice Questions

  1. What is the structure of a Collection+JSON document?
  2. How do query templates enable search discoverability?
  3. How does the write template guide item creation?
  4. How is pagination represented in Collection+JSON?
  5. What is the purpose of the prompt field in data arrays?

Challenge: Design a Collection+JSON API for a device inventory system with: paginated device list, search query with 5 filter fields, write template for device creation, and error handling for validation failures.

Mini Project

Build a Collection+JSON API for a threat management system with: paginated threat collection, query templates for filtering by severity and date, write template for creating threats, item-level links to related resources, and a JavaScript client that discovers and uses all templates.

FAQ

What is the difference between Collection+JSON and HAL?

HAL focuses on links and embedded resources. Collection+JSON focuses on list operations with query templates, write templates, and standardized data arrays.

Can I use Collection+JSON for non-collection resources?

While possible, Collection+JSON shines for list-oriented endpoints. For single resources, HAL or Siren are more natural choices.

How do I handle file uploads in Collection+JSON?

Collection+JSON doesn't natively support file uploads. Use a multipart request with the template fields, or provide a separate upload URL via links.

Does Collection+JSON support nested resources?

Items can have links to related resources. Deep nesting isn't part of the spec — use links for relationships instead.

How do I represent validation errors?

Set collection.error to an object with code and message. The items array can be empty or null. Include a template with the submitted values for correction.

What's Next

Learn about hypermedia link formats

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro