headless-api-design

SKILL.md

Headless API Design

Guidance for designing content delivery APIs for headless CMS architectures, enabling multi-channel content distribution.

When to Use This Skill

  • Designing REST or GraphQL APIs for content delivery
  • Implementing preview endpoints for draft content
  • Adding localization/i18n to content APIs
  • Planning pagination and filtering strategies
  • Configuring caching headers for content
  • Versioning content APIs

API Architecture Overview

Headless CMS API Layers

┌─────────────────────────────────────────────────────────────┐
│                    Content Consumers                         │
│  (Blazor, React, Next.js, Mobile Apps, IoT, Digital Signs)  │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                    Content Delivery API                      │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │  REST API   │  │ GraphQL API │  │ Preview/Draft API   │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                    Content Services                          │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │   Content   │  │    Media    │  │   Localization      │  │
│  │   Query     │  │   Resolver  │  │   Service           │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                    Content Repository                        │
│              (EF Core + JSON Columns + Cache)               │
└─────────────────────────────────────────────────────────────┘

REST API Design

Resource Endpoints

GET    /api/content                    # List all content items
GET    /api/content/{id}               # Get content by ID
GET    /api/content/alias/{path}       # Get content by URL path/alias
GET    /api/content/types/{type}       # List content by type

# Type-specific endpoints
GET    /api/articles                   # List articles
GET    /api/articles/{id}              # Get article
GET    /api/pages                      # List pages
GET    /api/pages/{id}                 # Get page

# Nested resources
GET    /api/articles/{id}/comments     # Get article comments
GET    /api/menus/{id}/items           # Get menu items

Query Parameters

# Pagination
?page=1&pageSize=20                    # Offset pagination
?cursor=eyJpZCI6MTIz&limit=20         # Cursor pagination

# Filtering
?filter[status]=published
?filter[contentType]=Article
?filter[author.id]=abc123
?filter[createdUtc][gte]=2025-01-01

# Sorting
?sort=-publishedUtc                    # Descending
?sort=title                            # Ascending
?sort=category.name,-createdUtc        # Multiple fields

# Field selection (sparse fieldsets)
?fields=id,title,slug,publishedUtc
?fields[article]=title,body
?fields[author]=name,avatar

# Include related resources
?include=author,categories
?include=author.profile

Response Structure

{
  "data": {
    "id": "abc123",
    "type": "Article",
    "attributes": {
      "title": "Getting Started with Headless CMS",
      "slug": "getting-started-headless-cms",
      "body": "<p>Content here...</p>",
      "publishedUtc": "2025-01-15T10:30:00Z",
      "status": "Published"
    },
    "parts": {
      "titlePart": {
        "title": "Getting Started with Headless CMS"
      },
      "seoPart": {
        "metaTitle": "Headless CMS Guide",
        "metaDescription": "Learn how to..."
      }
    },
    "relationships": {
      "author": {
        "data": { "id": "author456", "type": "Author" }
      },
      "categories": {
        "data": [
          { "id": "cat1", "type": "Category" }
        ]
      }
    }
  },
  "included": [
    {
      "id": "author456",
      "type": "Author",
      "attributes": {
        "name": "Jane Doe",
        "bio": "Technical writer..."
      }
    }
  ],
  "meta": {
    "version": "1.0",
    "generatedAt": "2025-01-15T14:22:00Z"
  }
}

Collection Response with Pagination

{
  "data": [...],
  "meta": {
    "totalCount": 156,
    "pageSize": 20,
    "currentPage": 1,
    "totalPages": 8
  },
  "links": {
    "self": "/api/articles?page=1&pageSize=20",
    "first": "/api/articles?page=1&pageSize=20",
    "prev": null,
    "next": "/api/articles?page=2&pageSize=20",
    "last": "/api/articles?page=8&pageSize=20"
  }
}

GraphQL API Design

Schema Definition

type Query {
  # Single item queries
  content(id: ID!): ContentItem
  contentByPath(path: String!): ContentItem

  # Type-specific queries
  article(id: ID!): Article
  articles(
    filter: ArticleFilter
    sort: ArticleSort
    first: Int
    after: String
  ): ArticleConnection!

  page(id: ID!): Page
  pages(parentId: ID): [Page!]!

  menu(id: ID, name: String): Menu
}

interface ContentItem {
  id: ID!
  contentType: String!
  displayText: String
  createdUtc: DateTime!
  modifiedUtc: DateTime!
  publishedUtc: DateTime
  status: ContentStatus!
}

type Article implements ContentItem {
  id: ID!
  contentType: String!
  displayText: String
  createdUtc: DateTime!
  modifiedUtc: DateTime!
  publishedUtc: DateTime
  status: ContentStatus!

  # Parts
  titlePart: TitlePart
  autoroutePart: AutoroutePart
  seoPart: SeoMetaPart

  # Fields
  body: String!
  featuredImage: MediaField
  author: Author
  categories: [Category!]!
  tags: [String!]!
  readTimeMinutes: Int
}

type ArticleConnection {
  edges: [ArticleEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type ArticleEdge {
  node: Article!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

input ArticleFilter {
  status: ContentStatus
  categoryId: ID
  authorId: ID
  tags: [String!]
  publishedAfter: DateTime
  publishedBefore: DateTime
  search: String
}

input ArticleSort {
  field: ArticleSortField!
  direction: SortDirection!
}

enum ArticleSortField {
  TITLE
  PUBLISHED_UTC
  CREATED_UTC
  READ_TIME
}

Content Parts as Types

type TitlePart {
  title: String!
  displayTitle: String
}

type AutoroutePart {
  path: String!
  isCustom: Boolean!
}

type SeoMetaPart {
  metaTitle: String
  metaDescription: String
  metaKeywords: String
  noIndex: Boolean!
  noFollow: Boolean!
}

type MediaField {
  paths: [String!]!
  urls: [String!]!
  alt: String
  caption: String
  mediaItems: [MediaItem!]!
}

type MediaItem {
  id: ID!
  url: String!
  mimeType: String!
  width: Int
  height: Int
  alt: String
}

Preview API

Draft Content Endpoint

# Requires authentication/preview token
GET /api/preview/content/{id}
GET /api/preview/content/{id}?version={versionId}

# Preview token in header
Authorization: Bearer <preview-token>
X-Preview-Mode: true

Preview Implementation

[ApiController]
[Route("api/preview")]
public class PreviewController : ControllerBase
{
    private readonly IContentService _contentService;
    private readonly IPreviewTokenService _tokenService;

    [HttpGet("content/{id}")]
    public async Task<ActionResult<ContentItemDto>> GetPreview(
        string id,
        [FromHeader(Name = "X-Preview-Token")] string? previewToken,
        [FromQuery] string? version)
    {
        // Validate preview token
        if (!await _tokenService.ValidateTokenAsync(previewToken))
        {
            return Unauthorized();
        }

        // Get draft or specific version
        var content = version != null
            ? await _contentService.GetVersionAsync(id, version)
            : await _contentService.GetDraftAsync(id);

        if (content == null)
        {
            return NotFound();
        }

        return Ok(content);
    }
}

Preview Token Generation

public class PreviewTokenService : IPreviewTokenService
{
    public string GenerateToken(string contentId, TimeSpan validity)
    {
        var payload = new
        {
            ContentId = contentId,
            ExpiresAt = DateTime.UtcNow.Add(validity),
            Nonce = Guid.NewGuid().ToString("N")
        };

        // Sign with HMAC or JWT
        return SignPayload(payload);
    }

    public async Task<bool> ValidateTokenAsync(string? token)
    {
        if (string.IsNullOrEmpty(token))
            return false;

        var payload = VerifyAndDecodeToken(token);
        if (payload == null)
            return false;

        return payload.ExpiresAt > DateTime.UtcNow;
    }
}

Localization Strategy

URL-Based Localization

# Path prefix (recommended)
GET /api/en/articles
GET /api/fr/articles
GET /api/de-DE/articles

# Query parameter
GET /api/articles?locale=en
GET /api/articles?locale=fr

# Accept-Language header
Accept-Language: en-US, en;q=0.9, fr;q=0.8

Localized Response Structure

{
  "data": {
    "id": "abc123",
    "type": "Article",
    "locale": "en-US",
    "attributes": {
      "title": "Getting Started",
      "body": "English content..."
    },
    "localizations": {
      "available": ["en-US", "fr-FR", "de-DE"],
      "links": {
        "fr-FR": "/api/fr/articles/abc123",
        "de-DE": "/api/de/articles/abc123"
      }
    }
  }
}

Fallback Chain

public class LocalizationService
{
    public async Task<ContentItem?> GetLocalizedContentAsync(
        string id,
        string requestedLocale)
    {
        // Define fallback chain
        var fallbackChain = GetFallbackChain(requestedLocale);
        // e.g., ["en-GB", "en", "default"]

        foreach (var locale in fallbackChain)
        {
            var content = await _repository
                .GetByIdAndLocaleAsync(id, locale);

            if (content != null)
            {
                return content;
            }
        }

        return null;
    }

    private List<string> GetFallbackChain(string locale)
    {
        var chain = new List<string> { locale };

        // Add language without region
        if (locale.Contains('-'))
        {
            chain.Add(locale.Split('-')[0]);
        }

        // Add default
        chain.Add("default");

        return chain;
    }
}

Caching Strategy

Cache Headers

[HttpGet("{id}")]
public async Task<ActionResult<ContentItemDto>> Get(string id)
{
    var content = await _contentService.GetAsync(id);
    if (content == null)
    {
        return NotFound();
    }

    // Set cache headers
    Response.Headers["Cache-Control"] = "public, max-age=300"; // 5 minutes
    Response.Headers["ETag"] = $"\"{content.Version}\"";
    Response.Headers["Last-Modified"] = content.ModifiedUtc
        .ToString("R"); // RFC 1123 format

    return Ok(content);
}

Conditional GET

[HttpGet("{id}")]
public async Task<ActionResult<ContentItemDto>> Get(
    string id,
    [FromHeader(Name = "If-None-Match")] string? ifNoneMatch,
    [FromHeader(Name = "If-Modified-Since")] string? ifModifiedSince)
{
    var content = await _contentService.GetAsync(id);
    if (content == null)
    {
        return NotFound();
    }

    var etag = $"\"{content.Version}\"";

    // Check ETag
    if (ifNoneMatch == etag)
    {
        return StatusCode(304); // Not Modified
    }

    // Check Last-Modified
    if (DateTime.TryParse(ifModifiedSince, out var modifiedSince))
    {
        if (content.ModifiedUtc <= modifiedSince)
        {
            return StatusCode(304); // Not Modified
        }
    }

    Response.Headers["ETag"] = etag;
    return Ok(content);
}

Cache Invalidation

public class ContentPublishHandler : INotificationHandler<ContentPublishedEvent>
{
    private readonly ICacheInvalidationService _cache;

    public async Task Handle(ContentPublishedEvent notification,
        CancellationToken cancellationToken)
    {
        // Invalidate specific content
        await _cache.InvalidateAsync($"content:{notification.ContentId}");

        // Invalidate collection caches
        await _cache.InvalidateByTagAsync($"type:{notification.ContentType}");

        // Invalidate CDN cache
        await _cache.PurgeCdnAsync($"/api/content/{notification.ContentId}");
    }
}

API Versioning

URL Path Versioning

GET /api/v1/content/{id}
GET /api/v2/content/{id}

Header Versioning

GET /api/content/{id}
Api-Version: 2.0

Implementation

// Program.cs
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("Api-Version")
    );
});

// Controller
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/content")]
public class ContentController : ControllerBase
{
    [HttpGet("{id}")]
    [MapToApiVersion("1.0")]
    public async Task<ActionResult<ContentItemDtoV1>> GetV1(string id)
    {
        // V1 response shape
    }

    [HttpGet("{id}")]
    [MapToApiVersion("2.0")]
    public async Task<ActionResult<ContentItemDtoV2>> GetV2(string id)
    {
        // V2 response shape with breaking changes
    }
}

Security Considerations

API Key Authentication

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue("X-Api-Key", out var apiKey))
        {
            return AuthenticateResult.NoResult();
        }

        var client = await _clientService.ValidateApiKeyAsync(apiKey!);
        if (client == null)
        {
            return AuthenticateResult.Fail("Invalid API key");
        }

        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, client.Id),
            new Claim("client_name", client.Name),
            new Claim("scope", string.Join(" ", client.Scopes))
        };

        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        return AuthenticateResult.Success(ticket);
    }
}

Rate Limiting

builder.Services.AddRateLimiter(options =>
{
    options.AddPolicy("content-api", context =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: context.Request.Headers["X-Api-Key"].ToString(),
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 1000,
                Window = TimeSpan.FromHours(1),
                QueueLimit = 0
            }));
});

Related Skills

  • content-type-modeling - Content structure for API responses
  • dynamic-schema-design - JSON column storage for flexible APIs
  • content-versioning - Version history API endpoints
  • cdn-media-delivery - CDN integration for media APIs
Weekly Installs
4
Installed on
antigravity3
windsurf2
trae2
opencode2
codex2
claude-code2