| name | headless-api-design |
| description | Use when designing content delivery APIs for headless CMS architectures. Covers REST and GraphQL API patterns, content preview endpoints, localization strategies, pagination, filtering, caching headers, and API versioning for multi-channel content delivery. |
| allowed-tools | Read, Glob, Grep, Task, Skill |
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 responsesdynamic-schema-design- JSON column storage for flexible APIscontent-versioning- Version history API endpointscdn-media-delivery- CDN integration for media APIs