← All posts

Build log

How this site was built — and what the AI tools actually did

I built this site for a pretty simple reason: I wanted one link to hand to a client, a recruiter, or a colleague who asks what I actually do. Building it also let me answer a smaller question — how far can a hand-written static site, plus a couple of AI tools, get without a framework or a build step?

Why a personal site, and why now

Honestly, most of my work is invisible. It lives in other people's repos, behind other people's domains, and it rarely has my name on it. A personal site is the one surface I fully control — somewhere to say what I do, show a few real projects, and make it easy to start a chat. So I kept it small on purpose: a handful of static pages and exactly one bit of back-end, the contact form.

Design first, in Open Design

I didn't start with code. I started in Open Design, which treats every screen as its own file and spits the result out as plain HTML, CSS and JavaScript. The export also comes with a design manifest — a JSON file that names the entry point, lists the screens, and locks down the design tokens (color, spacing, radius, motion) so the code can't quietly drift back to some default theme. I treat that manifest as the contract. The palette you're reading this on, a warm cream with a single orange accent, is defined once and reused everywhere.

Static export with Next.js

"No build step" didn't survive first contact with real content. The site is a Next.js app now, but it still ships as static files. Next.js's static export (output: "export") runs at build time and writes plain HTML, CSS and JavaScript into an out/ folder. No server, and no framework runtime doing the work in the browser — so what gets deployed is still a static site. That pays off: the Lighthouse scores are 100 for performance, accessibility and SEO.

The one requirement that wasn't trivial was language. The site is bilingual, English and Dutch, and instead of swapping text in the browser, each locale is its own static route — /en/… and /nl/… — generated at build time from shared components. The URL carries the language:

// next.config.mjs
const nextConfig = { output: "export", trailingSlash: true };

Because the language lives in the URL, search engines and screen readers get the right page on the first request, with the lang attribute matching the route — no client-side toggle to wait for.

A contact form that actually sends mail

A static site can't send email on its own, so the contact form is the only bit with a back-end. It's a small Azure Function, written as an isolated .NET 8 worker, listening on POST /api/contact. It validates what comes in, ignores a hidden honeypot field that bots love to fill, and hands the message to Azure Communication Services, which sends it as real email from my own domain.

The isolated worker model has one sharp edge that cost me some time. In that runtime, WriteAsJsonAsync quietly resets the status code to 200, so an error response still looks like success to the browser. The fix is to set the status again after the body is written:

var res = req.CreateResponse(code);
await res.WriteAsJsonAsync(new { error });
res.StatusCode = code; // worker 1.x WriteAsJsonAsync forces 200; re-assert after

It's one line, and it's covered by a unit test — because this is exactly the kind of thing that breaks silently months later.

Shipping to Azure

The whole thing runs on an Azure Static Web App. The free tier hosts the static files and the Function together, on one domain, which keeps things small. Deploying is continuous: push to main and a GitHub Actions workflow fires off, builds nothing — there's nothing to build — and uploads both the site and the API. Open a pull request and you get a throwaway preview environment, which turns out to be genuinely useful for checking a change before it goes live.

The only real config is telling the deploy action where everything lives:

app_location: "/"        # the static site
api_location: "api"      # the .NET function
output_location: ""      # nothing to build

The custom domain took the longest. The bare domain points at the Static Web App's IP through an A record, www is a CNAME to the generated hostname, and both get registered as custom domains in Azure. With DNS at a third-party registrar, expect to wait for propagation — and leave the verification TXT records in place while you set it up.

What the AI tools actually did

Two tools handled the AI part, and it's worth being precise about what each one actually did. Open Design produced the layout and the token set. OpenCode, an AI coding agent that runs in the terminal, wrote and refactored code all over the repo. It wired up the bilingual toggle, the form validation, the Azure Function and the test project, and kept earlier decisions consistent as the site grew.

Where the tools shone was the mechanical stuff: renaming an attribute across seven files, keeping indentation honest, turning a described rule into a validator with tests, and noticing that a dark star on a dark square just renders as a black blur. Where I still had to decide things myself: what the site should say, which orange to use, and what "done" actually means.

Put simply, the AI tools moved the work from typing to reviewing. Most of my time went into deciding what to build and checking the result was right — not into keystrokes.

What I would do differently

Three things I'd do differently next time:

  • Lock the design tokens before writing a single component. Changing a shade halfway through means touching every page.
  • Write the tests before the function, not after. The status-code bug was caught by a test that should've existed from the start.
  • Keep the first post short. This one grew; the next will be smaller and land sooner.