Theme:

Headless Umbraco: Two Paths to JSON Glory

Sometimes you need Umbraco to just handle content while your frontend does its own thing. This article compares two approaches: Umbraco's built-in Content Delivery API versus building your own custom solution. We'll look at real code, and help you figure out which one actually fits your project.

Not every Umbraco project needs to follow the traditional MVC pattern. Sometimes you need Umbraco purely for content management while your React, Vue, or Angular frontend handles the presentation layer. This article walks through two practical approaches: using Umbraco's built-in Content Delivery API versus building a custom solution with route hijacking.

Both approaches work well—the key is knowing which fits your project.

What Does "Headless" Even Mean?

A headless setup separates content management from presentation. Umbraco stores and manages your content, exposing it as JSON through APIs. Your frontend application then consumes that JSON and renders it however you need. This gives you flexibility in technology choices and enables multi-channel content delivery across web, mobile, and other platforms.

Option 1: Umbraco's Content Delivery API

Since Umbraco 12, the CMS ships with a built-in Content Delivery API that turns your content into JSON without writing a single line of code. It's like ordering pizza instead of making it yourself—sometimes the convenience is worth it.

For this article, I'm using V17 pre-release

Getting Started with the Content Delivery API

Install Umbraco Templates:

dotnet new install Umbraco.Templates

Create Your Project:

dotnet new umbraco --name MyProject

Enable Content Delivery API:

- For Umbraco 15+:

dotnet new umbraco -n MyProject -da

- For Umbraco 13 and 14, add this to your appsettings.json:


{
    "Umbraco": {
        "CMS": {
            "DeliveryApi": {
                "Enabled": true,
                "PublicAccess": true,
                "ApiKey": "my-api-key",
                "DisallowedContentTypeAliases": ["alias1", "alias2", "alias3"],
                "RichTextOutputAsJson": false
            }
        }
    }
}

Important: After enabling Content Delivery API, you need to manually rebuild the Delivery API content index (DeliveryApiContentIndex) in the Examine Management dashboard under Settings.

Configuration Options

The Content Delivery API gives you several knobs to turn:

  • PublicAccess: Set to false if you want to require API key authentication
  • ApiKey: Your secret handshake for secured endpoints
  • DisallowedContentTypeAliases: Content types you want to keep hidden
  • RichTextOutputAsJson: Toggle between JSON and HTML for rich text (JSON is better for frontend processing)

Using the API

Once configured, you can access your content through these endpoints:

  • /umbraco/delivery/api/v2/content - Query multiple content items
  • /umbraco/delivery/api/v2/content/item/{id} - Get a specific item by ID
  • /umbraco/delivery/api/v2/content/item/{path} - Get an item by its path

The API supports powerful querying:

  • Fetch: Get ancestors, children, or descendants
  • Filter: Apply custom filters to narrow results
  • Sort: Order results by various criteria
  • Expand: Include referenced content and media in responses
  • Fields: Limit which properties are returned (great for performance)

What the JSON Looks Like

Here's what Content Delivery API returns for a simple home page:


{
  "contentType": "home",
  "name": "Home",
  "createDate": "2025-11-06T14:03:18.6338252Z",
  "updateDate": "2025-11-20T14:37:18.4889247Z",
  "route": {
    "path": "/",
    "queryString": null,
    "startItem": {
      "id": "c60e5166-ba2d-4e17-a9eb-6db6fb211d1f",
      "path": "home"
    }
  },
  "id": "c60e5166-ba2d-4e17-a9eb-6db6fb211d1f",
  "properties": {
    "metaTitle": null,
    "metaDescription": null,
    "metaImage": null,
    "modules": {
      "items": [
        {
          "content": {
            "contentType": "bannerComponent",
            "id": "b4f0bbe2-2810-4349-ba50-1eda4e7128af",
            "properties": {
              "title": "title"
            }
          },
          "settings": null
        }
      ]
    }
  },
  "cultures": {}
}

Notice how it includes everything—IDs, dates, routes, culture info, even empty fields. It's comprehensive, which is great for flexibility but sometimes overkill for simple needs.

Extending the Content Delivery API

The Content Delivery API is extensible. You can implement custom handlers for:

  • ISelectorHandler - Custom fetch logic
  • IFilterHandler - Specialized filtering
  • IContentIndexHandler - Control how content is indexed

For deep dives on extending the Content Delivery API, check out Kenn's excellent articles.

Developer Tools

The Umbraco.Community.DeliveryApiExtensions package by lovely Laura and ByteCrumb lets you view JSON output directly in the CMS as a Content App. It's much more convenient than constantly hitting Swagger endpoints during development.

Option 2: Custom Headless Solution with Route Hijacking

Now for the DIY approach. Sometimes you need complete control over your JSON structure, want minimal payloads, or have specific business logic that doesn't fit the Content Delivery API mold. That's where a custom solution shines.

This approach uses Umbraco's route hijacking to intercept page requests and return JSON instead of Razor views. It's more work upfront but gives you surgical precision over the output.

Architecture Overview

The solution uses a clean, service-based architecture with dependency injection:

Controllers → Services → Block Dispatcher → Module Handlers → ViewModels

This separation keeps concerns nicely organized and makes testing easier.

 

What's a Block Dispatcher?

Before diving into the custom solution, let's clarify an important concept. When content editors build pages using Block List elements, these blocks come through as generic items. A Block Dispatcher is basically a router. It examines each block's type and sends it to the corresponding handler. Banner blocks go to the banner handler, gallery blocks to the gallery handler, and so on. This pattern keeps your code organized and makes adding new block types straightforward. (Thank you true team ❤️)

 

Step 1: Create Your Document Types

For this example, I've set up:

  • Home Page with template
  • Banner Component (Block List element)

Step 2: Page Controller

Create a base page controller that all page controllers inherit from:


public class PageController : RenderController
{
    public PageController(ILogger<RenderController> logger,
        ICompositeViewEngine compositeViewEngine,
        IUmbracoContextAccessor umbracoContextAccessor)
        : base(logger, compositeViewEngine, umbracoContextAccessor)
    {
    }

    public async Task<ResponseViewModel> TryGetApiResponseModelAsync(Func<Task<ResponseViewModel>> op)
    {
        var result = new ResponseViewModel();

        try
        {
            result = await op.Invoke();
        }
        catch (Exception e)
        {
            result.StatusCode = HttpStatusCode.InternalServerError;

            result.Error = new ErrorDetails(e);
        }

        return result;
    }
}

This base controller provides centralized error handling. Any exception gets caught and returned as a proper error response with status codes.

Step 3: Response Models

Define a consistent response structure:


public class ResponseViewModel
{
    public ContentResponseViewModel Content { get; }

    public HttpStatusCode StatusCode { get; set; }

    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    public ErrorDetails? Error { get; set; }

    public ResponseViewModel()
    {
        Content = new ContentResponseViewModel();

        StatusCode = HttpStatusCode.OK;
    }
}

public class ErrorDetails
{
    public string Message { get; set; }

    public string? StackTrace { get; set; }

    public string? Type { get; set; }

    public ErrorDetails(Exception exception)
    {
        Message = exception.Message;

        StackTrace = exception.StackTrace;
        
        Type = exception.GetType().Name;
    }
}

This gives you a consistent JSON structure with proper HTTP status codes and optional error details when things go wrong.

Step 4: Page Service Base Class

Create a base service that all page services inherit from:


public class PageService
{
    protected readonly IUmbracoMapper UmbracoMapper;
    protected readonly IModuleRenderService ComponentRenderService;

    public PageService(
        IUmbracoMapper umbracoMapper,
        IModuleRenderService componentRenderService)
    {
        UmbracoMapper = umbracoMapper;
        ComponentRenderService = componentRenderService;
    }
    protected async Task<ResponseViewModel> GetInitialApiResponseModelAsync(IPublishedContent content)
    {
        var apiResponseModel = new ResponseViewModel();

        //Add any common logic here (SEO, metadata, etc.)

        return apiResponseModel;
    }
}

Step 5: Implement Home Page Controller and Service

Now we can implement a specific page. This uses Umbraco's route hijacking convention: [DocumentTypeAlias]Controller.

Home Controller:


public class HomeController : PageController
{
    private readonly IHomePageService _homePageService;

    public HomeController(ILogger<RenderController> logger, 
        ICompositeViewEngine compositeViewEngine, 
        IUmbracoContextAccessor umbracoContextAccessor,
        IHomePageService homePageService) 
        : base(logger, compositeViewEngine, umbracoContextAccessor)
    {
        _homePageService = homePageService;
    }

    public async Task<IActionResult> Home()
    {
        var responseModel = await TryGetApiResponseModelAsync(
            () => _homePageService.GetApiResponseModelAsync(
                CurrentPage as Home));

        return StatusCode((int)responseModel.StatusCode, responseModel);
    }
}

Home Service


public class HomePageService : PageService, IHomePageService
{
    public HomePageService(IUmbracoMapper umbracoMapper, IModuleRenderService componentRenderService) 
        : base(umbracoMapper, componentRenderService)
    {
    }

    public Task<ResponseViewModel> GetApiResponseModelAsync(Home model)
    {
        var responseModel = GetInitialApiResponseModelAsync(model);

        var modulesDto = new ModulesDto
        {
            CurrentPage = model,
            Modules = model.Modules ?? BlockListModel.Empty
        };

        var homePageViewModel = new HomePageViewModel
        {
            Modules = ComponentRenderService.GetComponentViewModels(modulesDto)
        };

        responseModel.Result.Content.Page = homePageViewModel;

        return responseModel;
    }
}

Home Page ViewModel:


public class HomePageViewModel : IPageViewModel
{
    public string PageTypeAlias => Home.ModelTypeAlias;

    public IEnumerable<IModuleViewModel> Modules { get; set; }
}

Step 6: Block Dispatcher Pattern

Here's where it gets interesting. The Block Dispatcher handles converting Umbraco Block List items into strongly-typed view models:


public class BlockDispatcher
{
    private readonly Dictionary<string, Func<IModuleHandler>> _handlersByType = new();

    public BlockDispatcher(IServiceProvider serviceProvider)
    {
        var handlers = serviceProvider.GetServices<IModuleHandler>();

        foreach (var handler in handlers)
        {
            _handlersByType.Add(handler.Alias, () => (IModuleHandler)serviceProvider.GetService(handler.GetType()));
        }
    }

    public IModuleViewModel Dispatch(string aliasType, BlockListItem block, ModulesDto pageComponentsDto)
    {
        var handlerFactory = _handlersByType.GetValueOrDefault(aliasType);

        if (handlerFactory == null)
        {
            throw new ArgumentException($"No handler found for alias : {aliasType}");
        }

        var handler = handlerFactory();

        if (handler is null)
            throw new ArgumentException($"Handler for {aliasType} is null");

        var component = handler.Handle(block, pageComponentsDto);
       
        return component;
    }
}

Step 7: Module Render Service

The service that orchestrates component rendering:


public class ModuleRenderService : IModuleRenderService
{
    private readonly BlockDispatcher _blockDispatcher;

    public ModuleRenderService(BlockDispatcher blockDispatcher)
    {
        _blockDispatcher = blockDispatcher;
    }

    public IEnumerable<IModuleViewModel> GetComponentViewModels(ModulesDto pageComponentsDto)
    {
        var components = new List<IModuleViewModel>();

        var pageComponents = pageComponentsDto.Modules;

        if (pageComponents != null && pageComponents.Any())
        {
            foreach (var blockListItem in pageComponents.ToList())
            {
                var blockListAlias = blockListItem.Content?.ContentType.Alias;

                var component = _blockDispatcher.Dispatch(blockListAlias, blockListItem, pageComponentsDto);

                components.Add(component);
            }
        }

        return components;
    }
}

public interface IModuleHandler
{
    public string Alias { get; }

    IModuleViewModel Handle(BlockListItem blockListItem, ModulesDto pageComponentsDto);
}

Step 8: Create Module Handlers

Each Block List component gets its own handler:



public class BannerComponentHandler : IModuleHandler
{
    public string Alias => BannerComponent.ModelTypeAlias;

    public IModuleViewModel Handle(BlockListItem blockListItem, ModulesDto modulesDto)
    {
        if (blockListItem is not BlockListItem<BannerComponent> component)
        {
            return new BannerComponentViewModel();
            
        }

        var viewModel = new BannerComponentViewModel
        {
            Title = component.Content.Title
        };

        return viewModel;
    }
}

Step 9: Register Services (.NET 10 Considerations)

In your Program.cs or composition root, register all services. Note that .NET 10 requires explicit JSON configuration

 

The Result

Your custom endpoint returns clean, minimal JSON:


{
  "content": {
    "page": {
      "pageTypeAlias": "home",
      "modules": [
        {
          "type": "bannerComponent",
          "title": "title"
        }
      ]
    }
  },
  "statusCode": 200
}

Compare this to the Content Delivery API output—it's about 70% smaller and contains only what you need.

 

Real-World Scenarios

Scenario 1: Marketing Website

Requirement: Public marketing site, multiple languages, content editors need preview, React frontend.

Winner: Content Delivery API

Why: The Content Delivery API handles multi-language, preview, and all property types automatically. The verbose JSON is fine since you're building a SPA anyway. Your frontend team gets OpenAPI docs to work with.

Scenario 2: Mobile App Backend

Requirement: Native mobile app, specific JSON structure, needs to combine Umbraco content with external APIs, performance critical.

Winner: Custom Solution

Why: You need minimal payloads for mobile bandwidth, a specific structure the app expects, and you're aggregating data from multiple sources. The custom approach gives you complete control.

Scenario 3: Microsite with 5 Pages

Requirement: Simple promotional site, just a few pages, tight deadline.

Winner: Content Delivery API

Why: Why write code when you don't have to? Enable Content Delivery API and ship it.

Scenario 4: Enterprise Intranet

Requirement: Internal tool, complex permissions, custom business rules, needs data from HR systems and SharePoint.

Winner: Custom Solution

Why: You're implementing custom authorization anyway, need to aggregate multiple data sources, and have specific business logic. The custom approach lets you build exactly what you need.

 

Conclusion

Both the Content Delivery API and custom solutions have their place. The Content Delivery API is production-ready, well-documented, and handles most headless scenarios without writing code. It's the faster path for standard implementations.

Custom solutions offer precision control and minimal payloads when you need to aggregate data from multiple sources or have unique requirements that don't fit the standard model.

Writing this article took 45x longer than implementing the code examples—hopefully it saves you some time figuring out which approach fits your project. If you have questions, ping me on the Umbraco Discord or Twitter.

Resources