DEV Community

Cover image for Headless Umbraco with the Clean Starter Kit and Next.js
Phil Whittaker
Phil Whittaker

Posted on • Edited on

Headless Umbraco with the Clean Starter Kit and Next.js

We’ll walk through building a headless Umbraco site using two resources:

Umbraco Clean Starter Kit (by Paul Seal) – A simple, clean blog starter kit for Umbraco, that has been preprepared for headless delivery. (Umbraco Marketplace)

Clean Headless Next.js Frontend (by Phil Whittaker) – a Next.js project that consumes Umbraco Clean’s content via the Delivery API. (GitHub)

By following these steps, you’ll enable the Delivery API, set up a webhook for content revalidation, generate a typed API client with Orval, handle dictionary (language) items, and optimize images. Let’s dive in.

1. Setting Up Umbraco with the Clean Starter Kit

First, we need an Umbraco instance with some content. Paul Seal’s Clean Starter Kit gives us a pre-made blog (with homepage, navigation, sample content, etc.) which is perfect for demonstrating headless use.

Install Umbraco + Clean: Use the template for the Clean Starter Kit, not the Nuget package install. This will make it easier to understand and tweak the headless implementation if needed. The commands below will install for Umbraco 16.

In a terminal, run:

#Install the template for Clean Starter Kit
dotnet new install Umbraco.Community.Templates.Clean::6.0.0 --force

#Create a new project using the umbraco-starter-clean template
dotnet new umbraco-starter-clean -n MyProject

#Go to the folder of the project that we created
cd MyProject

#Run the new website we created
dotnet run --project "MyProject.Blog"

# Login with admin@example.com and 1234567890.
# Save and publish the home page and do a save on one of the 
# dictionary items in the translation section.
# The site should be running and visible on the front end now
Enter fullscreen mode Exit fullscreen mode

This will spin up a new instance of the Clean Starter kit in Umbraco running at http(s)://localhost:... with a random port. The template will create several dotnet projects; MyProject.Core, MyProject.Models, MyProject.Headless and MyProject.Blog. After installation, you should see content nodes (Home, etc.).

2. Enabling the Delivery API in Umbraco

By default, Umbraco’s Delivery API is disabled for security reasons. We need to enable it so our frontend can fetch content. This involves a simple config change and an update to the program.cs file:

In appsettings.json: In the Umbraco:CMS section, enable the Delivery API:

{
  "Umbraco": {
    "CMS": {
      "DeliveryApi": {
        "Enabled": true
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Update Program.Cs: In MyProject.Blog/program.cs add .AddDeliveryApi() to CreateUmbracoBuilder()

builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddWebsite()
    .AddDeliveryApi()
    .AddComposers()
    .Build();
Enter fullscreen mode Exit fullscreen mode

Finally start the Umbraco instance. Once completed, rebuild the “DeliveryApiContentIndex” via the Examine dashboard in the backoffice (Settings -> Examine -> Rebuild index) to ensure all content is indexed for the API.

Occasionally I have seen the content nodes fail to publish. Before we go any further, check that the non headless version of the site works before proceeding.

Now your Umbraco site will serve content at endpoints like /umbraco/delivery/api/v1/content/.... For example, try hitting http://localhost:port/umbraco/delivery/api/v2/content to see if you get a JSON result.

3. Configuring for Content Revalidation

We will want Umbraco to notify our Next.js app whenever content changes, so the frontend can update its static pages. The Clean starter kit includes a built-in code to handle this via a bespoke api calls to Next.Js.

In Umbraco’s appsettings.json, find the NextJs:Revalidate section. It just needs to be enabled:

"NextJs": {
  "Revalidate": {
    "Enabled": true,
    "WebHookUrls": [ "http://localhost:3000/api/revalidate" ],
    "WebHookSecret": "SOMETHING_SECRET"
  }
}
Enter fullscreen mode Exit fullscreen mode

This configuration tells Umbraco: After content is published, send a POST request to our Next.js app’s /api/revalidate endpoint, using SOMETHING_SECRET as the secret key. We’ll see in the Next.js code how that secret is used for security. For simplicity, we'll keep the WebHookSecret as it is but in production this should be changed on both Umbraco and NextJs instances.

With this enabled, the Clean kit’s code will hook into content published events and fire off the webhook. You may need to restart the Umbraco site after changing settings.

4. Cloning and Running the Next.js “clean-headless” Frontend

Now for the frontend. Clone the Next.js repository from GitHub:

git clone https://github.com/hifi-phil/clean-headless.git
cd clean-headless
npm install  
Enter fullscreen mode Exit fullscreen mode

Before running it, we need to configure a few environment variables. Create a .env.local file in the project root with the following (adjusting values to your setup):

# Base URL of your Umbraco site (no trailing slash)
NEXT_PUBLIC_UMBRACO_BASE_URL="http://localhost:5000"      
UMBRACO_REVALIDATE_SECRET="SOMETHING_SECRET"             
UMBRACO_REVALIDATE_ACCESS_CONTROL_ORIGIN="*"
Enter fullscreen mode Exit fullscreen mode

With env vars set, start the Next.js app:

#This will build a production ready version of the site
npm run build
Enter fullscreen mode Exit fullscreen mode

This will display a report of how the production version of the project is structured

Route (app)                                 Size  First Load JS    
┌ ○ /                                    1.16 kB         429 kB
├ ○ /_not-found                            146 B         101 kB
├ ● /[...page]                           1.16 kB         429 kB
├   ├ /features
├   └ /about
├ ƒ /api/revalidate                        146 B         101 kB
├ ○ /authors                               313 B         109 kB
├ ● /authors/[slug]                      1.16 kB         429 kB
├ ○ /blog                                1.16 kB         429 kB
├ ● /blog/[slug]                         1.16 kB         429 kB
├ ○ /contact                             1.11 kB         113 kB
├ ○ /robots.txt                            146 B         101 kB
├ ○ /search                              20.1 kB         135 kB
└ ƒ /sitemap.xml                           146 B         101 kB
+ First Load JS shared by all             101 kB
  ├ chunks/4bd1b696-7514213894eafa96.js  53.3 kB
  ├ chunks/684-7053df2aeaba7132.js       45.8 kB
  └ other shared chunks (total)          1.95 kB


○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand
Enter fullscreen mode Exit fullscreen mode

This is VERY important. The white and black dots tell us that the site is using statically generated content (HTML files) which is the most efficient and costs the least.

From here we can start the site in production mode.

#This with start the production version
npm run start
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000 in your browser. You should see the site loading content from Umbraco! The Clean-headless frontend is designed to mirror the Clean starter kit site, but delivered via Next.js. You’ll see the homepage content, navigation, and so on, matching what’s in Umbraco. You should be able to navigate around the site and reach all the pages.

If you update some content in Umbraco it will change in the Next.Js site. Note that the file served from Next.Js is a static HTML file, the change has been updated without doing any costly content rebuilds, this all happens seamlessly and transparently.

Let’s highlight some important aspects of this Next.js project and what we are doing:

5. Generating Typed API Clients with Orval

Manually creating TypeScript interfaces for all your Umbraco content JSON can be tedious. Orval is used here to automate that.

Orval reads the OpenAPI (Swagger) spec exposed in two swagger json files by the Umbraco instance. This includes not only the core Delivery API schema but also Clean’s custom endpoints that we have created (like dictionary, search, etc.).

You can see these swagger definitions at
/umbraco/swagger/index.html?urls.primaryName=MyProject+starter+kit
/umbraco/swagger/index.html?urls.primaryName=Umbraco+Delivery+API

To improve the output from Swagger and to generate typed client files in NextJs we will need to install the package Umbraco Delivery Api Extensions into the MyProject.Blog project.

dotnet add package Umbraco.Community.DeliveryApiExtensions
Enter fullscreen mode Exit fullscreen mode

For our purposes: you don’t necessarily need to run Orval manually because the repo already has generated the models. But it’s good to know how it works.

The clean-headless repo includes an orval.config.js file which defines how to generate the client. It targets the Umbraco Swagger JSON files and outputs TypeScript in the project (splitting by tags, using fetch, etc.).

module.exports = {
  'umbraco-transfomer': {
    output: {
      mode: 'tags-split',
      target: './src/api/client.ts',
      baseUrl: 'http://localhost:5000/',
      schemas: './src/api/model',
      client: 'fetch',
      override: {
          mutator: {
              path: './src/custom-fetch.ts',
              name: 'customFetch',
          },
      },
    },
    input: {
      target: 'http://localhost:5000/umbraco/swagger/delivery/swagger.json',
    },
  },
  'clean-starter-transfomer': {
    output: {
      mode: 'tags-split',
      target: './src/api-clean/client.ts',
      baseUrl: 'http://localhost:5000/',
      schemas: './src/api-clean/model',
      client: 'fetch',
      override: {
          mutator: {
              path: './src/custom-fetch.ts',
              name: 'customFetch',
          },
      },
    },
    input: {
      target: 'http://localhost:5000/umbraco/swagger/clean-starter/swagger.json?urls.primaryName=Clean+starter+kit',
    },
  }
};
Enter fullscreen mode Exit fullscreen mode

Before we can re-generate the port (5000) should be changed to point to your Umbraco instance.

In our project, running npm run generate would execute Orval, generating files in src/api/ and src/api-clean folders for the various API endpoints.

The config also references a customFetch in src/custom-fetch.ts – this is a custom wrapper around fetch that the developer created, in order to pass Next.js revalidation params.

Orval is useful as it allows overriding the fetch client to integrate Next’s revalidation logic. This is an advanced scenario, but essentially it means the generated API functions will call customFetch(), which can append special headers or query params. For example, if using Next’s fetch with a revalidate option (for ISR), one might customise fetch to ensure it uses Next’s caching properly.

With Orval you can use easy functions like getHomePage() or getContentById() which return fully typed data corresponding to your Umbraco models. This dramatically improves the developer experience – you can see what fields exist, avoid typos in property names, etc., much like you do in Umbraco’s server-side code with strongly-typed models.

6. Handling Dictionary Items (Localisation)

The Clean starter kit uses Umbraco’s Dictionary for localised text (e.g., labels, site title, etc.). Out-of-the-box, Umbraco’s Delivery API does not expose dictionary items via the standard content endpoints.

To bridge this, Clean includes a custom Dictionary API endpoint. In the Clean Umbraco project, you’ll find an API controller providing an endpoint to fetch dictionary values ( /api/v1/dictionary/getdictionarytranslations/ returning key-value pairs). The OpenAPI spec includes it, and Orval has generated a function for it (e.g., getDictionaryItems()).

7. Revalidation: How Content Updates Trigger Next.js

  • Umbraco side: The Clean kit’s code (enabled via NextJs:Revalidate config) listens for publish events. When a content node is published, it prepares a JSON payload with information about what changed. This includes the content’s URL (or “contentPath”), and flags like updateNavigation or updateLocalisation (which might be set true if, say, a menu node changed or a dictionary item changed). It then sends an HTTP POST to the WebHookUrls we configured, with the JSON body and an X-Hub-Signature-256 header that is an HMAC of that body using our secret
foreach (var content in notification.PublishedEntities)
{
    if (_allowedContentContentType.Any(x => x == content.ContentType.Alias))
    {
        if (_umbracoContextAccessor.TryGetUmbracoContext(out var umbracoContext) && umbracoContext.Content != null)
        {
            var publishedContent = umbracoContext.Content.GetById(content.Id);
            if (publishedContent != null)
            {
                var path = publishedContent.Url();
                _logger.LogInformation($"Web Content next js revalidation triggered for path {path}");
                await _revalidateService.ForContent(path);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Next.js side: In our Next app, there is an API route to handle this: a Route Handler at src/app/api/revalidate/route.ts. The code for this route does a few things:
  1. Verify the signature – it reads the raw request body and computes the expected header from the secret. If they don’t match, it returns 400 (to ensure only Umbraco (with the correct secret) can trigger revalidation).

  2. Parse the payload – it expects the JSON with maybe contentPath, updateNavigation, updateLocalisation fields. Based on these, it will call Next.js revalidation functions.

  3. Call Next.js revalidate functions – it uses revalidatePath() for specific pages and revalidateTag() for any cached data that is tagged. From the Clean code, if contentPath is provided, they remove any trailing slash and call revalidatePath(contentPath) to revalidate that page’s static cache. They also always call revalidateTag('navigation') when content changes, because the layout or menu might be cached separately and needs updating on any content change.

If updateLocalisation is true (e.g., a dictionary changed), they might similarly clear a tag for localisation.

In essence, Next will purge the stale HTML for that route so that next request triggers a rebuild. Because of Next’s ISR, users either get fresh content on the next load, or at worst they got the stale page once and the very next request got the fresh one.

This setup ensures content is updated near-instantly on the site. If you open your Next site and the Umbraco backoffice side by side: edit a piece of content (say, change the homepage title) and publish, you should be able to refresh the Next.js site after a second and see the change live. No manual deploys of the frontend, no waiting for a full rebuild – the on demand revalidation took care of it.

8. Image Optimisation with a Custom Next.js Image Loader

Next.js has an component that can optimise images, but since we are pulling media from Umbraco’s server, we want to leverage Umbraco’s built-in image processing (ImageSharp). In the Clean-headless project, the developers created a custom image loader for Next.js to handle Umbraco media.

In next.config.js, you might have noticed:

images: {
  loader: 'custom',
  loaderFile: './src/image-loader.ts',
},
Enter fullscreen mode Exit fullscreen mode

This tells Next to use our custom loader. Let’s see what image-loader.ts does:

// src/image-loader.ts
export default function UmbracoMediaLoader({ src, width, quality } : { src: string, width: string, quality?: string }) {
  return `${process.env.NEXT_PUBLIC_UMBRACO_BASE_URL}${src}?w=${width}&q=${quality || 75}`
}
Enter fullscreen mode Exit fullscreen mode

This simple function takes the src (which in Next.js would be the path of the image as stored in Umbraco, e.g. /media/abcd1234/filename.jpg), and the desired width/quality. It returns a URL pointing to Umbraco’s backend with query parameters for width and quality. Umbraco will automatically serve a resized image thanks to its image processing pipeline (for instance, ?w=300&q=75 gives a 300px wide JPEG at 75% quality).

On the Next.js page, you can use Next’s component like:

import Image from 'next/image';

<Image 
    src="/media/abcd1234/filename.jpg" 
    width={800} 
    height={600} 
    alt="Example" 
    loader={UmbracoMediaLoader}  // This may be auto-set globally by next.config
/>
Enter fullscreen mode Exit fullscreen mode

Next will call our loader to get the correct URL. The benefit: images are optimised and cached via the CDN (since the URL is unique per width/quality), and we offload the heavy lifting to Umbraco. We don’t need Next.js to handle image proxying or have its own cache – simpler architecture and it reuses Umbraco as an asset CDN.

If your Umbraco is on Umbraco Cloud or behind its own CDN (e.g., Cloudflare), those image URLs will be served very fast. If not, you could also host images on Azure Blob and serve via a CDN. Either way, this custom loader technique is great to know: it integrates Next’s optimized image component with Umbraco’s image processing.

Final Thoughts

By following this approach, we achieve a modern, decoupled Umbraco solution:

  • Content editors keep using Umbraco’s familiar backoffice. When they publish content, the static site out front updates almost immediately, thanks to our custom api calls to Next.js & ISR.

  • Developers get to work with a cutting-edge frontend stack, with full control over the user experience, routing, and performance optimisations. We saw how tools like Orval bring strong-typing to the frontend models (no more guesswork on JSON shapes), and how we can integrate frameworks/libraries like ShadCN UI or Storybook to boost our productivity.

  • Infrastructure is simplified: Umbraco can be a slim API app (it could even be on a lower-tier server, since public traffic mainly hits the frontend), and the frontend can be deployed on a Vercel. With CDN caching, users around the world get content quickly, and the load on Umbraco is minimal (only on content publish or cache miss).

In 2025, this architecture is often the sweet spot between dynamic and static: we get the performance and scalability of static sites with CDN delivery, and the freshness and interactivity of dynamic sites through ISR and APIs. It truly is the best of both worlds for Umbraco implementations.

Umbraco’s evolution has made it easier than ever to go headless – you can use the open-source CMS you love and still achieve a Jamstack workflow. By pairing Umbraco with frameworks like Next.js, you’re investing in a future-proof, modular architecture. Your front-end is no longer tied to .NET releases, and your backend can focus on what it does best (content).

If you’re an Umbraco developer building monolithic MVC sites, now is the time to try headless. The benefits in infrastructure simplicity, upgradeability, and performance are tangible. As we demonstrated, you don’t have to start from scratch – the Clean starter kit headless implementation and community tools are there to jump-start your journey. Give it a try and experience how headless Umbraco can modernise your delivery approach!

Top comments (1)

Collapse
 
krebil profile image
Tobias Mønster

Hey Phil, great article!

I'm wondering how you handle revalidation on unpublish and delete events since at that point a piece of content may not have a path anymore. Do you calculate it manually?

Also is there a reason you prefer a notification handler instead of the built in webhooks?