---
title: "The ultimate Umbraco site structure, revisited"
description: "How I use the Ultimate Site Structure, with a custom URL provider and content finder that keep the frontpage separate from the site root."
date: 2026-09-03
tags: ["umbraco", "csharp", "contentfinder", "architecture"]
---

At the Umbraco meetup in Odense on September 2, 2026, [Kaspar Boel Kjeldsen](https://kjeldsen.dev/) included the Ultimate Site Structure in his talk, [The 10 Umbracommandments](https://www.meetup.com/umbracodkmeetup/events/315773679/). It was good to hear somebody recommend a pattern that I have used since Umbraco 7.

The original article, [The "ultimate" site structure setup](https://cultiv.nl/blog/tip-of-the-week-the-ultimate-site-structure-setup/), was written by Sebastiaan Janssen in December 2010. It is more than 15 years old and starts with a warning that it is probably outdated. The implementation certainly is. The structure itself still holds up.

I use it on every Umbraco project I work on, without exception.

## What the Ultimate Site Structure looks like

The content tree has a site node at the root. The frontpage and all other top-level pages are children of that node.

```text
Content
└── Site
    ├── Home
    ├── Products
    ├── About
    └── Contact
```

The site node owns things that apply to the whole site:

- Hostnames and cultures
- Navigation and footer settings
- Other global settings
- The selected frontpage
- Rules for which document types editors can create below it

The site node does not need to render its own content. Its job is to represent the site.

## The frontpage is not the site

A common Umbraco content tree puts the frontpage at the root and every other page below it. I do not think that hierarchy describes the content correctly. The contact page is part of the site, but it is not a child of the frontpage.

Keeping the site and frontpage separate also removes an awkward dependency. If the frontpage needs a different document type or a complete replacement, I can create a new page and select it as the frontpage. I do not have to move every other page below a new root node.

The hostname stays on the site node. So do cultures and global settings. They do not move just because the frontpage changes.

This separation is also useful for site-specific features. My approach to [editor-friendly 404 pages in Umbraco](/blog/editor-friendly-404-page-in-umbraco) stores the selected 404 page on the same site node.

## The missing routing pieces

Sebastiaan's original solution used a Content Picker with the alias `umbracoInternalRedirectId` on the site node. Umbraco then renders the selected frontpage when somebody requests the site root.

The remaining problem is outbound routing. When Umbraco generates a link to the Home node, its default URL is still `/home`. Hardcoding `/` in navigation only hides part of the problem. An editor can still select Home in the rich text editor or a link picker and get `/home`.

The complete solution needs to work in both directions:

- A URL provider must return the site URL when Umbraco asks for the frontpage URL.
- A content finder must resolve the site URL to the selected frontpage.

Umbraco's [routing documentation](https://docs.umbraco.com/umbraco-cms/reference/routing/request-pipeline) explains this as the outbound and inbound request pipelines.

> **Note:** `umbracoInternalRedirectId` is one of Umbraco's special property aliases. Add it to a document type with a Content Picker, and Umbraco renders the picked page without changing the URL in the browser. The [special routing property aliases](https://docs.umbraco.com/umbraco-cms/reference/routing/routing-properties) also include `umbracoRedirect`, `umbracoUrlName`, and `umbracoUrlAlias`.

## Returning the site URL for the frontpage

The URL provider only handles content selected as a frontpage. For every other content item, it returns `null` and lets Umbraco continue through the provider collection.

```csharp
public class FrontpageUrlProvider : DefaultUrlProvider
{
    private readonly FrontpageUrlHelper _frontpageUrlHelper;

    // The Umbraco 18 DefaultUrlProvider dependencies are omitted here.
    public override UrlInfo? GetUrl(
        IPublishedContent content,
        UrlMode mode,
        string? culture,
        Uri current)
    {
        // A frontpage uses the URL of the site node that owns it.
        var site = _frontpageUrlHelper.GetSiteOfFrontpage(
            content.Key,
            culture);

        // Returning null lets the next URL provider handle normal pages.
        return site is null
            ? null
            : base.GetUrl(site, mode, culture, current);
    }
}
```

`FrontpageUrlHelper` is not an Umbraco class. It is a small project class I wrote to keep the routing rules in one place. Given a frontpage key and culture, it finds the site node that owns the frontpage. Given a site key and culture, it returns the selected frontpage or the fallback page. The URL provider uses the first operation. The content finder uses the second. I show the relevant parts of the helper further down.

Delegating to `DefaultUrlProvider` is important. Umbraco already knows how to choose between relative and absolute URLs and how to handle schemes, hostnames, and cultures. My provider only swaps the content item from the frontpage to its site node.

The official [outbound request pipeline documentation](https://docs.umbraco.com/umbraco-cms/reference/routing/request-pipeline/outbound-pipeline) covers provider ordering and the full constructor required when inheriting from `DefaultUrlProvider`.

## Resolving the site URL to the frontpage

The content finder handles the other direction. When a request maps to a site node, it replaces the published content with that site's frontpage.

The finder has `FrontpageUrlHelper`, `IUmbracoContextAccessor`, and `IDocumentUrlService` injected. The shortened method below shows where each one is used.

```csharp
public Task<bool> TryFindContent(IPublishedRequestBuilder request)
{
    IPublishedContent? frontpage = null;
    var path = request.Uri.AbsolutePath;

    if (request.HasDomain() && request.Domain!.Uri.AbsolutePath.Length > 1)
    {
        // Remove a domain path such as /dk before resolving the route.
        path = path[request.Domain.Uri.AbsolutePath.Length..];
    }

    if (path.InvariantEquals("/") && request.HasDomain())
    {
        // The matched domain already points to the site node.
        var site = _umbracoContextAccessor
            .GetRequiredUmbracoContext()
            .Content?
            .GetById(request.Domain!.ContentId);

        if (site is not null)
        {
            frontpage = _frontpageUrlHelper.GetFrontpageOfSite(
                site.Key,
                request.Culture);
        }
    }
    else
    {
        // Resolve the remaining path when the domain did not identify the site.
        var siteKey = _documentUrlService.GetDocumentKeyByRoute(
            path.EnsureStartsWith("/"),
            request.Domain?.Culture,
            request.Domain?.ContentId,
            false);

        if (siteKey.HasValue)
        {
            frontpage = _frontpageUrlHelper.GetFrontpageOfSite(
                siteKey.Value,
                request.Culture);
        }
    }

    if (frontpage is null)
    {
        return Task.FromResult(false);
    }

    // Returning true stops the remaining content finders.
    request.SetPublishedContent(frontpage);
    return Task.FromResult(true);
}
```

The first branch handles a request to `/` when Umbraco has matched a domain directly to the site node. In that case, the domain already gives us the site node ID.

The second branch handles requests where the path still needs to be resolved. This includes a site reached without a hostname and a domain with a path such as `example.com/dk`. For the latter, the code removes `/dk` from the request path first. An empty remainder becomes `/` through `EnsureStartsWith("/")`. `IDocumentUrlService.GetDocumentKeyByRoute()` can then resolve the route to a document key. `GetFrontpageOfSite()` only returns content when that key belongs to a site node.

Umbraco runs content finders in collection order until one returns `true`. The [IContentFinder documentation](https://docs.umbraco.com/umbraco-cms/reference/routing/request-pipeline/icontentfinder) explains the interface and registration options.

## Falling back to the first page

The content finder improves on the original setup in one small but useful way. If an editor has not selected a frontpage, it uses the first child with a template.

```csharp
private IPublishedContent? GetFrontpage(
    IPublishedContent site,
    string? culture)
{
    var frontpageUdi = site.Value<GuidUdi?>(
                           "umbracoInternalRedirectId",
                           culture,
                           fallback: Fallback.ToLanguage)
                       ?? site.Value<GuidUdi?>(
                           "umbracoInternalRedirectId",
                           culture,
                           fallback: Fallback.ToDefaultLanguage);

    if (frontpageUdi is not null)
    {
        // Use the page selected on the site node.
        return _umbracoContextAccessor
            .GetRequiredUmbracoContext()
            .Content
            .GetById(frontpageUdi.Guid);
    }

    // A site without a selection uses its first renderable child.
    return site.FirstChild(child => child.TemplateId is not null);
}
```

The picker lookup first uses the requested culture, then its fallback language, and finally the default language. If none of them has a value, the first renderable child becomes the frontpage.

This means the site keeps working before somebody configures the picker. It also makes `umbracoInternalRedirectId` optional if the content finder handles all inbound requests in the project.

## Do not traverse the tree for every URL

The first versions of this provider caused performance problems that were difficult to explain. Profiling eventually led back to frontpage URL generation.

A URL provider can run many times during one request. A navigation, link collection, or sitemap may ask Umbraco to generate a URL for every item it contains. The early implementation searched through a large part of the content tree each time it had to decide whether an item was a frontpage.

The fix was to build the mapping once and cache it per culture.

```csharp
private sealed record CachedFrontpages(
    Dictionary<Guid, Guid> SiteKeyByFrontpageKey);

private CachedFrontpages GetAllFrontpages(string? culture)
{
    // Each culture can select a different frontpage.
    var cacheKey = $"Skttl.FrontpageUrl.FrontpageSettings__{culture}";

    // Build the mapping only when this culture is not cached yet.
    return _runtimeCache.Get(
               cacheKey,
               () => BuildFrontpageMappings(culture))
           as CachedFrontpages
           ?? new CachedFrontpages([]);
}

private CachedFrontpages BuildFrontpageMappings(string? culture)
{
    var content = _umbracoContextAccessor
        .GetRequiredUmbracoContext()
        .Content;
    var siteKeyByFrontpageKey = new Dictionary<Guid, Guid>();

    // This simplified example assumes every site is a root node of type site.
    var sites = content.GetAtRoot()
        .Where(node => node.ContentType.Alias.InvariantEquals("site"));

    foreach (var site in sites)
    {
        if (GetFrontpage(site, culture) is { } frontpage)
        {
            // The URL provider needs to look up the site from the frontpage.
            siteKeyByFrontpageKey[frontpage.Key] = site.Key;
        }
    }

    return new CachedFrontpages(siteKeyByFrontpageKey);
}

public IPublishedContent? GetSiteOfFrontpage(
    Guid frontpageKey,
    string? culture)
{
    var frontpages = GetAllFrontpages(culture);

    return frontpages.SiteKeyByFrontpageKey.TryGetValue(
        frontpageKey,
        out var siteKey)
            ? _umbracoContextAccessor
                .GetRequiredUmbracoContext()
                .Content
                .GetById(siteKey)
            : null;
}
```

This shortened version assumes that every site uses the `site` document type and lives at the content root. If a project allows nested site nodes or several site document types, I use `IDocumentNavigationQueryService` to collect those keys instead. Either way, building the cache reads each site's frontpage once. Normal URL generation then uses a dictionary lookup instead of repeating that work for every link.

I no longer have useful before-and-after numbers from the original issue. It happened many years ago. The important lesson is in Umbraco's own guidance for custom URL providers: cache the work used to decide which provider owns a URL.

## Invalidate the cache when the site changes

A cache of frontpage mappings is only correct until an editor selects another frontpage. The implementation clears it whenever a configured site node is published.

```csharp
public class ClearFrontpageCache(
    AppCaches appCaches)
    : INotificationHandler<ContentPublishedNotification>
{
    public void Handle(ContentPublishedNotification notification)
    {
        var siteWasPublished = notification.PublishedEntities.Any(
            content => content.ContentType.Alias.InvariantEquals("site"));

        if (siteWasPublished)
        {
            // ClearByKey removes the cached entry for every culture suffix.
            appCaches.RuntimeCache.ClearByKey(
                "Skttl.FrontpageUrl.FrontpageSettings");
        }
    }
}
```

The individual cache keys end with a culture, but `ClearByKey` clears every key that starts with the supplied value. Clearing the base key therefore invalidates all culture-specific frontpage mappings.

## Gotchas

There are a few assumptions behind this implementation.

**One frontpage belongs to one site.** Umbraco's Content Picker allows two site nodes to select the same content item. The cache maps one frontpage key to one site key, so the last mapping would replace the first. I have never needed to share one frontpage between sites, and the implementation deliberately does not support it.

**The picker is not restricted to the current site.** I use a standard Content Picker and keep the setup simple. The helper does not verify that the selected page is a descendant of the site node. A stricter project could add that validation or configure a more constrained picker. I have not needed the extra guard so far.

**The cache must vary by culture.** A variant site can select different frontpages for different languages. A single global mapping would return the wrong site or frontpage for at least one culture.

**Domains can contain paths.** A culture or site may start at `/dk` rather than `/`. Remove the configured domain path from the request path, then add a leading slash before passing the remainder to `GetDocumentKeyByRoute()`.

**Collection order matters.** Both URL providers and content finders stop when one handles the item or request. Register the frontpage implementations early enough to run before Umbraco's default handling.

## How to wire it together

The main registrations fit in a composer. The exact `DefaultUrlProvider` constructor changes between Umbraco versions, which is why the earlier example focused on the overridden method.

```csharp
public class FrontpageUrlComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.Services.AddSingleton<FrontpageUrlHelper>();

        // Insert both routing components before Umbraco's default handling.
        builder.UrlProviders().Insert<FrontpageUrlProvider>();
        builder.ContentFinders().Insert<FrontpageContentFinder>();
        builder.AddNotificationHandler<
            ContentPublishedNotification,
            ClearFrontpageCache>();
    }
}
```

The helper examples use `umbracoInternalRedirectId` as the frontpage property and `site` as the site document type alias. Replace those strings in the helper and cache notification if your project uses different aliases. You can use a normal property alias such as `frontpage` when the custom content finder owns inbound routing.

For multi-site solutions, put the hostname and culture on each site node. If the project has several site document types, check each alias when building and invalidating the cache. Umbraco's [multisite setup guide](https://docs.umbraco.com/umbraco-cms/tutorials/multisite-setup) covers domain mapping in the backoffice.

## Final thoughts

The Ultimate Site Structure has lasted because it models the site more accurately. The site node is the stable boundary for domains, cultures, settings, and routing. The frontpage is a page that can change without reorganizing the rest of the content tree.

The 2010 solution needed a modern routing implementation, but it did not need a new structure. A small URL provider, a content finder, and a carefully invalidated cache are enough to keep the pattern useful in current Umbraco projects.