Umbraco headless & SvelteKit in production
In 2023 and 2024, me and my team at iO (re)built the public website for IBN. The old website dated from 2016, and the CMS – Umbraco – was End-of-Life, which means no security updates would be coming out in the future.
Also, IBN wanted their website to become fully accessible. I recommended to build a new front-end too, because it was also 7 years old.
Umbraco CMS
Umbraco was chosen again because the customer had good experience with it. Umbraco 11 was the latest version at the time, which didn’t have a native content API. We tried a GraphQL package, but it was a lot of work to get the content out of the CMS. To build a page I just needed all the content that was added to a page.
Umbraco’s Content Delivery API
After a week of struggling with GraphQL, Umbraco 12 came out, which had a native content API, called the Content Delivery API. We tried it out, and it gave me everything I need: all content of any page, fetchable by the page’s URL.
For most pages and functionality, we use the ‘native’ Content Delivery API. It can provide:
- Site-wide settings and content, like the main navigation items
- All content and metadata per URL/page
- A paginated list of news items, to generate a news overview
- The parent pages of the current page, to generate breadcrumbs
- A list of all news pages, to generate an RSS feed
- A list of all pages to generate a sitemap
- An endpoint for posting Umbraco Forms form submissions to
Custom APIs
We built two custom API where the Delivery API could not provide for:
- A search API
- A vacancy filter API
Page types
The CMS is configured to be a regular website. There is a homepage, with subpages. Pages have a certain page type, which determines a bit of different structure per type:
- Modular page
- News overview
- News detail
- Page with subnavigation
- Vacancy overview
- Vacancy detail
- Vacancy application
- Search results page
Next to that, we have a little bit of ‘global content’ (or ‘floating content’), which can be (re)used in a content picker in i.e. an FAQ component.
The setup can be easily extended to serve as a multi-site environment.
Page metadata
All pages have a tab with metadata settings. We use SEO Checker for meta tags. It was chosen because it was present in the old platform, but in hindsight we could have easily set up the fields for meta tags ourselves. The redirect module and sitemap generator don’t work if you use the Delivery API.
A modular page
Every page consists of at least a Hero component, which ensures all pages have an <h1>
element.
Most pages are what we call a ‘modular’ page. The rest of the page can be set up with components in a Block List. Every component has a mandatory title, which outputs as an <h2>
in the front-end. This ensures the correct heading structure.
SvelteKit
For the front-end, we chose SvelteKit as a meta-framework. For a content site, it is an excellent choice for its features, ease of use and performance.
Or Next.js?
I think Next.js and Nuxt are too heavy for performance for a ‘regular’ content website. It might be a good choice for complex web applications. However, I think SvelteKit might be a good fit for this too, but there is not enough experience yet.
Or Astro?
I also considered Astro, but I don’t think this framework is mature enough yet. I built Drupal voor Overheden in Astro, which was a quite nice experience and the result is perfect in terms of performance. The biggest downside for me is you can’t enforce a good Content Security Policy in Astro: you have to enable script-src unsafe-inline
, which I think is unacceptable for client work.
Routing
Most meta-frameworks like Next.js, Nuxt, SvelteKit and Astro have a built-in router which generates URLs based on your folder structure. I don’t want that: the content editor and the CMS need to determine the structure. Content editors still create pages, not floating pieces of content.
One route to rule them all
To achieve this, I created one ‘catch-all’ route where all pages are generated from. I did this by creating a [...rest]
folder in the routes
folder. The +page.server.js
file is where all the magic happens. A function checks Umbraco’s Content Delivery API if it has a matching page. If it returns a 404, the content from an Umbraco page marked as the 404 page is fetched from the Delivery API, and a 404 page is served to the user.
Site data
I also fetch generic site data on every page request. This contains the site title, generic metadata, and all content for the main navigation and footer. If a 404 is thrown for a page, I still need this data to render the full 404-page.
Page types
The Delivery API always returns the page type (document type). There is (only) one ‘entry’ template in SvelteKit. Based on the page type, the page template is determined. This has one downside, which I elaborate on later.
Extra data for page type
Only for a couple of page types I need some extra content which is not returned by the Delivery API initially.
News overview
This template needs child items of itself: news detail items. The Delivery API has a built-in function to get this, and can do it paginated too. The following url returns the child pages of /news/
, sorts it on the publishDate and takes the first 10:
`https://your.website/umbraco/delivery/api/v1/content?fetch=children:/news&take=10&sort=publishDate%3Adesc&skip=0`
For breadcrumbs, I do a similar request by fetching the ‘ancestors’ of the current page.
Vacancy overview
The vacancy overview page lists sub items too, but this page has filtering. We built a custom API to fetch a subset of vacancies, based on query parameters. This way I can do a server-side render with filtered results. I can also re-use the API for client-side filtering. So checking a filter checkbox fetches a new set of items, in-page. In SvelteKit, this is super-easy. The trick is to update the query string
const handleCheckboxChange = async (e) => {
// fake get request with query params
const formData = new FormData(e.currentTarget);
const queryString = new URLSearchParams(formData);
replaceState(`?${queryString.toString()}`, $page.state);
// get async vacancies from api
formData.set('pageId', $page.data.pageData.id);
const queryString2 = new URLSearchParams(formData);
// fetch data from a local API, which passes the request on to the Content Delivery API
const request = await fetch(`/api/vacancies?${queryString2.toString()}`);
const response = await request.json();
// set response data to state
filters = response.Filters;
items = response.Results;
};
Site search
For generic site search, we also built a custom API. This is a simple method that uses Examine and returns paginated results with a simple title, description and link.
A modular page
Any modular page is made from a Hero component, and a blocklist of components. The Delivery API returns this as an array of objects. I check if I have a matching component in a template, and render the matching component with the data.
Images
Images are hard. It’s not a case of dropping an <img src="some-image.jpg>
in your HTML and be done with it. You need srcset
, maybe multiple <source>
elements in a <picture>
, lazy or eager loading, and more.
I think the front-end developer should be in charge of defining the right sizes for a web page. So I want to define this in my front-end code, and get a resized version of the original image from the CMS.
Also, Umbraco has built-in functionality to determine different crops/zooms for the same image used in different locations. This should not be used for your srcset
attribute! The mentioned options in Umbraco are for using another version of the same image in different places on your website. The srcset provides different resizes of the exact same image + crop, which a browser then uses to determine which size to download for the current layout and screen resolution.
The Delivery API provides all the necessary information and tools to do this well. This is the (shortened for verbosity) API response for an image:
{
"image": [
{
"focalPoint": {
"left": 0.3467,
"top": 0.4742
},
"name": "Alt text",
"url": "https://example.com/ukdnulk1/example.jpg",
"width": 7054,
"height": 4702
}
]
}
Sadly, it isn’t well documented what to do with all this data.
From this I can construct the following HTML:
<img
src="https://example.com/ukdnulk1/example.jpg"
srcset="https://example.com/ukdnulk1/example.jpg?format=webp&width=628&quality=50&rxy=0.3467,0.4742 628w, https://example.com/ukdnulk1/example.jpg?format=webp&width=1256&quality=50&rxy=0.3467,0.4742 1256w"
width="7054"
height="4702"
alt="Alt text"
sizes="100%, (min-width: 80rem) 40rem"
>
That’s a lot of HTML for one image. Let’s break it down.
src
In the src
attribute, I place the URL to the original image. It can contain the original size (7054px x 4702px), which is too large for Web, but it isn’t downloaded anyway because the srcset
attribute is present.
srcset
I construct a query string based on the JSON and props that I define in the front-end. This is what props I pass to the <Image>
component:
<Image
{...img}
widths={[628, 1256]}
sizes="100%, (min-width: 80rem) 40rem"
lazy={false}
/>
So, I take the original URL, and append query string parameters to it:
- The format. I always want a webp format. It is widely supported and compression is good. (Did you know? JPEG is from 1992. Compression algorithms have greatly increased since then!)
- The width and optionally the height. This sizes the original image down to an appropriate size for a performant web page.
- Quality. Set to 50%: a nice balance between quality and file size.
- The focal point parameters. This is not always needed, but very helpful if an image is cropped (not resized) to different dimensions.
width & height
You need to specify width and height properties on all your images. This way a browser can reserve this space before the image is downloaded and the dimensions are known. It prevents layout shifting (CLS in CWV), which is an important metric for your page performance. It is conveniently provided by the Delivery API. It doesn’t matter how big the numbers are.
alt
This is pretty straightforward. Always add an alt-attribute to images. An important accessibility note: if your image is decorative, add an empty alt tag: alt=""
.
sizes
This is important too. The sizes attribute provides hints to the browser what size it will be displayed in on what window sizes. In the example above this hint says:
- This image is displayed at 100% width of the window
- On windows of a minimum width of 80rem, the image is displayed at 40rem
The browser then looks at what resizes of the image are provided (628px wide and 1256px wide), and downloads the most appropriate image.
Forms
We use Umbraco Forms to enable content editors to build their own form for any page. The Delivery API provides all the structure and field constraints, which I map to the right HTML structure. I have full control over the HTML, which is convenient because forms are hard to structure right to make them accessible. I wrote a simple client-side validation script for field validation and submit validation. Using the browser’s Constraint Validation API this is fairly easy and can be done in ~100 lines of code.
If JavaScript fails for some reason, the browser’s built-in form validation still does the right job of validating fields client-side.
Submitting forms
The form submission is sent to a SvelteKit action, which posts the data to the Umbraco Forms API:
const req = await fetch(`${API_URL}/umbraco/forms/api/v1/entries/${data.get('formId')}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Api-Key': FORMS_API_KEY
},
body: JSON.stringify({
values: values
})
});
The action is a server-side function, so the API keys are never exposed to the client.
The response contains error messages if there are any, which are fed back to my Form component and displayed. On success, it 303-redirects to a thank-you page.
Rich text
Most rich text editors save content as HTML into your database. Therefore, I, as a front-end developer, have no control of the output of the content. This is fine for regular text, but rich text editors are often misused for content that is not text: tables, images, buttons or even video embeds. I can’t make these things responsive, performant or accessible.
Umbraco does have rich text to Markdown support, but at the time of building, it worked too weird or broken to use.
To allow content editors to mix these types of content, we came up with a simple blocklist for content. Content editors can use:
- rich text (with image and embed options disabled), images (which I can optimize for performance)
- video’s (YouTube embeds, which I can control by cookie consent settings)
- call to actions (buttons with a link, color and icon)
This can be easily expanded to support other types of content. They also always space nicely.
Sitemap.xml
SEO Checker’s sitemap generation is useless in a headless situation, because Umbraco doesn’t really know anything about the website you’ve built upon it. Not even the domain.
I ended up building a custom route in SvelteKit which fetches all pages and generates XML upon a request to /sitemap.xml
. In 30 lines of code in /src/routes/sitemap.xml/+server.ts
The Delivery API has a way to fetch all descendants from the root node. Loop through the result, and voilà: you have a sitemap.
Automatic redirects
When moving pages around, Umbraco automatically creates redirects. The Delivery API returns this, so I can make the website respond accordingly. SvelteKit has a redirect method, which can do what Umbraco tells it to do:
import { redirect } from '@sveltejs/kit';
redirect(request.status, request.headers.get('location'));
Manual redirects
Redirects created manually with SEO Checker or SkyBrud don’t work before the Delivery API: it just returns a 404
status code. Redirects do work if you request the same page from Umbraco’s front-end. So if I get a 404, I check that, and create the redirect. Otherwise, it’s definitely a 404.
Meta fields
We added the SEO Checker Umbraco package to the project early on, mainly because it was also present on the ‘old’ website we were rebuilding. It seemed like a convenient way to handle sitemap generation, redirects and meta fields.
It didn’t.
What’s left of the functionality of the package are the meta fields for description and og-tags. But we could have easily created these ourselves.
So, I don’t recommend this for a headless environment. We would have been better off with only SkyBrud for handling redirects.
Back-end performance
A really important part of any website. Performance starts with the speed of the back-end: if the content comes out slow, the front-end will be slow too.
Fortunately the Delivery API is very fast by itself. Initially we had a CDN in between which caches the output, but it didn’t work reliably. We removed the CDN and didn’t notice any performance downgrade.
Umbraco is hosted on Azure. You don’t need a very high performance tier, because the CMS and the Content Delivery API are not a production environment: the SvelteKit environment is that.
The Content Delivery API has a nice way of caching the results for a specified period if it’s not fast enough for your needs, but we don’t use that.
I recommend discussing with your client if they really need immediate content updates. If the can live with a maximum of 15 minutes, it might already make a huge difference in the hosting costs.
Front-end performance
I chose SvelteKit because of its easy of use, but also for its performance. The framework optimises itself during build time and sends a minimal amount of code (JavaScript) to the browser.
HTML & CSS
SvelteKit also fully supports server-side rendering and progressive enhancement. This means that on every page request, basic HTML is sent to the client (browser) with a small bundle (10kb compressed) of CSS.
This always is by far the most performant and reliable way of displaying a web page. If JavaScript is slow or even fails, you still have a working website.
In SvelteKit, no configuration or extra dependencies are needed to have it render webpages and assets this way: it just works.
JavaScript
Out-of-the box, SvelteKit bundles your client-side code automatically. It also does code-splitting per route, but that doesn’t work in this case because the application doesn’t know anything about routes: they are dynamically determined by Umbraco.
As a result, all JavaScript for the site is sent to the client upon first page view. However, this is only 100 kb (compressed) in size. All JavaScript combined is about 400 kb in size. This is about the same as a clean Next.js application.
Remember: JavaScript is expensive. It needs to be downloaded, parsed and executed, which is way more heavy for performance than just HTML and CSS.
MPA or SPA
By default, SvelteKit makes your site or application a single-page application. Since IBN is ‘just’ a website, I disabled the client side router, which effectively makes it a multi-age application (MPA). So on navigation, you go to a new page, fresh HTML is downloaded, and (cached) assets are added. I think this is the most reliable way of navigating websites.
Preview mode
An often-used built-in functionality from Umbraco is the Preview Mode. It enables a content editor to see added or edited content page before publishing it.
At the time of building, there was no way to make the Preview-button do something differently that showing a preview of its own front-end. So we added a plugin that redirects you to a different domain. This points to an alternative hosting environment on Netlify with one difference: an environment variable which says PREVIEW_MODE = true
. I use that in SvelteKit to send an extra request header when I call the Delivery API, which then returns saved but not published content.
Altering the behavior the Preview button is now natively supported in Umbraco.
Multilingual and dictionary items
IBN is a single-language site, so we have not explored the possibilities or limitations of using dictionary items.
What’s cool
It’s fast
Umbraco is fast, so the API is fast, so the site is fast. Client-side code is minimal. But also, deployments are fast. A merge to the main branch kicks off a build and deploy, which finishes in under a minute.
Freedom and flexibility
I have full freedom and control over the HTML. Changes can be made very easily, and if needed, pushed to Production in less than a minute.
Accessibility
It also means I can focus on the structure and accessibility of the application. Making it accessible was a big requirement, this could be done easily and effectively.
(Scoped) CSS
In SvelteKit, CSS is scoped by default, no extra configuration needed. It also warns you and cleans up any unused CSS.
I also have some global CSS, which sets a Reset, Custom Properties and basic styles for fonts, headings, links and text which come from the CMS and cannot be scoped.
I use a little bit of Sass, but write mostly regular CSS with it.
Low costs
Hosting costs are low. We have a couple of services running in Azure: a dev database, a testing environment (app service + database) and a production environment (app service + database).
The front-end runs on Netlify. Because of it’s ease of use, configuration takes almost no time (Azure eat your heart out). Hosting costs are also extremely low (Azure, eat your heart out, again), and the performance is top-notch.
The stack
The front-end stack is fairly simple. It uses SvelteKit as a meta-framework, Supple CSS for some basic CSS utilities, Date Picker Svelte and Lite YouTube Embed.
Simple animation utilities are built-in into Svelte, so you don’t need to write much code or install a package for it.
I use the SvelteKit Netlify adapter to render webpages on Netlify.
Live data from Umbraco
As a content editor, I want my content to be published immediately to the website, so there is no delay in content updates. Every page is built from the current data in Umbraco, so there are no delays or old caches.
What’s not so cool
No code-splitting
Since Umbraco controls the routes, no code-splitting can be done by SvelteKit. This results in 1 bundle for the whole website. It’s not that big, but in my opinion too much for most pages. It’s still really fast though.
Unsafe-inline styles for CookieBot
I like to have a very strict Content Security Policy (CSP), but CookieBot requires unsafe-inline
styles to be enabled. I haven’t found a way to work around this (yet).
No AVIF
Umbraco can output WebP format by adding ?format=webp
to the URL of an image. However, there is no support for the even better AVIF format (yet).
No types, no TypeScript
I didn’t use TypeScript. I kind of expected the Delivery API to know what types it outputs, which I can use in my front-end IDE. I ended up looking at the API output itself and follow that structure in my components. The structure sometimes is weird, but not impossible to work with. No server-side conversion is made: my front-end consumes what the Delivery API outputs one-on-one.
Conclusion
I am very happy with the result. It is fast, it is accessible, it is scalable. The whole setup is easy to understand. The most important part is miss is AVIF image support. Otherwise, I would dare to say: I’ve made the perfect website.
We learned a lot, and built a lot. The headless setup makes it possible to reuse a lot of components, which would now take a fraction of the time to set up. Like Umbraco Forms integration and the client-side validation with it, but also atoms and molecules for generic layouts.
I would reuse this approach for every website I would build from now on.