Tutorials
Short, standalone tutorials covering specific problems we've hit on the Umbraco Community site and the approaches we took to solve them.
Each tutorial is self-contained: it states the concrete problem, explains the trade-offs of our solution, and walks through the implementation file by file. They're written to be picked up on a different Umbraco project (or no Umbraco project at all) — but they reference real code in this repo, so anyone working on the site can jump straight to the source if they prefer to read code over prose.
These sit alongside the other docs in this folder rather than replacing them:
- Primers orient you to a whole area of the codebase (e.g. the frontend) with links out to the deeper docs for each topic. Start here if you're new.
- How-to guides like
BUILDING_PAGES.mdandBUILDING_BLOCKS.mdtell you the conventions for adding new things to this codebase. - Operational notes like
LESSONS_LEARNED.mdcover workflow gotchas (Umbraco upgrades, cloud deploys, schema management). - Tutorials (this folder) explain why a particular piece of the codebase is shaped the way it is, and how to build something similar from scratch.
How to read these
Tutorials are split into two kinds:
foundations/— pieces of code that other tutorials build on. Read these first if a refinement says it's a prerequisite.refinements/— extensions, bug fixes, or improvements layered onto a foundation. Each refinement names the foundation it depends on at the top.
You don't need to read the suite in order. Each tutorial points at the next logical stop in its closing section, so you can either follow a thread the whole way through, or just jump straight to whichever problem happens to match the one in front of you.
What's here
Foundations
- Building an inline SVG TagHelper for Umbraco — A custom
<svg-src>TagHelper that reads SVG files from Umbraco media and inlines them into the page, so they can be styled and animated with CSS. Adapted from Warren Buckley's Our.Umbraco.TagHelpers. - Resolving content in a multi-tenant Umbraco site — One Umbraco instance, multiple tenant root content nodes. Walks through the
Root()+GetSiteSettings()pattern that keeps every content lookup scoped to the current request's tenant, the document-tree shape it assumes, and the small set of helpers that consumers (menu, footer, SEO, sitemap) lean on. - Wiring Vite's manifest into Umbraco's Razor pipeline — A pair of TagHelpers (
<script vite-src>/<link vite-href>) that point at the Vite dev server on:5123for HMR in development and at the content-hashed files named inmanifest.jsonin production. Covers the entry-name convention, dev-vs-prod CSS handling, andIFileProvider.Watch-based cache invalidation that survives a deploy without an app restart. - Turning a form that renders asynchronously into a multi-step flow — Enhancing DOM a third-party widget renders on its own schedule. The
<dc-form-steps>element waits for an Umbraco Forms form (async-rendered inside<umb-forms-render>) to appear, turns it into a multi-step form once it does, and disconnects the observer cleanly. The pattern transfers anywhere your enhancement might run before the thing it enhances exists. - How to create and use a master block list that you can filter per tenant — The content-modelling decision behind Block Restrictions: keep a single Block Grid/List data type holding every block, point all document types at it, and narrow the menu per document type with a rule — instead of a near-duplicate data type per document type (and per tenant). Frames the data-type-sprawl problem, the master-plus-rule alternative, and links to the resolution and editor-enforcement tutorials that implement it.
- How to make configuration cascade down a content tree, with document type overrides — The resolution engine behind Block Restrictions, framed generally: attach a rule to a document type, resolve it for any node by walking content ancestors (not the type hierarchy), let the closest rule win, and fail open when nothing matches. Covers the two-tier cache and the generation-counter trick for invalidating an unknowable set of cached reads on a single write. Transfers to permissions, feature flags, and theming that cascade down the tree.
- GitHub OAuth login for Umbraco members — Wiring GitHub as the sole external login provider for Umbraco members via the
AspNet.Security.OAuth.GitHubpackage: theAddOAuthvs.AddRemoteSchemegotcha, backfilling a verified private email from GitHub's/user/emailsendpoint, auto-linking/auto-approval, and clearing every auth cookie on sign-out. - How to keep inline scripts and styles working under a strict Content Security Policy — The
NonceTagHelper+Joonasw.AspNetCore.SecurityHeadersintegration that lets inline<script>/<style>tags survive a strict CSP. Covers the per-request scoped nonce service, the domain allow-list plumbing inConstants.Security, the content-driven (currently inert)DisableCspMiddlewareescape hatch, and a real "forgot the nonce on a duplicated tag" bug from this repo's history. - How to add multi-tenant site search using Umbraco's built-in Examine index —
SearchServicequerying Umbraco's zero-configExternalIndexviaManagedQuery, tenant-scoping results in code viacurrentPage.Root(), merging in two unrelated indexes (community blogs and documentation), and the pagination/canonical-URL interaction bug it shares with the SEO primer. - How to secure a custom backoffice Management API endpoint —
BlockRestrictionApiController's base-controller-carries-everything pattern:[BackOfficeRoute], Umbraco's built-inSectionAccessContentpolicy, the v18 Swagger-to-OpenAPI migration, and the typed fetch wrapper that calls it. Cross-references three sibling packages for contrast. - Building a touch-friendly drag-to-scroll slider in vanilla web components — The
<dc-slider>custom element: touch drag that follows the finger, snap-on-release computed from drag distance (no CSSscroll-snapinvolved), hover-zone vs. explicit-arrow-button navigation via aclosest()lookup, and a sibling progress indicator coupled only by a document-levelCustomEvent.
Refinements
- Scoping inline SVG
<style>to prevent class-name bleed (builds on the inline SVG TagHelper) — Illustrator-exported SVGs ship<style>blocks with generic class names (.st0,.st1, …) that are document-scoped, not SVG-scoped. Two such SVGs on one page fight over the same class names. Fix: have the TagHelper add a deterministic class per SVG file and prefix every internal selector with it. - Caching the scoped SVG output (builds on the scoping refinement) — Once the scoped SVG markup is deterministic per media path, every render of the same SVG is byte-identical. Wrap the read + sanitise + parse + scope work in Umbraco's
RuntimeCachekeyed by media path; serve the cached blob directly on the hot path. Skips the cloud media round-trip entirely after warm-up. - How to serve a different 404 page per tenant (builds on multi-tenant content resolution) — When the request 404'd there's no current page to anchor tenant lookups off. Resolve the tenant root from the domain binding instead (
request.Domain?.ContentId), walk descendants for aPageNotFoundcontent type, and return it for theUmbraco.Community.NotFoundTrackerpackage to serve with HTTP status 404. - How to keep SEO schema valid when a tenant hasn't configured their brand settings (builds on multi-tenant content resolution) — Every page emits
Organizationschema, but editors forget to fill in tenant brand fields. A smallOrganizationSchemaBuilderaccepts a nullableSocialSettingsand falls back to hardcoded constants so unconfigured tenants still produce valid schema. - Output cache policies for slow upstream APIs — Two independent caching layers protect the Sessionize integration: a short, purely time-based
[OutputCache]policy at the HTTP boundary, and a separateIMemoryCache+ disk-backed stale fallback one layer further in, inside the client that actually talks to Sessionize. Neither layer knows the other exists — deliberately. - Syncing custom backoffice configuration across environments (builds on the content-tree-inherited-config foundation) — Block-restriction rules live in a custom database table, so they don't ride Umbraco's deploy pipeline and aren't version-controlled. The fix: mirror every save to a git-committed JSON file (DB → disk, automatic and one-way), pull back via a reviewed diff on a dashboard (disk → DB, manual — because import deletes orphaned rules), and a zip path for Cloud where you can't touch the filesystem. The transferable lesson for any package that owns config: automatic out, deliberate in.
- Wrapping Umbraco's native block editor with restriction filtering (builds on the master-block-list foundation and content-tree-inherited-config) — A custom property editor UI that reuses the native Block Grid/List schema but wraps the native element, filtering its allowed-block list down to a tree-resolved restriction. Covers Light-DOM context propagation, the recreate-on-restriction pattern when the rule arrives late, the Grid-vs-List filtering asymmetry, and the clipboard value translators that copy/paste needs to survive the wrapping. Compares the wrap approach against the server-side modal-replacement approach of Kraftvaerk.Umbraco.BlockFilter.
Planned
Two foundations are still stubs, sitting at the path the finished tutorial will live at: foundations/intersection-observer-paused-animation.md and foundations/postcss-mixin-for-design-tokens.md. Each has a > **Status:** Planned callout at the top and a "what this will cover" sketch — read one if you're weighing whether to pick it up.
Contributing a new tutorial
When adding a new tutorial:
- Check
foundations/andrefinements/first. If a stub already exists for your topic (look for a> **Status:** Plannedcallout at the top of the file), expand it in place rather than creating a duplicate. If no stub exists yet, that's fine; just pick the right folder for it (foundations stand alone; refinements depend on an earlier piece of code) and create a new file with a kebab-case filename that describes the technique rather than the bug. - Follow the section structure used by the existing tutorials: a one-paragraph framing, then The problem → Why the obvious fix doesn't work → Our approach → Walkthrough → Alternatives we considered → Trade-offs and known limits. For foundation pieces, swap "The problem" for Why you might want this and "Why the obvious fix doesn't work" for What we're building.
- Link to real files in this repo using paths relative to the tutorial file (e.g.
../../../src/UmbracoCommunity.Web/TagHelpers/SvgTagHelper.cs). - Credit prior art. If the code is adapted from a community project, lead with a "Credit where it's due" section linking the source and naming contributors.
- Move the tutorial out of "Planned" and into the relevant section above: add it to What's here, and remove its bullet from Planned — all in the same commit.
You don't need to maintain a contributors list by hand. Each rendered doc shows a Contributors section generated from git history (docs/contributors.generated.json, produced by npm run generate:doc-contributors and refreshed in CI). Open a PR and you'll be credited automatically — with your GitHub avatar where your commit email is linked to your account.
Foundations
-
How to secure a custom backoffice Management API endpoint
Community content on the new Umbraco backoffice almost exclusively covers the UI side — property editors, dashboards, workspace views. The C# endpoints those UIs actually call are consistently under-documented. This tutorial is the other half: how BlockRestrictionApiController — and its three near-identical siblings elsewhere in this repo — routes under the backoffice, locks itself down to users with the right section access, registers itself in the API docs, and gets called from a typed client. It's a foundation piece, and a direct sequel to the backoffice extensions primer, which sketches the client side in a few paragraphs and explicitly defers the backend half to here.
-
How to make configuration cascade down a content tree, with document type overrides
A common need in a CMS: attach a piece of configuration to something high up, and have everything below it pick the value up automatically unless it's overridden closer to home. Block Restrictions does it with allowed-block rules — set a rule once, and every page beneath inherits it — but the shape is general. This tutorial is the resolution engine behind that: how to attach a rule, resolve it for any node by walking up the tree, cache the answer so you're not re-walking on every request, and fail open when nothing is configured. It's the "how rules resolve" half of the Block Restrictions trio; the editor-wrapping refinement is what consumes the answer.
-
Building a touch-friendly drag-to-scroll slider in vanilla web components
Carousel libraries like Swiper and Embla are excellent, and most of what they give you is overkill for a block that shows a handful of cards or slides and needs prev/next navigation. This tutorial walks through <dc-slider> — a small custom element in this repo that handles touch drag (following the finger, snapping to the nearest slide on release), desktop hover-zone navigation, and an explicit-arrow-button opt-in, all without a dependency. It's a foundation piece, reused as-is by two different content blocks with zero component-side changes between them.
-
GitHub OAuth login for Umbraco members
Community members on this site don't have passwords. There's no "forgot password" flow to build, no email-verification loop to maintain, and no credential store to worry about leaking — sign-in is GitHub or nothing. For a site built for developers, most of whom already have a GitHub account they use daily, that's not a compromise; it's the obvious identity provider. This tutorial walks through how that's wired into Umbraco's member system, and the handful of sharp edges that bit along the way. It's a foundation piece — nothing else in this suite builds on it yet, but it's the base the account page and the wider community-profile feature sit on top of.
-
Building an inline SVG TagHelper for Umbraco
SVGs are wonderful for icons and brand marks — small, sharp, infinitely scalable — but only if you can actually style them from CSS, and that's where things tend to get fiddly. This tutorial walks through the <svg-src> TagHelper that the Umbraco Community site uses to inline SVG files from Umbraco media straight into the rendered Razor view, where CSS and JavaScript can then reach inside them. It's a foundation piece — most of the SVG-related tutorials in this suite build on top of it.
-
How to pause an animation when it's off-screen, the tab is hidden, or the user prefers less motion
The <dc-image-slider> component auto-scrolls a row of images — but you don't want it to keep burning a CPU core when the user can't see it, or when they've asked the OS to dial down motion. This tutorial will walk through the small composition that makes the animation cheap and polite: requestAnimationFrame for the loop itself, IntersectionObserver to pause when the slider scrolls off-screen, visibilitychange for tab switches, and prefers-reduced-motion for accessibility. The general lesson is how to animate something well without reaching for a library.
-
Resolving content in a multi-tenant Umbraco site
Running multiple sites from a single Umbraco instance is one of those things that looks easy until you've shipped one — and then suddenly every content lookup needs to know which tenant it belongs to. This tutorial walks through the pattern the Umbraco Community site uses for multi-tenancy (several distinct sites out of one Umbraco instance) and the small set of helpers that keep every content lookup scoped to this request's tenant rather than wandering off into another one's tree.
-
Turning a form that renders asynchronously into a multi-step flow
Progressive enhancement has a tidy mental model: the server sends working HTML, and your JavaScript layers extra behaviour on top once it loads. That model quietly assumes the HTML you want to enhance is there when your code runs. But what happens when it isn't yet — when the very thing you mean to enhance is rendered, asynchronously, by some other component that owns its own timing? Your enhancement runs, finds nothing to do, and gives up. This tutorial walks through the small MutationObserver pattern the Umbraco Community site uses to solve exactly that: the <dc-form-steps> element waits for an Umbraco Forms form to finish rendering, turns it into a multi-step form once it appears, and then disconnects cleanly.
-
How to keep inline scripts and styles working under a strict Content Security Policy
A strict Content Security Policy that bans inline scripts catches most XSS vectors at the browser level — the browser simply refuses to run a <script> tag that isn't on an approved list. The trouble is that a real site has inline scripts and styles: build-tool bootstrap snippets, per-instance background colours an editor picked in the backoffice, structured-data JSON-LD. Ban all of them and half the site breaks; allow 'unsafe-inline' and you've defeated the point of having a CSP at all. The standard middle ground is a nonce — a random token generated once per request, stamped onto both the CSP header and every inline tag you actually trust, so the browser runs only the ones whose token matches. This is a foundation piece: nothing else in this suite builds on it, but it underpins every inline <script> and <style> in the codebase.
-
How to create and use a master block list that you can filter per tenant
This is the content-modelling decision that the whole Block Restrictions package exists to serve, written down on its own because it's the why behind two other tutorials and it's easy to lose under their mechanics. The short version: keep a single Block Grid (or Block List) data type that holds every block the site could ever use, point every document type at that one data type, and then narrow the offered set per consumer — per document type, and by inheritance the content nodes beneath it — with a restriction rule, rather than building a separate data type for every document type that wants a different subset of blocks.
-
How to generate spacing utility classes from your own design tokens
Utility-first CSS frameworks like Tailwind give you the ergonomics of .pt-md and .mx-xs — but they want to own your design system. The rhythm mixin in this repo flips that arrangement: you write your spacing tokens in root.css as CSS custom properties, hand them to a small postcss-mixins rule, and you get the same utility classes generated for you — driven entirely by your own tokens, with no opinion from a framework. This tutorial will walk through writing the mixin, the modifier suffixes it emits, and how to layer it on an existing PostCSS pipeline.
-
How to add multi-tenant site search using Umbraco's built-in Examine index
"How do I add search to my Umbraco site?" is a perennial community question, and most answers stop at the single-tenant happy path: query ExternalIndex, render the hits, done. This tutorial walks through what that answer actually looks like once you add a second tenant, a second content source, and a page full of results that need to paginate cleanly — the site search on the Umbraco Community site, built entirely on Umbraco's own zero-config Examine index. It's a foundation piece: nothing else in this suite builds on it, but it's a self-contained answer to a question that comes up on every multi-page Umbraco site sooner or later.
-
Wiring Vite's manifest into Umbraco's Razor pipeline
Vite bundles your frontend and Umbraco renders your views, and sitting between the two is a single <script> tag that needs to behave completely differently depending on where it's running. Locally, you want the browser talking to the Vite dev server so you get hot module replacement (HMR — your edits to JS and CSS show up in the browser without a full page reload). In production, you want it loading a content-hashed file whose name changes with every build. Same tag, two different use cases. This tutorial walks through the pair of TagHelpers the Umbraco Community site uses to bridge them — <script vite-src> and <link vite-href> — so that you can write one stable line of Razor and let the C# work out, per environment, whether to point at localhost:5123 for HMR or at the hashed asset named in Vite's manifest.json.
Refinements
-
Caching the scoped SVG output
Once the SVG TagHelper produces deterministic scoped output, every render of the same media item is byte-identical — which is a rather lovely property to have, because it makes the whole pipeline cacheable. We can do the read + sanitise + parse + selector-prefix work once and then happily reuse the result for an hour. On cloud-hosted media in particular this is the difference between every page render making N media-storage round-trips and making zero of them, which is a meaningful difference in latency for the end user.
-
Output cache policies for slow upstream APIs
When a page depends on a third-party API you don't control — Sessionize, in this case — the API's speed and availability become your page's speed and availability, unless you put something in between. This refinement doesn't build on another tutorial in this suite; it's a self-contained look at how the Sessionize integration protects itself with two independent caching layers, not one, and why a single [OutputCache] attribute — however tempting — isn't the whole story.
-
How to serve a different 404 page per tenant
Multi-tenant Umbraco sites need multi-tenant 404 pages — it stands to reason that if a request lands on Tenant A's domain and ends up at a missing URL, the user shouldn't suddenly be looking at Tenant B's header and footer just because we couldn't find the page they asked for. This tutorial walks through the small resolver that makes that work, and the subtlety that forces it to resolve the tenant from the domain binding rather than from the (non-existent) current page.
-
Scoping inline SVG <style> to prevent class-name bleed
There's a surprisingly satisfying class of "the logo went the wrong colour" bugs that all trace back to the same root cause: inline <style> blocks inside SVGs aren't actually scoped to the SVG they live in, even though every bit of your experience tells you they ought to be. This tutorial walks through how we ran into the problem on the Umbraco Community site, why the obvious fixes don't quite hold up, and how we ended up solving it with a small change to a single TagHelper.
-
Syncing custom backoffice configuration across environments
You've built a feature whose configuration lives in your own table — for us, block-restriction rules keyed by document type. It works beautifully on your machine. Then someone asks the obvious question: a rule you set up locally needs to be on staging and production too, and ideally it should be reviewable in a pull request like the rest of the codebase. Suddenly "it's in the database" is a problem, not a feature. This tutorial is how Block Restrictions makes its configuration travel between environments and live in git.
-
How to keep SEO schema valid when a tenant hasn't configured their brand settings
Structured data — the small blocks of Schema.org JSON-LD that crawlers like Google and Bing look for in your page head — needs a publisher: an Organization with a name, a URL, and a logo, that they can attribute the content to. On a multi-tenant Umbraco site, that publisher is naturally per tenant — Site A's publisher is the Umbraco Community brand, Site B's is the events microsite, and so on. The challenge is that tenant brand metadata is editor-configurable, which means (let us be honest with each other here) that it's also editor-forgettable. This tutorial walks through the small pattern that produces valid Organization schema whether the tenant's brand fields are filled in, partially filled in, or entirely absent.
-
Wrapping Umbraco's native block editor with restriction filtering
The Block List and Block Grid editors are two of the most useful things Umbraco ships, and by default they offer every block a data type is configured with to every editor, on every node. We wanted to narrow that list per document type (with inheritance down the content tree) — a "Blog Post" should only offer a handful of blocks, a "Landing Page" the full set. This tutorial is about the backoffice side of that: how to enforce the restriction in the editing UI without reimplementing the block editor, by wrapping the native one instead of replacing it. The sting in the tail is copy-and-paste, which breaks in a genuinely puzzling way once you wrap, and needs a little boilerplate to put right.