Building a QR-Code Walking Route App with Umbraco, Next.js and AI

A local neighborhood council wanted to create a walking route through the area using numbered poles with QR codes.

The idea quickly became a bit more interesting than a normal QR route. The same 25 poles should be reusable for different routes. Children could follow a gnome story, another route could contain sports challenges, and another could be a neighborhood detective game.

Physically, everyone follows the same route. What you see after scanning a QR code depends on the theme you picked.

I used the project as an experiment: could I build the complete application using an AI coding agent, without writing the implementation code myself?

That meant building both the Umbraco backend and the Next.js frontend, including the content model, Delivery API integration and frontend components, by describing the requirements and letting the agent implement them.

Why Umbraco and Next.js?

The backend runs on Umbraco 18 and .NET 10. Content is exposed through the Umbraco Content Delivery API v2.

The frontend is built with Next.js 16 and talks only to the Delivery API. There is no Razor frontend and there is no database shared between the two applications.

For this project that split made sense.

The volunteers managing the walking routes should be able to maintain everything from the Umbraco backoffice. Visitors, on the other hand, mainly use the application on their phone while walking around the neighborhood. The frontend therefore needs to be lightweight, mobile-friendly and eventually installable as a web app.

It also gave me a useful test case for AI-assisted development because the agent had to understand two separate applications and keep the contracts between them consistent.

Getting the content model right first

Before building the frontend, I spent some time thinking about the content structure.

The important part is that a physical QR pole and the content shown for that pole are two different things.

There are 25 physical poles in the neighborhood. Those poles don't change depending on which theme you follow.

If the coordinates, pole number and QR target were stored directly inside every themed route, the same pole would exist several times. Changing the location of pole 7 would then mean updating pole 7 in every route.

Instead, I separated the physical markers from the themed content.

routeHub                  → one physical walking route
├── markerRepository       → poles, listed once
│   └── routeMarker        → pole 1..25, physical anchor, QR target
└── routeCollection        → a theme ("Kabouterroute", "Sportroute", ...)
    └── routePoint         → content for one pole, in one theme, references a routeMarker

This means a marker only exists once.

A routePoint then references that marker and contains the content for a specific route.

For example, marker 7 could show a gnome story in one route and a sports challenge in another.

Creating the Umbraco structure through MCP

Instead of manually creating all the document types and data types in the Umbraco backoffice, I connected the AI agent directly to my local Umbraco installation using the Umbraco MCP development server

My OpenCode configuration contains something similar to this:

"mcp": {
  "servers": {
    "umbraco": {
      "type": "local",
      "command": ["npx", "-y", "@umbraco-cms/mcp-dev@17"],
      "environment": {
        "UMBRACO_BASE_URL": "https://localhost:<port>/",
        "UMBRACO_INCLUDE_TOOL_COLLECTIONS": "document,media,document-type,data-type"
      }
    }
  }
}

This gives the coding agent tools that can work directly against Umbraco.

It can create data types, document types and content and, more importantly, it can inspect what already exists. That is quite different from asking an AI model to generate some Umbraco code and hoping it matches the actual CMS setup.

There was one thing I had to document quite explicitly: the order in which the Umbraco structure should be created.

For this project that roughly became:

  • Data types

  • Element types used by Block Lists

  • Block List data types

  • Compositions

  • Document types

  • Allowed child types and list views

Without that instruction the agent occasionally tried to create something that depended on another type that did not exist yet.

Once the order was written down in the project instructions, that stopped being a problem.

There is also a small version-related annoyance.

The project itself uses Umbraco 18.1.1, while the MCP development package currently used in the project is:

@umbraco-cms/mcp-dev@17

Because of that, the agent sees a version mismatch warning when it starts a session.

It doesn't cause any problems for this project, but I had to add a note about it to the project instructions. Otherwise the agent would regularly notice the warning and try to "solve" something that didn't need solving.

Keeping the Next.js side simple

For the frontend I wanted to keep the responsibilities fairly strict.

There are essentially three layers.

The first is an HTTP client that talks to the Umbraco Delivery API.

The second is a mapper that converts the Delivery API response into the frontend domain model.

The third is a server-side loader that combines the two.

The important rule here is that the mapper doesn't make network requests.

That sounds like a small detail, but it turned out to be useful while working with an AI agent. The mapping code can be tested and changed without needing a running Umbraco instance for every iteration.

It also keeps Delivery API-specific structures out of the React components.

Making missing block renderers a build error

One thing I specifically wanted was for new content block types to fail loudly.

The frontend has a ContentBlock union and a BlockRenderer.tsx component that switches over the available block types.

The default case calls an assertNeverBlock() helper.

The practical result is that when a new block type is introduced, TypeScript forces us to deal with it.

The workflow becomes:

  • Create block in Umbraco

  • Map it in mapper.ts

  • Add it to ContentBlock

  • Build fails↓

Create the corresponding React renderer

I prefer that over silently ignoring blocks that the frontend doesn't understand.

It is also a useful guardrail when an AI agent is making changes. If it updates the model but forgets the renderer, the compiler catches it immediately.

No demo data fallback

Another rule I added early was: no fallback demo content.

During development it is tempting to do something like this:

  • Try Umbraco

  • If Umbraco fails

  • Return some local demo JSON

I deliberately didn't want that.

If the frontend can't retrieve content from Umbraco, I want that problem to be visible.

Otherwise you can easily end up testing a perfectly working frontend without realising it hasn't talked to the CMS for the last hour.

For this application it is also better for visitors to see that something went wrong than to accidentally receive outdated or fake content.

What isn't finished yet

The application works, but there are still several things on the list before I would call it production-ready.

Caching is currently implemented using Next.js cache tags. Content is tagged per route and per site, with a one-hour revalidation period.

What is missing is publish-triggered cache invalidation.

At the moment, publishing something in Umbraco can therefore take up to an hour to become visible in the frontend.

The next step is to add a webhook from Umbraco that tells Next.js which tags should be revalidated when content is published.

Other things that are still on the list:

  • QR-code generation;

  • GPS/location checks;

  • PWA functionality;

  • automated tests;

  • better error handling around route loading.

Those will probably be useful follow-up experiments because the basic architecture and project rules are now already in place.

What I learned from building it this way

Almost all implementation code in this project was written by the AI agent.

That doesn't mean I could just give it one large prompt and come back later.

Most of the work was in deciding how the application should behave and writing those decisions down clearly enough and reviewing all the logic.

Things like:

  • a marker and a route point are different concepts;

  • document types have to be created in a specific order;

  • mappers aren't allowed to perform HTTP calls;

  • there is no demo-data fallback;

  • every content block must have an explicit renderer.

Once those rules were part of the repository, the agent became much more predictable.

That has probably been the most useful part of the experiment for me.

For me, the interesting part of this project wasn't how much code the agent could generate. It was seeing how important the project instructions became. Once things like the content model, build order and frontend rules were written down, the agent became much more predictable.

There is still enough left to build, especially around caching, QR generation and testing, so I'll probably use this project for some more experiments with AI-assisted development.