headless-api-design

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

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "headless-api-design" with this command: npx skills add melodic-software/claude-code-plugins/melodic-software-claude-code-plugins-headless-api-design

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&#x3C;ActionResult&#x3C;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&#x3C;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&#x3C;string> GetFallbackChain(string locale)
{
    var chain = new List&#x3C;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 &#x3C;= 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&#x3C;ActionResult&#x3C;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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

design-thinking

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

plantuml-syntax

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

system-prompt-engineering

No summary provided by upstream source.

Repository SourceNeeds Review