case study

The pages I would never have built by hand

Most websites have a page builder for the easy pages and hand-written code for the ones that matter. On RAYFILM's new platform there is no second category. Whether it is login, registration, the cart, the checkout or the customer account pages, each one is assembled in the same editor as a news item, and I would not have had the time to build that without AI. This is about what it does, and why twenty-five years of habits are the reason it stayed clean.

Client
RAYFILM
Status
In development
Stack
.NET 9, Next.js
Published
October 2026

RAYFILM sells label sheets, photo prints and calendars, and until now each of those has been its own website. Two of them are mine. I wrote tiskfotoobrazu.cz first, then anylabels.eu, the one from the last piece. The first draws on a canvas. The second is SVG, because that geometry ends up in front of a cutting laser. Both were the right choice at the time, and they share almost nothing. Had I known the second one was coming, I would have built both on SVG and reused most of the code.

That is the reason for putting everything under one roof. The company site with its catalogue and shop comes first, then the two builders move in, then a calendar builder that has never existed.

I ended the last piece by saying this is the first customer project I have built from scratch with AI, and that what interested me was whether my rules survive that. They did, and the best place to see it is the part of the platform that holds everything else together: the page builder.

Over the years the client has come back to me many times for small changes on the current sites, a text here, a banner there, a page rearranged. Each one is a small job, but together they are a steady maintenance cost for the client and an interruption for me. This time I wanted them to make most of those changes themselves, without waiting for a developer. So the new platform is built around a page builder, and that is what this piece is about. The platform is still in development, so this is a look at it from the inside.

The pages I would have hardcoded

Every page builder has the same weak spot. It copes well with a news item or a landing page, which are mostly text and images. A cart or a checkout is different. It carries state from one step to the next and validates what the customer types, so its parts depend on one another. Builder widgets know nothing about their neighbours, so every widget requests the same information again, and the page gets slow. Fixing that would mean building a new layer on top of the widgets so they can share what they need, and with so many unknowns in that, hardly anybody bothers. A developer writes the page directly instead, gives it its own endpoint, and from then on only a developer can change it.

I would have done exactly that. Not because I can't build a better answer, but because for one person a better answer is a lot of work to justify on a site that mostly has to function.

On this platform, a news article and the registration form are made the same way. An editor assembles the page in the CMS from what I call content blocks, which in most other systems you would know as widgets. Pages come in three levels: a layout owns the frame, a template inherits the layout and adds structure, and a page inherits the template and fills in only what the template left open.

The checkout shows how it fits together. The layout gives it the header and footer, the template adds the three steps, and the page itself is three things: the context, the summary and the cart.

The checkout page in the CMS editor. The site header comes from the layout. An orange Layout zone holds the template's three-step progress bar (cart, checkout, summary), and inside it a blue Template zone holds a cart context block with the cart on the left and the order summary on the right. A menu is open next to the order summary with the options Edit, Move up, Move down, Move out of Row and Delete.
The checkout in the editor. The header comes from the layout, the progress steps from the template, and the cart and order summary from the page, in the innermost zone. The open menu is the one an editor uses to move or remove a block.

I have worked inside commercial CMS platforms that cost real money and are harder to live with than this one.

Written by hand, the block system alone (the editor, the inheritance chain, and every block type with its own fields, rendering and tests) would have taken several more months of full-time work than it did. That is time I would not have spent on it, and the typing is only part of the cost. A system like this is a project of its own. No sane developer starts one unless the client asked for exactly that, and then it comes with months of discovery, every detail gathered up front, because the foundation has to be right from the first day. Get it wrong and you rewrite it, and rewriting by hand means the time already spent is written off.

With AI that changes. When I am not typing the code by hand, changing the foundation of a feature does not mean scrapping everything and starting again. I could begin with less certainty and correct course as I went, in the gaps around other work.

One trip for the whole page

Correcting course started with the problem I opened with. Building a page builder is only half the work. The rest is thinking it through, and that part never finishes, because every fix uncovers something else. This one was the first. Blocks are independent by design, which is what lets an editor move them around, and it also means none of them knows what its neighbours have already asked for. Take a product page with six of them, all needing the same product: if each fetches its own copy, that is six trips to the back end to answer a single question.

The usual answers both take the page away from the person who is supposed to own it. You can write the page by hand, with an endpoint that returns exactly what it needs, and it is fast and locked. Or you can make one large block that fetches everything and renders the whole page, and an editor can drop it on a page and leave it alone.

What I built instead is a layer on top of the builder, and the layer is one more block, the Context block. Once per page, it fetches the data the page is built around, whether that is a product, a cart or the logged-in customer. The blocks nested under it read what it already holds and ask nothing themselves. One trip for the whole page, and the editor can still shuffle the nested blocks as they like.

Products, carts, checkout and forms are all built the same way, each with its own Context block and its own small blocks. Each new area was another use of a pattern that already worked, so none of them was designed from scratch. A page an editor assembles costs about what a page written by hand would cost, and someone who has never opened the code can still rearrange it.

The Context block is a complex feature laid over an already complex one, and both exist because AI made the typing cheap enough to try, but deciding what they should be was still my job.

But I am well aware that few projects allow working this way. It depends on the client and the circumstances, and this one had no fixed scope and no deadline. That let me keep the discovery phase short and talk the open questions through with AI as they came up. It was a luxury, but it is also what gave me room to learn the tools properly, mistakes included. By the end I had a way of working with AI that I trust, and the obvious next question is how the code it wrote stands up to my own standards of quality.

It did not turn into spaghetti

The worry I hear most about AI-written code is that it works and nobody can read it. The code on this platform reads the way mine always has. Modules are removable, adding the seventh block of a kind costs what the second cost, and every fact lives in one place. I have held those three rules for two decades, and I don't negotiate them under time pressure.

A module declares itself, and nothing else knows how it is built:

C#
public interface IModule
{
    string Name { get; }
 
    void ConfigureServices(IServiceCollection services, IConfiguration configuration);
 
    void ConfigureEndpoints(IEndpointRouteBuilder endpoints);
}

The host registers each module and asks all of them to map their endpoints, without naming any of them:

C#
public static IEndpointRouteBuilder MapModuleEndpoints(this IEndpointRouteBuilder endpoints)
{
    var modules = endpoints.ServiceProvider.GetServices<IModule>();
 
    foreach (var module in modules)
    {
        module.ConfigureEndpoints(endpoints);
    }
 
    return endpoints;
}

There are six modules. Four carry the business: Content, Eshop, Identity and Platform. Two support them: Notifications, which Identity, Content and Eshop all send email through, and Sync, which moves content between environments and which nothing else depends on. Between the business modules, a module may reference another module's Contracts project but never its Api project. So Eshop asks for a VAT rate through an interface and gets whatever Platform registered against it. The two are independent at compile time and connected at runtime.

The host registers six modules. Each is split into an Api project holding the implementation, a Contracts project holding the public surface, and an Entities project. Content and Eshop use Platform's Contracts; Identity, Content and Eshop use Notifications' Contracts; nothing depends on Sync.
Modules meet only at their Contracts.

Inside a module it is the same idea again. The module lists its features, and each feature registers itself:

C#
public void ConfigureEndpoints(IEndpointRouteBuilder endpoints)
{
    endpoints.MapProductEndpoints();
    endpoints.MapPricingEndpoints();
    endpoints.MapOrderEndpoints();
    endpoints.MapCartEndpoints();
    // …
}
C#
public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder app)
{
    var orderEndpoints = app.MapGroup("/api/orders")
        .WithTags("Orders")
        .RequireAuthorization();
 
    orderEndpoints.MapGet("/{id:int}", GetOrderById)
        .RequireAuthorization(PermissionCodes.ViewOrders);
 
    // …
}

Adding a feature means adding one line to that list, in the one module that owns it. Each feature groups its own routes and carries its own permissions, so the access rules sit next to the code they protect. Extension methods per feature and route groups are how Microsoft's documentation suggests organising a larger API.

At the bottom of the chain is the endpoint itself:

C#
private static async Task<Results<Ok<OrderDetailDto>, NotFound>> GetOrderById(
    int id,
    IOrderReadService readService,
    CancellationToken cancellationToken)
{
    var order = await readService.GetOrderByIdAsync(id, cancellationToken: cancellationToken);
    return order is null
        ? TypedResults.NotFound()
        : TypedResults.Ok(order);
}

It does nothing but ask a service and turn the answer into a response. The return type says what the endpoint can answer, an order or "not found", and the compiler enforces it. The service arrives as a parameter, so the dependencies are listed in plain sight. Both are what Microsoft recommends for this kind of API. The nested generics are tedious to type by hand, which is exactly the kind of work AI makes free.

Extensibility is the same idea. Content blocks are not registered anywhere:

C#
var assembly = typeof(ContentModule).Assembly;
services.RegisterAllImplementations<IContentBlockType>(assembly);
services.RegisterAllImplementations<ISnippetKind>(assembly);

Adding a block type means adding a class. There is no list to update, so there is no list to forget to update.

Nothing in these examples is surprising, and that is the point. Any decent developer would write it this way, and it is what I would have written myself. What surprises me is where it came from. Nowhere in my instructions does it say "use typed results" or "inject services as parameters", and I do not type it into prompts. Not once, and certainly not every time. I set three rules: modules are removable, extending costs the same every time, and every fact lives in one place. Everything above, including the shapes Microsoft's own documentation recommends, came out of those three.

It was not always like that. For a long time I said everything, over and over, and the code still drifted. Getting AI to write it this way took time to put a leash on it, and longer to learn what keeps it there. The clearest example is content blocks. When I first had AI build the page builder, it hardcoded them: thirteen block types written out as an enum, with a Custom entry for whatever the list missed. I did not notice at first sight. I noticed when I added a new round of content blocks and saw that each one meant editing the same list. Getting rid of it took far longer than it sounds. Early on, neither the AI nor I was any good at working this way. I would ask for an extensible design and get back one that was extensible until the next feature touched it, when a switch statement would quietly reappear. It was frustrating. Repeating the same correction again and again was so much work that some days I would rather have written the code myself. I pushed through, because I realised I was the one doing something wrong: AI was supposed to take work off me, not add to it. So I started testing every tool that could hold the context for me, and keep my sanity along the way. What worked for content blocks was giving them their own standing set of instructions, loaded every time that part of the system is touched, with the registry-over-enum rule written into it. That was the turning point. Once the context was kept for me, I stopped repeating myself, and the rules did the remembering. More than sixty block types later, the shape has not slipped back once. How that works is the subject of the next piece.

The top of the Add block picker in the CMS editor, with a legend of block markers above groups of block types: Form, Content and Catalog, with the start of Checkout cut off below. Several catalog blocks, such as Product buy and Product description, have a dashed border and a diamond marking that they must be placed inside a context block.
The top of the block picker. The list continues through checkout, account, authentication and more. A diamond marks a block that only works inside a matching context block.

The safety net

The second area I would not have had time for is the safety net around the code. On almost every project, unit tests are the first thing squeezed when a schedule gets tight. The checks around them, from code style to a scan of every dependency for known security problems, are a project of their own to set up. With AI I have all of it, and it runs on every push.

I had AI scan the source code, recommend what should be checked, and then write the two pipelines with me, one for the back end and one for the front end. Every push to the development branch goes through both. The back end is tested with integration tests against a real SQL Server, and the front end's component library is built and exercised in a real browser. A change reaches the staging server only when the build and every test pass, and a health check then confirms that the API and both front-end apps actually came up.

The run summary of the front-end pipeline in Azure DevOps. The Validate stage has failed: lint and type check finished with a warning, unit and integration tests failed, and Storybook build and test passed. The Build and Deploy stages after it show as skipped.
A push whose tests failed. The build and deploy stages never ran.

Working alone, I would not normally have set up multi-step pipelines like these, kept separate for each repository and for the development and master branches. That is how a team works. With AI, team-style setups like this are no longer time-consuming.

The pipelines are the last line of defence. The rules I set for AI are enforced earlier, while the code is being written. The back end inherits one file that turns warnings into errors and switches on the .NET analyzers, StyleCop and Sonar, so nothing compiles until it is clean, on my machine and in CI alike. The front end has one command that runs linting, style checks, type checks, tests and translation coverage. On top of the standard tools I have custom checks of my own, for example for a missing database migration or a missing translation.

The area I used to find the most boring and tedious, testing and checking, has become one I enjoy. A full report of everything that might be wrong used to mean building the whole infrastructure behind it first. Now the report just arrives, and it covers things I would not even have thought to check.

I stay in charge without reading every line of what comes back, which nobody manages across two hundred features. What makes that possible is that the rules I care about are checked automatically, and the work stops when one is broken, whether I am paying attention that day or not.

What comes next

RAYFILM is still in development. When it goes live, I will write about how it held up.

Before that comes the part I have only touched on here: how I actually work with AI day to day, meaning the agents, the memory, the plans, and what I changed after things went wrong. That is the next piece.

Need a site your team can change without a developer?

Page builders, shop checkouts, and one senior developer covering what used to take a team: this is the kind of work I like being handed.

Tell me about your project

← More case studies