Skip to main content

Why this exists

I wanted to know if AI could build a full working website end to end without me writing any of the code. A real one, in production, with a CMS, a contact form, calendar exports, analytics, and the kind of SEO and security posture you'd ship for a paying client.

The Umbraco Kent Meetup gave me a reason to try. We needed a site, I help run the group, and nobody was waiting on a deadline if a sprint went sideways.

Two constraints shaped the whole thing. My Astro knowledge was almost nothing, a couple of tutorials and that was it, so if Claude got it wrong I couldn't quietly fix it myself and pretend that bit was always like that. And the hosting bill had to be small enough that whatever ended up in the bicep template could host a couple more little sites alongside it.

Claude Code did the work. I drove, reviewed the diffs, pushed back when something looked wrong, and shipped.

What it had to do

I picked a high bar on purpose. AI gets sharper when you ask for something specific and lazier when you don't. So the brief was: A+ on securityheaders.com, every Umbraco Health Check green, Lighthouse 100 across all four categories, a contact form going through the CMS, Google Analytics that only fires after the user accepts cookies, images served from Umbraco with on-the-fly resizing, and a publish in the backoffice that triggers a rebuild on its own.

Anything that dragged a score below target got treated as a bug. That shaped more decisions than I expected.

The architecture

Umbraco 17 running headless on a Linux Azure App Service at cms.umbracokentmeetup.org.uk. Astro compiling a static front end into an Azure Static Web App at www.umbracokentmeetup.org.uk. Cloudflare in front of both. SQLite for local dev, Azure SQL in prod. Azure Blob Storage for media. Key Vault for the secrets. SMTP2GO for outbound mail.

Umbraco Cloud is the obvious starting point and I like it, but the pricing didn't suit a free community group. The whole stack as it stands (App Service, SWA, SQL, Storage, Key Vault, Log Analytics, App Insights) costs less per month than a Starter on Cloud. I look after it myself, but Claude wrote all the bicep and the pipeline so the ops side is less painful than that sounds.

I could have clicked through the Azure portal, but I wanted the whole environment in one template I could blow away and re-run. A single az deployment group create lands twenty resources in one resource group, idempotent on re-runs, and what-if catches the surprise that would otherwise cost you money.

Designing for Kent

I sketched the initial layout in Google Stitch, then iterated with Claude on the components. The theme is Kent: Garden of England, so relaxed greens and creams, with the Invicta horse drawn as a unicorn because Umbraco loves unicorns and it seemed a good fit. No pure black, no 1px sectioning borders, depth from stacked surface tokens.

That's the lot on design. This post is mainly about Umbraco.

Schema as code with uSync

First real Umbraco decision. Umbraco 17 ships with an MCP server now, and it's good, but I left it alone. I had enough new things on the go without adding another.

So Claude wrote the uSync files directly. Document types, data types, compositions, datatype configurations, the seed content. They live in UmbracoKentMeetup.Cms/uSync/v17/ in source control and the CMS imports them on boot.

The pattern that paid off most was making the navigation a property of the content rather than a separate thing to maintain. A blocksComposition gives every page type its Block List, and each of the nine block element types (hero, why join, FAQ, events, organisers, venue, sponsors, CTA, contact) declares its own navLabel and hideFromNavigation. Any block whose author has filled in a navLabel automatically contributes a link in the header, the mobile drawer and the footer. Adding a new section to the site is a backoffice job, not a code change, and there's no nav config anywhere that can drift out of sync with the page.

The dev loop is edit a .config file, restart the CMS, watch uSync re-import. Note the restart. ImportAtStartup only runs on process start, so dotnet watch and its hot reload won't pick up a schema change. I worked that out the slow way.

In production I set uSync.ImportAtStartup: None so an editor's hour of work doesn't get wiped on the next deploy. The first deploy needs a one-off Settings → uSync → Import and then it's done. From that point the schema lives in the database, and a .config change in git only matters when a redeploy lands.

Two bits of advice if you're new to uSync. Keep your GUIDs stable across environments, because uSync uses them as primary identifiers and losing them is more painful than you'd think. And use compositions wherever you can.

A thin host, with custom code in a library

I kept the CMS host project tiny: a vanilla Umbraco bootstrap, the CORS policy, a small security headers middleware, and a project reference to UmbracoKentMeetup.Core. Every custom controller, hosted service and composer lives in Core.

The reason is testing. Core has no dependency on the web host, so the sync planner and the iCal parser can be unit tested without spinning up Umbraco. That's how the suite got to 105 tests with none of them slow or flaky.

Umbraco picks the code up through the normal composer scan because Core registers itself as an MVC application part:

public sealed class CoreComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.Services.Configure<MeetupSyncOptions>(
            builder.Config.GetSection(MeetupSyncOptions.Section));

        builder.Services.AddHttpClient();
        builder.Services.AddHostedService<MeetupEventSyncService>();

        builder.Services
            .AddControllers()
            .AddApplicationPart(typeof(CoreComposer).Assembly);
    }
}

That's the whole integration surface. Once Core is referenced from Cms and AddComposers() runs, every controller, hosted service and option binding lights up. No manifest XML, no special folder layout. It's mostly ASP.NET Core with the Umbraco composer pipeline sat alongside, and once that clicked a lot of the rest got easier.

Syncing events from Meetup.com

The first version polled Meetup's RSS feed and parsed the title with a regex. That was always going to be a mess. It relied on whoever wrote the event sticking to one exact title format, and RSS carries no end time, so the calendar download had to invent one.

I moved to Meetup's iCalendar feed (https://www.meetup.com/<group>/events/ical/) with Ical.Net 5.x. Start and end times both come through, the UID gives a stable identifier, the description survives, and the location is reliable enough to use as a classifier.

The hosted service runs on a fifteen minute cadence. It waits for IHostApplicationLifetime.ApplicationStarted before the first cycle so Umbraco's DI graph is up before we grab IContentService and friends. I'd originally had a fixed Task.Delay there as a boot fudge, which is the kind of thing that works until the day the app starts slowly.

There's a failure backoff too. A Meetup outage shouldn't smear a stack trace across the log every fifteen minutes for the rest of the day, so after three consecutive failures I drop to an hourly retry and log one warning per cycle instead of the full exception.

Inside SyncAsync the real work happens against Umbraco's content services. Two patterns from 17 caught me out, and the docs around both are thin.

First, publishing. The old IContentService.SaveAndPublish(IContent) is deprecated in 17, replaced by IContentPublishingService.PublishAsync(key, publishModel, userKey) where the model is an ICollection<CulturePublishScheduleModel>. For invariant content you pass a single entry with Culture = null (invariant) and Schedule = null (publish now). The user key is for the audit trail, and Constants.Security.SuperUserKey is fine for a background service that isn't acting for a real person.

Second, GetPagedChildren:

var existing = contentService
    .GetPagedChildren(folder.Id, 0, 500, out _, null!, null!, Ordering.ByDefault())
    .Where(c => c.ContentType.Alias == EventAlias)
    .ToList();

The four argument overload is obsolete in 17.x and goes away in 19. The full one takes propertyAliases, filter and ordering. The bit that cost me an hour: if you pass [] for propertyAliases you get "load NO properties" and every GetValue<T>() comes back null. Pass null for the implicit "load everything" behaviour. The compiler is no help. The tests just start failing in odd ways and you sit there staring at the data.

The failure mode is nastier than it sounds. Every existing event's eventId reads as empty, so the upsert never matches an existing node, so the deletion phase treats them all as orphans and purges them. Every cycle.

The dirty check that wasn't

After I moved to the iCal feed the sync started logging updated=1 on every cycle even when nothing had changed. So we were republishing every fifteen minutes, which meant uSync was churning exports and the Delivery API cache was constantly throwing away perfectly good data.

It was the location field on online events. The iCal LOCATION was empty, my parser was setting an empty string, and Umbraco's change detection compared a stored null to that incoming empty string and called it dirty. IsDirty() returned true, that propagated up to the node, and so we republished.

The fix is four lines, used everywhere we write a value:

private static void SetIfChanged(IContent node, string alias, string? incoming)
{
    var current = node.GetValue<string>(alias);
    if ((current ?? "") == (incoming ?? "")) return;
    node.SetValue(alias, incoming);
}

With that in, ten one minute test cycles ran back to back and all logged unchanged=1. The lastSyncDate field only gets stamped inside the branch that actually saves, so it records the last time data really changed rather than the last time we looked. Stamp it unconditionally and it forces the node dirty by itself, which is a neat way to recreate the bug you just fixed.

If you're doing any feed driven sync into Umbraco you'll want some version of this. Property change detection uses object.Equals on boxed values, and null != "" even when both are semantically empty for a text field.

A timezone bug that almost slipped past me

This one's recent. After a meetup night I looked at the logs and saw the event had been deleted from the CMS at 20:12 BST. The event ran 19:00 to 21:00, so the sync had removed it while it was still on.

Two bugs sitting on top of each other. The planner was using eventDate (start) for the "has this finished" check, so any event whose start was in the past was up for deletion. And the parser was stripping Kind off the parsed datetime, leaving a wall clock literal with no timezone metadata, which the sync service then compared to DateTime.UtcNow. DateTime comparison ignores Kind, so 19:00 stored as BST wall clock was compared against UTC as if it lived in the same frame.

In BST the bug accidentally delayed detection by an hour, which landed near the actual end time and hid it for months. In GMT it would have fired the moment the event started.

The fix normalises every date to Europe/London wall clock the whole way through. The parser converts on the way in, whatever the source iCal used:

public static DateTime? ToLondonWallClock(CalDateTime? source)
{
    if (source is null) return null;
    return TimeZoneInfo.ConvertTimeFromUtc(source.AsUtc, LondonTz);
}

And the planner now uses end time rather than start, with a 2.5 hour fallback for older nodes that have no eventEndDate stored. The same constant is used by the calendar export, so the "is this event over?" cut-off and the implied duration on a downloaded .ics agree with each other. Two places computing a fallback duration differently is the sort of thing that produces a bug report six months later.

If you're storing wall clock values without timezone metadata, every part of the pipeline has to agree on which clock the wall clock belongs to. Mix UTC and local in a single comparison and the bug stays quiet until DST flips. The IANA identifier Europe/London works cross platform on .NET 6 and later, so you don't need the Windows-only GMT Standard Time any more.

It also bought six new tests, including one that puts an event mid-window and asserts it survives the sync.

iCal calendar downloads

Each event card has an "Add to calendar" link that hits a small controller in the CMS, which reads the event node and writes a .ics by hand. No library, because the output is a dozen fixed lines and a dependency for that felt like overkill.

One thing caught me out. RFC 5545 wants CRLF between content lines, and StringBuilder.AppendLine uses Environment.NewLine, which is \n on a Linux App Service. The output is then technically invalid and some strict consumers refuse to import it. I use a small local helper that appends a literal \r\n instead.

The output isn't fully conformant beyond that, in fairness. There's no VTIMEZONE component behind the TZID=Europe/London parameter and long DESCRIPTION values aren't folded at 75 octets. Google and Apple Calendar both take it happily, which is what our members use, so it's on the list rather than in the code.

The bit worth copying: keep the UID stable, namespaced to your domain, and unique to the event. Then re-downloads update the same calendar entry instead of stacking duplicates next to it.

The contact form

The contact form posts cross origin from the static front end to a CMS controller, with DataAnnotations validation, a honeypot field and a timing gate on top. Website is a hidden field positioned off canvas with aria-hidden so screen readers ignore it, and anything submitted within five seconds of page load gets dropped. Both checks return 200 with a success message so a bot can't tell which one it tripped.

I'm under no illusion about the timing gate. It's a client supplied value with nothing signing it, so anyone who cares can backdate it or leave it off. It filters the lazy traffic and that's all I asked of it. The honeypot does the real work.

The Umbraco specific part matters more. IEmailSender.SendAsync takes an EmailMessage with a from of null, which makes it use the SMTP configured From address, and the submitter goes in replyTo. You don't want emails arriving looking like they came from the visitor, because that breaks SPF and looks dodgy in the recipient's client. Reply-To gets the same convenience without the forgery.

The recipient address and subject line are CMS properties on the home page rather than appsettings, so an editor can change either without a deploy. Sounds small, but I'd rather not be doing redeploys at 11pm because someone's spotted a typo in the subject line.

Consuming the Delivery API in Astro

The Astro build queries the Delivery API at build time and turns the responses into typed shapes. If you're new to headless Umbraco with a TypeScript front end, the mapper layer is the bit I'd put time into first. The Delivery API returns Record<string, unknown> and you do not want that shape leaking past one file, so every property access goes through a small typed helper (str, bool, richText, mediaProp, blocks).

Memoise the page level fetches behind a module level promise while you're there. The build is one Node process per run, so it's safe, and getHome ends up called from the index page, the 404 page and every [...slug] routed page that needs site settings. One fetch does the lot.

The API authenticates with a single header (Api-Key: <key>). The key sits in Key Vault and reaches the build through an Azure DevOps variable group. Don't ship it in a .env, don't commit it.

Images, ImageSharp and content hashes

Umbraco's media library writes uploads under a content hashed path like /media/<hash>/<filename>.<ext>, without you opting in to it. Files at the same logical path get a different hash when the content changes, so you can cache the URL for as long as you like.

Every <img> URL on the Astro side gets wrapped with ImageSharp query strings (width, height, rmode, format=webp, quality) and then signed. ImageSharp resizes on the CMS server, caches the output to disk, and only honours requests whose URL carries a valid HMAC signature. The front end signs each URL at build time with the secret from Key Vault, so nobody can hammer the endpoint with arbitrary dimensions and burn App Service CPU. Editors upload originals, the front end asks for the size it needs per breakpoint, and nobody ships a 4 MB JPG because they forgot to compress it.

Two things to watch. ImageSharp defaults to mode=crop when both width and height are set, which centre crops the source. Fine for headshots, very much not fine for sponsor logos, so pass mode=max anywhere the aspect ratio matters. And SVGs skip ImageSharp entirely, so they leave the app with no Cache-Control and pick up whatever the CDN decides. I added a small middleware just to stamp a long cache header on SVG media.

For prod media storage the Azure Blob provider is wired in conditionally, calling umbracoBuilder.AddAzureBlobMediaFileSystem() only when Umbraco:Storage:AzureBlob:Media:ConnectionString has a value. Bicep populates that env var on the App Service, so uploads go to the media container in Azure. Locally it's unset and Umbraco falls back to its default disk path, wwwroot/media. Same code in dev and prod, which is the whole point.

Small gotcha: the provider writes to a media/ sub path inside the media container, so the real blob path is <container=media>/media/<hash>/<filename>. The doubled prefix is by design and mildly confusing the first time you go looking in the portal for a file.

Production runtime mode

Umbraco 17 has three runtime modes: BackofficeDevelopment (default), Development and Production. Production does not let you cheat. Five validators run on boot and if any one fails the host refuses to start.

Three things tripped me up. The runtime mode lives at Runtime:Mode, not RuntimeMode directly under CMS as I'd typed it first time. ModelsMode can't be InMemoryAuto in Production, though Nothing and SourceCodeManual both pass; I went with Nothing because nothing on the server renders Razor against the generated models. And the fifth validator the public docs don't mention is the Razor runtime compilation one. Don't reference the Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation package and you'll be fine. The docs talk about four. The fifth is the one you'll be chasing in the boot log going "but what is it now?"

Turn this on earlier than feels comfortable. Every validator failure is a config bug you'd otherwise ship and find later under more pressure.

Once all five are happy the Health Checks dashboard goes clean. A short middleware in Cms/Program.cs covers the response side, setting X-Frame-Options, X-Content-Type-Options and Referrer-Policy.

Note what's not there: X-XSS-Protection. I set it at first, then took it out. The legacy browser filter it controlled has been removed from every current browser. Sending the header at all, even the 0 value that older write-ups recommend over 1; mode=block, trips warnings from some scanners. MDN's line is to omit it entirely, so I do.

Cookie consent and Google Analytics

The site sets exactly one cookie until you accept anything, and that's the consent record. GA4 only loads after you say yes.

Version one parked the GA snippet inside <script type="text/plain" data-cookie-consent="tracking"> and swapped the type on consent. That seemed clean until I tried a real GA install snippet, which contains a nested </script>. The HTML parser closes the outer <script> on the first one it sees, whatever the type attribute says, so the page broke the moment GA went near it.

Version two uses <template data-cookie-consent="tracking">. Templates are inert at parse time, scripts inside them don't run, and the parser handles nested tags. On consent, JS walks each template, clones the content, recreates each <script> as a fresh element, drops the fragment in and removes the template. You have to build new script elements with the same attributes and textContent, because cloned scripts stay inert. It works for GA, GTM, Plausible, or whatever an editor pastes in next.

The CSP allows the activated scripts because the parked and activated content hash to the same value. A build script walks dist/**/*.html, hashes every inline <script>, and substitutes the script-src allowlist into the static host header configs.

Webhook-driven rebuilds

Editors shouldn't have to know what a pipeline is. Umbraco's built in webhook posts to the Azure DevOps pipelines REST API on every publish, and that queues a front end rebuild. The webhook config goes in _Settings → Webhooks_:

Field

Value

URL

https://dev.azure.com/<org>/<project>/_apis/pipelines/<id>/runs?api-version=7.1

Headers

Authorization: Basic <base64 of ":<PAT>">

Events

Content Published, Content Unpublished, Content Deleted, Media Saved, Media Deleted

Two things cost me an evening each.

Do not add Content-Type: application/json as a custom header in Umbraco's webhook UI. Umbraco copies user-supplied headers onto HttpRequestMessage.Headers, but Content-Type is a content header, not a request header, so .NET throws System.InvalidOperationException: Misused header name and the webhook never fires. Umbraco sets the content type itself anyway. Leave it off.

The PAT-authenticated webhook arrives at Azure DevOps with Build.Reason='Manual', the same as someone clicking "Run pipeline" in the UI. I put condition: ne(variables['Build.Reason'], 'Manual') on the CMS build stage so an editor publish only rebuilds the Astro front end. A git push to main still does both.

There's also a quirk where deleting a published node fires both Content Unpublished and Content Deleted, queueing two pipeline runs on top of each other. Azure DevOps doesn't deduplicate API queued runs. The cheap fix is dropping Content Unpublished from the subscription if your editors don't unpublish without deleting. Mine don't.

Cloudflare gotchas

Cloudflare handles apex to www redirects, trailing slash canonicalisation, edge caching and the SSL on the apex. Two things sat quietly broken until I noticed them.

The trailing slash redirect rule needs a hostname filter. I missed it, so the rule was firing on cms.* too, and a request to /umbraco/management/api/v1/server/status (no trailing slash, no extension) was getting bounced cross host to www.*, losing its CORS headers on the way. Management API calls from the backoffice were quietly failing. Add Hostname equals www.<your-domain> to the conditions and it goes away.

Cloudflare's default Browser Cache TTL is four hours, and it silently overrides whatever you sent from origin. I set 30 days on CMS media via Umbraco:CMS:Imaging:Cache.BrowserMaxAge, then watched the browser receive max-age=14400 and burned a chunk of an afternoon on it. Fix it in the dashboard: Caching, Configuration, Browser Cache TTL, "Respect Existing Headers". The Cache Rule for cms.*/media/* should also have Edge TTL: Respect Origin Cache-Control.

Getting to Lighthouse 100

Four of these were less obvious than the rest, and only mattered once everything else was sorted.

The hero image is the LCP on the home page, so it gets a <link rel="preload" as="image" imagesrcset="..." imagesizes="..."> before the stylesheet. The imagesizes value has to match the rendered <img sizes> exactly, and one character off means the browser drops the preload without telling you. The CMS origin gets a preconnect too, so the DNS and TLS handshake happen during the document parse rather than serially when the first <img> fires.

CSS is split at build time into a critical file of about 12 KB inlined per page, and a below-the-fold file loaded via the media="print" swap pattern with a <noscript> fallback. That cleared the last render-blocking flag PageSpeed Insights was throwing at me.

aspnet-client-validation was costing 118 ms of forced reflow on every page, wiring up handlers for a form most visitors never touch. Lazy bootstrap on first focus or pointerdown with { once: true } and it disappears off the trace.

The header was shifting the page on first paint, because its height came from flex children plus padding and the JS measurement disagreed with the CSS fallback once the brand font loaded. Giving .site-header an explicit height, and letting the JS track changes rather than establish the value, fixed it. A CLS problem is often two sources of truth for one number.

So, did AI build it?

Yeah, it did. I didn't write any of the code by hand. I planned with Claude Code, looked at every diff, pushed back when something was wrong, and shipped it.

The useful part wasn't the typing. It was that a second pair of eyes with infinite patience will go and work out why GetPagedChildren returns null properties, at 10pm, without complaining. The bugs in this post are all real, and most took a conversation to find rather than a prompt.

Three things I'd pass on. Astro and headless Umbraco go together well, because the Delivery API gives you the shape a static site generator wants and the build cost stays flat however many pages you add. Background services for content sync are pleasant as long as you've nailed change detection, and SetIfChanged is the small piece of code that makes the pattern viable. And set the bar high: "Lighthouse 100" and "A+ on securityheaders.com" are concrete enough that AI can plan against them, where "make it pretty good" isn't.

The site's at https://www.umbracokentmeetup.org.uk. If you're in Kent, come along to a meetup. We meet monthly and alternate between online and hybrid at Dragon Coworking in Rochester and we'd love to see some new faces.