Skip to content

Chaining HandleAsServerSideRequest in Umbraco - Don't break your packages

Umbracologoblue05

Chaining HandleAsServerSideRequest in Umbraco - Don't break your packages

Introduction

If you've worked with Umbraco (v10+) and needed to handle routes with file extensions — like /sitemap.xml or /robots.txt — you'll likely have come across HandleAsServerSideRequest. It's a configuration option on the UmbracoRequestOptions class that tells Umbraco's request middleware to treat certain requests as server-side rather than client-side. Without it, Umbraco sees the file extension, assumes it's a static file request, and doesn't create an UmbracoContext — meaning your controllers and content finders won't work as expected.

The typical approach, as shown in the Umbraco documentation, looks something like this:

builder.Services.Configure<UmbracoRequestOptions>(options =>
{
    options.HandleAsServerSideRequest = httpRequest =>
    {
        return httpRequest.Path.StartsWithSegments("/sitemap.xml");
    };
});

Simple enough. But what happens when you're not the only one setting this?

TLDR

If you or a package sets HandleAsServerSideRequest, you're replacing the previous value — not adding to it. Capture the existing function first and chain your logic with it to avoid silently breaking other registrations.

The Problem

I recently found myself in a situation where I needed to add a custom server-side route to a project, but the project was also using a package that had already registered its own route via HandleAsServerSideRequest.

The issue is that HandleAsServerSideRequest is a Func<HttpRequest, bool> — it's a single delegate property, not an event or a collection. Every time you assign to it, you replace whatever was there before. There's no built-in mechanism to "add" to it.

So if a package registers this:

builder.Services.Configure<UmbracoRequestOptions>(options =>
{
    options.HandleAsServerSideRequest = httpRequest =>
    {
        return httpRequest.Path.StartsWithSegments("/package-route.xml");
    };
});

And then your project registers this:

builder.Services.Configure<UmbracoRequestOptions>(options =>
{
    options.HandleAsServerSideRequest = httpRequest =>
    {
        return httpRequest.Path.StartsWithSegments("/sitemap.xml");
    };
});

The package's registration is gone. Silently. No error, no warning. Your /sitemap.xml works, but the package's /package-route.xml no longer gets an UmbracoContext and things start breaking in ways that can be very confusing to debug.

Why This Happens

Under the hood, this is standard .NET IOptions<T> behaviour. When you call Services.Configure<UmbracoRequestOptions>, you're registering an IConfigureOptions<UmbracoRequestOptions> action. At runtime, all registered configure actions run sequentially in the order they were registered.

The key thing to understand is that each action receives the same options instance, already mutated by previous actions. So if action A sets HandleAsServerSideRequest to function A, and then action B sets it to function B — function A is simply overwritten. It's a property assignment, not an event subscription.

This is perfectly normal for most options — if two things set MaxRequestBodySize, you'd expect the last one to win. But HandleAsServerSideRequest is a predicate function where you'd ideally want all registrations to be considered.

The Solution

The fix is to capture the existing function before you replace it, and then call it as part of your new function. This creates a chain, similar to how middleware works:

builder.Services.Configure<UmbracoRequestOptions>(options =>
{
    var next = options.HandleAsServerSideRequest; // Store the current function
    options.HandleAsServerSideRequest = httpRequest =>
    {
        // If our check returns true, return immediately.
        // Otherwise, call the previous function in the chain.
        return httpRequest.Path.StartsWithSegments("/sitemap.xml") || next(httpRequest);
    };
});

What's happening here:

  1. We grab a reference to whatever HandleAsServerSideRequest is currently set to (this could be the default, or something a package has already registered).
  2. We assign a new function that first checks our condition.
  3. If our condition is true, we short-circuit and return true immediately.
  4. If our condition is false, we delegate to the previous function via next(httpRequest).

This means every registration preserves the ones that came before it. It doesn't matter how many things need to register routes — as long as they all follow this pattern, everything works.

A note on the Default Value

One small gotcha I noticed — the intellisense XML comments on HandleAsServerSideRequest state that the default return value is true. This is incorrect. If you look at the Umbraco source code, the default is actually false. This makes sense — by default, Umbraco doesn't treat anything extra as a server-side request unless you tell it to.

This is worth being aware of because if you're chaining and the very first function in the chain is the default, it will return false for everything — which is the correct behaviour, it just means "nothing else matched".

Advice

If you're a package author — please use this chaining approach. If you directly assign to HandleAsServerSideRequest without capturing the previous value, you risk breaking any other package or project-level registration that was set up before yours.

If you're a site developer — you should also use this approach, because you can't always guarantee what order your code and your packages' code will run in. Chaining is defensive and safe.

I've posted this solution on the Umbraco community forum as well for reference.

Thanks for reading.