Technical Deep Dive
How We Built originalobjective.com: Umbraco 16 Headless with Next.js 15
A deep dive into how we built our own website using Umbraco 16 as a headless CMS with Next.js 15 on the frontend. Covers the architecture decisions, block grid rendering, API generation with Orval, and the lessons we learned along the way.


Curated by Matt Perry
CTO
Why Headless Umbraco?
We have been building with Umbraco since version 7. It is a CMS we know inside out. But for our own site, we wanted something different from the traditional server-rendered Razor approach.
The requirements were straightforward. We needed a content-managed website that loads fast, ranks well in search engines, and gives editors full control over page layouts through the block grid. We also wanted to use React and Tailwind CSS on the frontend because that is where our team works fastest.
Umbraco 16 with its Delivery API gave us exactly that. The CMS handles content modelling, media management, and the block grid editor. Next.js 15 handles rendering, routing, and performance. The two communicate through a typed REST API.
The Architecture
The setup is simple in principle:
Umbraco 16 runs on .NET 9 and serves as the content repository. Editors use the backoffice to create pages, manage block grids, upload media, and publish content. The Delivery API exposes all of this as JSON.
Next.js 15 with the App Router fetches content from the Delivery API at build time and request time. Pages are statically generated where possible, with incremental static regeneration (ISR) keeping content fresh without full rebuilds.
Orval generates TypeScript API clients from the Umbraco Swagger specification. This means we get full type safety across the boundary between CMS and frontend. When a content type changes in Umbraco, we regenerate the client and TypeScript catches any mismatches at compile time.
Block Grid Rendering
The block grid is the core of the editing experience. Editors arrange content blocks in a 12-column grid, choosing from over 40 block types we have built. Each block has content properties and optional settings properties for styling.
On the frontend, a central component mapper (GetComponent.tsx) takes a block's content type alias and returns the correct React component. Each block component receives its content and settings as typed props.
// Simplified example of the component mapper
export function GetComponent(contentTypeAlias: string) {
switch (contentTypeAlias) {
case 'heroBlock':
return HeroBlock;
case 'articleContainerBlock':
return ArticleContainerBlock;
case 'faqBlock':
return FaqBlock;
// ... 40+ more blocks
}
}The block grid wrapper iterates over the layout, resolves each block's component, and renders them with their content and settings. Settings typically control colours, backgrounds, and layout options, keeping styling separate from content.
Server Components by Default
Most of our block components are React Server Components. They render on the server with zero client-side JavaScript. This is a significant performance win because the majority of our blocks are static content: headings, text, images, and cards.
We only use client components ('use client') where genuine interactivity is needed: forms with react-hook-form, tabbed interfaces, search functionality, and animations with Framer Motion. For adding animations to server components without making them client components, we built animation wrapper components that handle the motion logic on the client while keeping the content server-rendered.
API Client Generation with Orval
Orval changed how we work with the Umbraco API. Instead of writing fetch calls by hand and hoping the response shape matches our types, we point Orval at the Swagger JSON and it generates everything.
The generated client includes typed request functions, response models, and even custom fetch wrappers. We added a custom fetch layer (custom-fetch.ts) that handles error responses, caching headers, and revalidation consistently across all API calls.
Our workflow when content types change:
- Update the document type or data type in Umbraco
- Run
pnpm run orval:localto regenerate the TypeScript client - Fix any type errors the compiler flags
- Update the block component if needed
This catches breaking changes at build time rather than in production.
Static Generation and ISR
Next.js 15 changed how data fetching works. The fetch function no longer caches by default, which caused us some headaches initially. Pages that should have been static were rendering dynamically on every request.
We solved this with a combination of generateStaticParams for pre-rendering pages at build time and revalidation settings on our fetch calls. Every API request includes a 60-second revalidation period, meaning content updates appear within a minute without a full rebuild.
One gotcha: Next.js 15 uses Proxy detection on searchParams. Even destructuring searchParams from page props (without awaiting it) triggers dynamic rendering. We had to remove searchParams from page function signatures entirely to keep pages static.
Image Handling
Images are stored in Azure Blob Storage through Umbraco's media system. On the frontend, we use Next.js Image with a custom loader that constructs the correct URL for Umbraco's image processor. This gives us automatic resizing, format conversion, and lazy loading.
The image loader handles both local development (pointing to localhost) and production (pointing to the Azure-hosted CMS) through environment-based URL configuration.
What We Would Do Differently
If we were starting again today, we would:
Use the Umbraco MCP server from day one. We built the initial content types and pages manually through the backoffice. Midway through the project, we started using the Model Context Protocol server to manage content types programmatically. It is dramatically faster and less error-prone, especially for creating block types with compositions.
Set up ISR caching earlier. We spent too long debugging dynamic rendering issues that came down to Next.js 15's new defaults. Understanding the caching model upfront would have saved days.
Build fewer, more flexible block types. We have over 40 block types. Some could have been consolidated into more configurable versions. The maintenance overhead of many small blocks adds up.
The Stack
For reference, here is the full technology stack:
- CMS: Umbraco 16.2.0 on .NET 9.0
- Frontend: Next.js 15.5.7 with React 19
- Styling: Tailwind CSS 4
- API generation: Orval
- Media storage: Azure Blob Storage
- Hosting: Vercel (frontend), Azure App Service (CMS)
- Analytics: GA4 via Google Tag Manager
- Cookie consent: Klaro (GDPR compliant)
The source is not open, but if you are building a similar stack and have questions, get in touch. We are happy to share what we have learned.

Curated by Matt Perry
CTO
Why Headless Umbraco?
We have been building with Umbraco since version 7. It is a CMS we know inside out. But for our own site, we wanted something different from the traditional server-rendered Razor approach.
The requirements were straightforward. We needed a content-managed website that loads fast, ranks well in search engines, and gives editors full control over page layouts through the block grid. We also wanted to use React and Tailwind CSS on the frontend because that is where our team works fastest.
Umbraco 16 with its Delivery API gave us exactly that. The CMS handles content modelling, media management, and the block grid editor. Next.js 15 handles rendering, routing, and performance. The two communicate through a typed REST API.
The Architecture
The setup is simple in principle:
Umbraco 16 runs on .NET 9 and serves as the content repository. Editors use the backoffice to create pages, manage block grids, upload media, and publish content. The Delivery API exposes all of this as JSON.
Next.js 15 with the App Router fetches content from the Delivery API at build time and request time. Pages are statically generated where possible, with incremental static regeneration (ISR) keeping content fresh without full rebuilds.
Orval generates TypeScript API clients from the Umbraco Swagger specification. This means we get full type safety across the boundary between CMS and frontend. When a content type changes in Umbraco, we regenerate the client and TypeScript catches any mismatches at compile time.
Block Grid Rendering
The block grid is the core of the editing experience. Editors arrange content blocks in a 12-column grid, choosing from over 40 block types we have built. Each block has content properties and optional settings properties for styling.
On the frontend, a central component mapper (GetComponent.tsx) takes a block's content type alias and returns the correct React component. Each block component receives its content and settings as typed props.
// Simplified example of the component mapper
export function GetComponent(contentTypeAlias: string) {
switch (contentTypeAlias) {
case 'heroBlock':
return HeroBlock;
case 'articleContainerBlock':
return ArticleContainerBlock;
case 'faqBlock':
return FaqBlock;
// ... 40+ more blocks
}
}The block grid wrapper iterates over the layout, resolves each block's component, and renders them with their content and settings. Settings typically control colours, backgrounds, and layout options, keeping styling separate from content.
Server Components by Default
Most of our block components are React Server Components. They render on the server with zero client-side JavaScript. This is a significant performance win because the majority of our blocks are static content: headings, text, images, and cards.
We only use client components ('use client') where genuine interactivity is needed: forms with react-hook-form, tabbed interfaces, search functionality, and animations with Framer Motion. For adding animations to server components without making them client components, we built animation wrapper components that handle the motion logic on the client while keeping the content server-rendered.
API Client Generation with Orval
Orval changed how we work with the Umbraco API. Instead of writing fetch calls by hand and hoping the response shape matches our types, we point Orval at the Swagger JSON and it generates everything.
The generated client includes typed request functions, response models, and even custom fetch wrappers. We added a custom fetch layer (custom-fetch.ts) that handles error responses, caching headers, and revalidation consistently across all API calls.
Our workflow when content types change:
- Update the document type or data type in Umbraco
- Run
pnpm run orval:localto regenerate the TypeScript client - Fix any type errors the compiler flags
- Update the block component if needed
This catches breaking changes at build time rather than in production.
Static Generation and ISR
Next.js 15 changed how data fetching works. The fetch function no longer caches by default, which caused us some headaches initially. Pages that should have been static were rendering dynamically on every request.
We solved this with a combination of generateStaticParams for pre-rendering pages at build time and revalidation settings on our fetch calls. Every API request includes a 60-second revalidation period, meaning content updates appear within a minute without a full rebuild.
One gotcha: Next.js 15 uses Proxy detection on searchParams. Even destructuring searchParams from page props (without awaiting it) triggers dynamic rendering. We had to remove searchParams from page function signatures entirely to keep pages static.
Image Handling
Images are stored in Azure Blob Storage through Umbraco's media system. On the frontend, we use Next.js Image with a custom loader that constructs the correct URL for Umbraco's image processor. This gives us automatic resizing, format conversion, and lazy loading.
The image loader handles both local development (pointing to localhost) and production (pointing to the Azure-hosted CMS) through environment-based URL configuration.
What We Would Do Differently
If we were starting again today, we would:
Use the Umbraco MCP server from day one. We built the initial content types and pages manually through the backoffice. Midway through the project, we started using the Model Context Protocol server to manage content types programmatically. It is dramatically faster and less error-prone, especially for creating block types with compositions.
Set up ISR caching earlier. We spent too long debugging dynamic rendering issues that came down to Next.js 15's new defaults. Understanding the caching model upfront would have saved days.
Build fewer, more flexible block types. We have over 40 block types. Some could have been consolidated into more configurable versions. The maintenance overhead of many small blocks adds up.
The Stack
For reference, here is the full technology stack:
- CMS: Umbraco 16.2.0 on .NET 9.0
- Frontend: Next.js 15.5.7 with React 19
- Styling: Tailwind CSS 4
- API generation: Orval
- Media storage: Azure Blob Storage
- Hosting: Vercel (frontend), Azure App Service (CMS)
- Analytics: GA4 via Google Tag Manager
- Cookie consent: Klaro (GDPR compliant)
The source is not open, but if you are building a similar stack and have questions, get in touch. We are happy to share what we have learned.
Subscribe to the AI Growth Newsletter
Get weekly AI insights, tools, and success stories - straight to your inbox.
Here is what you will get when you subscribe:

- AI for SMBs - adopt AI without big budgets or complex setup
- Future Trends - what is coming next and how to stay ahead
- How to Automate Your Processes - save time with workflows that run 24/7
- Customer Service AI - chatbots and agents that delight customers
- Voice AI Solutions - smarter calls and seamless accessibility
- AI News - how to stay ahead of the ever changing AI world
- Local Success Stories - how AI has changed business in the UK
No spam. Just practical AI tips for growing your business.