Building Headless CMS Solutions with ASP.NET Core
Content Management for Modern Apps - Websites, Mobile, Kiosks, and Beyond
Modern apps don’t live in a single place anymore. Your content needs to reach a website, a mobile app, a digital kiosk, a smart TV, and maybe even a voice assistant, all at the same time. Traditional CMS platforms were never built for that world.
Headless CMS architecture is the answer, and ASP.NET Core is one of the best platforms available to build one. In this article, we’ll break down what headless CMS actually means, why it matters for modern development, and how to build a production-ready headless CMS backend using ASP.NET Core from the ground up.
What Is a Headless CMS, and Why Should You Care?
If you’ve worked with platforms like WordPress, Umbraco in its classic mode, or Sitefinity, you know the experience: the CMS controls both the content and the presentation. The “head” (the frontend that renders HTML to the browser) is tightly coupled to the “body” where the content lives. Change something fundamental about how content is stored, and you’re rippling changes all the way through to the UI. That tight coupling is useful when you’re building a simple website, but it becomes a real problem the moment your content needs to appear in more than one place.
A headless CMS removes the head entirely. There’s no built-in rendering layer. Instead, content is stored and managed in a backend system and delivered via APIs, typically REST or GraphQL, to whatever frontend or client wants it. Your React web app, your Flutter mobile app, your Alexa skill: they all pull from the same content source. You write the content once and deliver it everywhere.
The market is moving fast in this direction. According to research cited by Strapi, 69% of B2C decision-makers increased their CMS investment in 2024, with the headless CMS market growing at a projected 21% CAGR through 2032. This isn’t just a developer preference; it’s a business imperative for any organization managing content across multiple channels.
For ASP.NET Core developers, the opportunity is significant. You already have one of the best backend frameworks available for building high-performance APIs. All you need is the right architecture to turn it into a capable, flexible content backend.
The Architecture at a Glance
Before diving into code, it helps to understand what we’re building conceptually. A headless CMS built on ASP.NET Core consists of a few key layers working together.
At the core is the content model: the definition of your content types. Think of these as your data schemas. A “Blog Post” content type might have fields like a title, slug, author, body, tags, and a published date. A “Product” content type might include a name, price, description, images, and SKU. These are defined in code as C# classes and mapped to a database.
Above that sits the content API: the ASP.NET Core controllers or Minimal API endpoints that expose CRUD operations for your content types. These are the endpoints your frontend clients call to fetch, create, update, or delete content. They return structured JSON by default, and with a library like Hot Chocolate or graphql-dotnet, you can add a GraphQL layer on top.
Next comes the content delivery layer, which is distinct from the management API. While the management API is used by editors and administrators to create and update content, the delivery API is public-facing and optimized for reading. This is where caching becomes critical.
Finally, there’s the admin interface: the UI that your non-developer content editors use to manage content. This can be a custom-built React or Blazor app, or you can integrate with existing tooling. For this article, we’ll focus on the backend API and leave the admin UI as a separate concern.
Setting Up Your ASP.NET Core CMS Project
Let’s get practical. Start by creating a new ASP.NET Core Web API project targeting .NET 8:
dotnet new webapi -n HeadlessCms -f net8.0
cd HeadlessCms
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
dotnet add package Slugify.Core
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearerThese packages cover Entity Framework Core for data access, Redis for distributed caching, a slug generation library for SEO-friendly URLs, and JWT Bearer authentication for securing the management API. If you’re thinking about GraphQL support, you can also add HotChocolate.AspNetCore when you’re ready to layer that in.
Modeling Your Content Types
Content modeling is probably the most important design decision you’ll make in a headless CMS. Get it right, and everything downstream becomes easy. Get it wrong, and you’ll be fighting your own data structures for the lifetime of the project.
The key insight is that content types should be defined as code-first C# models, not hard-coded database tables for every possible field. A flexible approach uses a base ContentItem entity with common fields and stores type-specific fields as serialized JSON in a Data column. This pattern lets you add new content types without running database migrations every time a new type is needed.
public class ContentItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ContentType { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public ContentStatus Status { get; set; } = ContentStatus.Draft;
public string Data { get; set; } = "{}"; // JSON-serialized type-specific fields
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? PublishedAt { get; set; }
public string AuthorId { get; set; } = string.Empty;
}
public enum ContentStatus { Draft, Review, Published, Archived }For specific content types, you define strongly typed DTOs that map to and from the Data JSON field:
public class BlogPostData
{
public string Body { get; set; } = string.Empty;
public List<string> Tags { get; set; } = new();
public string FeaturedImageUrl { get; set; } = string.Empty;
public string ExcerptText { get; set; } = string.Empty;
public string SeoTitle { get; set; } = string.Empty;
public string SeoDescription { get; set; } = string.Empty;
}This pattern gives you the flexibility of a schemaless store while keeping the advantages of C# type safety where it matters. For schemas that are truly dynamic (user-defined content types), you can also store the schema definition itself as a ContentType entity in the database, which is exactly how platforms like Squidex (an open-source ASP.NET Core headless CMS) handle user-defined schemas.
Building the Content Delivery API
The delivery API is the heart of your headless CMS. Let’s build a clean, performant set of endpoints using ASP.NET Core Minimal APIs. Minimal APIs are a great fit here: they have lower overhead than controller-based APIs and lead to cleaner, more focused code.
If you’ve been following along with our series, you’ll already be familiar with designing clean APIs in ASP.NET Core. The same principles that apply to CQRS-style backends apply here too. Our article on CQRS and Event Sourcing in ASP.NET Core covers patterns that pair nicely with a headless CMS backend, especially if you want to track a full history of content changes.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<CmsDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddStackExchangeRedisCache(options =>
options.Configuration = builder.Configuration["Redis:ConnectionString"]);
builder.Services.AddScoped<IContentRepository, ContentRepository>();
builder.Services.AddScoped<IContentDeliveryService, ContentDeliveryService>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(/* configure options */);
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Public delivery endpoints
var content = app.MapGroup("/api/content");
content.MapGet("/{contentType}", async (
string contentType,
IContentDeliveryService delivery,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 10,
[FromQuery] string? tag = null) =>
{
var result = await delivery.GetPublishedAsync(contentType, page, pageSize, tag);
return Results.Ok(result);
});
content.MapGet("/{contentType}/{slug}", async (
string contentType,
string slug,
IContentDeliveryService delivery) =>
{
var item = await delivery.GetBySlugAsync(contentType, slug);
return item is null ? Results.NotFound() : Results.Ok(item);
});Notice that the delivery endpoints don’t require authentication. This is by design. Your content delivery API should be as fast and accessible as possible. Authentication is reserved for the management API endpoints that create, update, or delete content.
Adding a Caching Layer That Actually Works
Performance is non-negotiable in a headless CMS. Your content APIs need to be fast because they’re called on every page load, every app screen transition, and potentially from thousands of concurrent clients. Without caching, even a modest traffic spike can put serious strain on your database.
The good news is that content is a perfect caching candidate by nature. Published content doesn’t change frequently, and when it does change (when an editor publishes a new post, for example), you can invalidate specific cache keys rather than flushing everything.
ASP.NET Core’s distributed caching via Redis is well-suited for this. Here’s how a caching service layer wraps your content delivery service:
public class CachedContentDeliveryService : IContentDeliveryService
{
private readonly IContentDeliveryService _inner;
private readonly IDistributedCache _cache;
private static readonly TimeSpan DefaultTtl = TimeSpan.FromMinutes(30);
public CachedContentDeliveryService(
IContentDeliveryService inner,
IDistributedCache cache)
{
_inner = inner;
_cache = cache;
}
public async Task<ContentItem?> GetBySlugAsync(string contentType, string slug)
{
var cacheKey = $"content:{contentType}:{slug}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached is not null)
return JsonSerializer.Deserialize<ContentItem>(cached);
var item = await _inner.GetBySlugAsync(contentType, slug);
if (item is not null)
{
var options = new DistributedCacheEntryOptions()
.SetAbsoluteExpiration(DefaultTtl);
await _cache.SetStringAsync(cacheKey,
JsonSerializer.Serialize(item), options);
}
return item;
}
public async Task InvalidateAsync(string contentType, string slug)
{
var cacheKey = $"content:{contentType}:{slug}";
await _cache.RemoveAsync(cacheKey);
}
}Register this in your DI container using the decorator pattern:
builder.Services.AddScoped<IContentDeliveryService>(sp =>
{
var inner = new ContentDeliveryService(sp.GetRequiredService<IContentRepository>());
return new CachedContentDeliveryService(inner,
sp.GetRequiredService<IDistributedCache>());
});Now, every time an editor publishes or updates content through the management API, you call InvalidateAsync to ensure the next request gets fresh data. This gives you the best of both worlds: fast reads from cache, and accurate content after edits. For more advanced caching scenarios, check out our article on Monitoring and Observability in ASP.NET Core, which covers how to instrument your caching layer with metrics so you can track hit rates and cache effectiveness in production.
Securing the Management API
The management API (the endpoints that let editors create, update, publish, and delete content) needs proper authentication and authorization. JWT Bearer authentication is the standard approach in ASP.NET Core, and it pairs naturally with an identity provider like Azure AD B2C, Auth0, or a custom implementation using ASP.NET Core Identity.
You’ll also want role-based access control. A content editor should be able to create and edit drafts but not necessarily publish them. A publisher can move content to a “Published” state. An administrator has full control. These roles map naturally to ASP.NET Core’s authorization policies. We covered this pattern in depth in our article on Authentication with OAuth and OpenID Connect in ASP.NET Core, and the same principles apply perfectly to a CMS management API.
// Require authentication and editor role for content management
var manage = app.MapGroup("/api/manage")
.RequireAuthorization();
manage.MapPost("/{contentType}",
[Authorize(Roles = "Editor,Publisher,Admin")]
async (string contentType,
CreateContentRequest request,
IContentRepository repo) =>
{
var slug = SlugHelper.GenerateSlug(request.Title);
var item = new ContentItem
{
ContentType = contentType,
Title = request.Title,
Slug = slug,
Status = ContentStatus.Draft,
Data = JsonSerializer.Serialize(request.Data)
};
await repo.CreateAsync(item);
return Results.Created($"/api/content/{contentType}/{slug}", item);
});
manage.MapPatch("/{id}/publish",
[Authorize(Roles = "Publisher,Admin")]
async (Guid id, IContentRepository repo, IContentDeliveryService cache) =>
{
var item = await repo.GetByIdAsync(id);
if (item is null) return Results.NotFound();
item.Status = ContentStatus.Published;
item.PublishedAt = DateTime.UtcNow;
await repo.UpdateAsync(item);
await ((CachedContentDeliveryService)cache).InvalidateAsync(
item.ContentType, item.Slug);
return Results.NoContent();
});Supporting Multiple Frontends with Content Filtering
One of the biggest advantages of a headless CMS is that the same content API can serve multiple frontends simultaneously. But different frontends often need different data shapes and different subsets of content. A mobile app might want a shorter excerpt and a thumbnail URL. A desktop website might want the full body. A newsletter system might only want published posts from the last 30 days.
Rather than creating separate endpoints for every use case, build a flexible query interface into your delivery API. Query parameters are the most pragmatic approach for REST-based APIs:
content.MapGet("/{contentType}", async (
string contentType,
IContentDeliveryService delivery,
HttpContext http) =>
{
var query = new ContentQuery
{
ContentType = contentType,
Page = int.TryParse(http.Request.Query["page"], out var p) ? p : 1,
PageSize = int.TryParse(http.Request.Query["pageSize"], out var ps)
? Math.Min(ps, 100) : 10,
Tag = http.Request.Query["tag"],
AuthorId = http.Request.Query["author"],
PublishedAfter = DateTime.TryParse(http.Request.Query["after"],
out var after) ? after : null,
Fields = http.Request.Query["fields"].ToString().Split(',',
StringSplitOptions.RemoveEmptyEntries)
};
var result = await delivery.QueryAsync(query);
return Results.Ok(result);
});The Fields parameter is particularly useful. It lets clients request a sparse fieldset, reducing payload size for mobile clients where bandwidth matters. Your delivery service can use reflection or a dictionary-based approach to filter the output JSON down to only the requested fields.
Adding GraphQL Support
REST is great for simple content fetching, but GraphQL becomes attractive when your content model is complex and your clients have very specific, varied data requirements. With GraphQL, a mobile client can request the title, slug, and thumbnail of the last five blog posts by a given author in a single query, without any over-fetching or under-fetching.
The Hot Chocolate library is the leading GraphQL implementation for ASP.NET Core. Adding it to your headless CMS is straightforward:
dotnet add package HotChocolate.AspNetCore
dotnet add package HotChocolate.Data.EntityFramework// In Program.cs
builder.Services
.AddGraphQLServer()
.AddQueryType<ContentQuery>()
.AddMutationType<ContentMutation>()
.AddProjections()
.AddFiltering()
.AddSorting();
app.MapGraphQL(); // exposes /graphqlpublic class ContentQuery
{
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<ContentItem> GetContentItems(
[Service] CmsDbContext context,
string contentType) =>
context.ContentItems
.Where(c => c.ContentType == contentType
&& c.Status == ContentStatus.Published);
}With this in place, clients can write queries like the following against your /graphql endpoint:
query GetRecentPosts {
contentItems(contentType: "blog-post", order: { publishedAt: DESC }) {
id
title
slug
publishedAt
}
}Hot Chocolate also has excellent built-in support for persisted queries, which lets you predefine allowed queries and cache them on the server. This is a meaningful security and performance improvement for production GraphQL APIs.
Webhooks and Real-Time Cache Invalidation
One of the most powerful features you can add to your headless CMS is a webhook system. When content is published, updated, or deleted, your CMS fires a webhook to notify downstream systems: your CDN, your frontend build system, a notification service, or a third-party integration.
Here’s a clean implementation of a webhook dispatcher in ASP.NET Core:
public class WebhookService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly CmsDbContext _context;
public async Task DispatchAsync(string eventType, ContentItem item)
{
var hooks = await _context.Webhooks
.Where(w => w.Events.Contains(eventType) && w.IsActive)
.ToListAsync();
var payload = JsonSerializer.Serialize(new
{
@event = eventType,
timestamp = DateTime.UtcNow,
content = new { item.Id, item.ContentType, item.Slug, item.Status }
});
var tasks = hooks.Select(async hook =>
{
using var client = _httpClientFactory.CreateClient();
var content = new StringContent(payload,
Encoding.UTF8, "application/json");
await client.PostAsync(hook.Url, content);
});
await Task.WhenAll(tasks);
}
}Dispatch webhooks after every significant content event: on publish, on update, on delete. On the receiving end (say, a Jamstack frontend hosted on Vercel or Netlify), the webhook triggers a rebuild or an incremental static regeneration, keeping the frontend perfectly in sync with your CMS content.
Media Management
A CMS without media management is only half a CMS. Images, videos, and documents are a core part of almost every content type. For ASP.NET Core, the most practical approach is to store media in blob storage (Azure Blob Storage or AWS S3) and return public URLs in your content API responses.
Here’s a minimal media upload endpoint that stores files in Azure Blob Storage and returns a URL:
manage.MapPost("/media/upload",
[Authorize(Roles = "Editor,Publisher,Admin")]
async (IFormFile file, IBlobStorageService blobService) =>
{
var allowedTypes = new[] { "image/jpeg", "image/png", "image/webp",
"image/gif", "application/pdf" };
if (!allowedTypes.Contains(file.ContentType))
return Results.BadRequest("Unsupported file type.");
if (file.Length > 10 * 1024 * 1024) // 10MB limit
return Results.BadRequest("File size exceeds 10MB limit.");
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
using var stream = file.OpenReadStream();
var url = await blobService.UploadAsync(fileName, stream, file.ContentType);
return Results.Ok(new { Url = url, FileName = fileName });
});For image optimization, consider running uploaded images through a processing step: resize to multiple dimensions and convert to WebP format before storing them. The ImageSharp library from SixLabors makes this straightforward in C# without requiring native dependencies like ImageMagick.
Choosing the Right Database
Your choice of database has a big impact on your CMS’s flexibility and performance. For the content item model described above (where type-specific fields are stored as JSON), you have two practical options.
SQL Server and PostgreSQL both have excellent JSON column support. SQL Server’s OPENJSON function and PostgreSQL’s jsonb column type let you query into the JSON Data field when needed, while still benefiting from relational database features like transactions, foreign keys, and full-text search. For most ASP.NET Core shops, this is the right default choice.
MongoDB is another option, and it’s the approach used by Squidex, one of the most mature open-source ASP.NET Core headless CMS projects. MongoDB’s flexible document model maps directly to content items, and it scales well for read-heavy workloads. The trade-off is losing ACID transactions and the familiarity of relational modeling.
For most new projects, start with SQL Server or PostgreSQL via Entity Framework Core. You get schema migrations, familiar tooling, and strong consistency guarantees out of the box.
Deployment Considerations
A headless CMS backend is a natural fit for containerized deployment. Your ASP.NET Core app is stateless (all session data lives in Redis or the database), which means you can scale horizontally without any sticky session configuration. A Dockerfile for your headless CMS is essentially the standard ASP.NET Core Dockerfile:
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["HeadlessCms/HeadlessCms.csproj", "HeadlessCms/"]
RUN dotnet restore "HeadlessCms/HeadlessCms.csproj"
COPY . .
WORKDIR "/src/HeadlessCms"
RUN dotnet build -c Release -o /app/build
FROM build AS publish
RUN dotnet publish -c Release -o /app/publish --no-restore
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "HeadlessCms.dll"]Deploy this to Azure Kubernetes Service, AWS ECS, or Azure Container Apps. Put a CDN like Azure Front Door or Cloudflare in front of your delivery endpoints for geographic distribution and an additional caching layer. CDN caching combined with Redis caching and your application’s in-memory response caching creates a multi-tier stack that can handle enormous read traffic with minimal database load.
Comparing Build vs. Buy
If you’re wondering whether to build your own headless CMS from scratch or use an existing .NET-native solution, that’s a valid question worth exploring before committing to a custom build.
Orchard Core is probably the most comprehensive option available. It’s a fully featured, modular CMS and application framework built on ASP.NET Core that supports headless content delivery alongside traditional rendering. It has an active community, excellent documentation, and a rich module ecosystem. If your requirements align with what Orchard Core provides, it can save you months of development time.
Piranha CMS is lighter weight and specifically designed for decoupled and headless architectures. It’s package-based, targets ASP.NET Core, and is a great fit for small to medium projects where you don’t need the full complexity of Orchard Core.
Squidex is worth a look for developer-focused teams that want a rich, API-first experience with auto-generated REST and GraphQL endpoints. It’s built with ASP.NET Core and CQRS and can be self-hosted with Docker.
The case for building your own comes down to control and fit. If your content model is unusual, your performance requirements are extreme, or you have specific compliance requirements that off-the-shelf tools don’t easily satisfy, a custom ASP.NET Core backend gives you full flexibility. The architecture described in this article is designed to be production-ready, not just a toy example.
Wrapping Up
Building a headless CMS with ASP.NET Core is genuinely satisfying because ASP.NET Core is so well-suited for it. The combination of Minimal APIs for lean endpoints, Entity Framework Core for data access, distributed caching for performance, JWT authentication for security, and optional GraphQL support covers every major concern in a real-world CMS backend.
The architectural principles we’ve covered here (separating delivery from management, caching aggressively, invalidating on publish, designing for multiple frontends, and deploying statelessly) are the same principles that power some of the largest content platforms in the world. Applied to ASP.NET Core, they give you a backend that’s flexible, performant, and maintainable.
Whether you’re building an internal content platform, powering a multi-channel digital experience, or simply exploring what ASP.NET Core can do beyond traditional server-rendered apps, a headless CMS is one of the most practical and rewarding projects you can tackle. The patterns covered here are a solid foundation. Go build something great.
Join The Community
If you found this article useful, subscribe to ASP Today on Substack to get new posts delivered straight to your inbox every week. We cover practical, in-depth ASP.NET Core topics just like this one.
Already subscribed? Jump into the ASP Today community on Substack Chat and let us know what you’re building. We’d love to hear how you’re approaching headless architecture in your own projects.


