Claude-skill-registry headless-api-design
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.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/headless-api-design" ~/.claude/skills/majiayu000-claude-skill-registry-headless-api-design && rm -rf "$T"
manifest:
skills/data/headless-api-design/SKILL.mdsource content
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 structure for API responsescontent-type-modeling
- JSON column storage for flexible APIsdynamic-schema-design
- Version history API endpointscontent-versioning
- CDN integration for media APIscdn-media-delivery