Contact

How this site is built, from Markdown to a Cloudflare Worker

Drawing of the Enschedé printing house in Haarlem around 1740: compositors and pressmen at work, inside an ornamental frame.
Interior of the Enschedé printing house in Haarlem, Adolf van der Laan, c. 1740. Rijksmuseum, public domain.

This site is a small product, and we built it the way we build client products, so it is a fair example of what we mean when we say something is done. Everything below is in the repository: the pages, the Worker, the audit and the workflow that deploys it.

Static pages served by a Worker

The pages are built with Astro to plain HTML, one directory per route with a trailing slash. A Cloudflare Worker serves the built directory as static assets, and it runs first on every request so it can handle the few things a file server cannot: a redirect from http to https and from www to the apex, the 404 status for a missing page, the headers below, and the contact endpoint.

We chose this over Astro’s server mode because nothing on the site changes per request, and over a separate Pages project because one Worker with one deploy path is less to look after. Visibility to search engines and AI crawlers is the point of the site, so every page is complete HTML at request time with no client rendering.

There is very little JavaScript, and none of it is needed to read a page. The contact form works as a plain HTML form post, and a small inline script enhances it to post JSON and show errors in place when JavaScript is available. Three small module scripts add motion: the pixel field behind the home page heading, a soft light that follows the pointer over images, and thumbnails that assemble from coarse pixels when they scroll into view. Each loads deferred, does nothing until the pointer or the viewport asks it to, pauses when its element is off screen, and stays still for anyone who has asked their system for reduced motion. The animated figures in articles and on the service pages are SVG with CSS animation and no script.

Content with a validated schema

Services, case studies and articles are Markdown and MDX files in three content collections, each with a schema. The schema says a title is at most 80 characters, a description is between 70 and 160, a case study’s role is one of a fixed list that is printed verbatim on the page, and an article’s related field is a list of slugs. When a file breaks the schema, the build fails with the file and the field named.

The schema is also why the role on a case study cannot drift into a claim we did not intend. It is an enum, and the page prints it.

markdown edge deploy build titlelinksimagessitemap html indexnow
Markdown with validated front matter becomes HTML, a sitemap, llms.txt, RSS and sharing images in one build, which an audit checks before anything deploys.

One build, every surface

The sitemap, the RSS feed, llms.txt and llms-full.txt are Astro endpoints over the same collections as the pages. llms.txt follows the llmstxt.org layout: a paragraph about the studio, key facts, then the services, case studies and articles with one-line descriptions. llms-full.txt appends the full text of every page, with the figure tags stripped out because they carry no text. Because they come from the same files, a new article appears in all of them without editing anything else.

Sharing images are rendered before each build from the title and section of each entry, with satori and resvg, and committed, so a content change made without the toolchain still has a valid image. An article’s sharing image also carries a crop of its cover artwork. Structured data for the organisation, services, FAQs, articles and breadcrumbs comes from small functions in one file, so no page hand-writes JSON-LD.

The audit

After the build, a script walks the output and fails on anything that would ship a broken search contract: a page without a title, description or canonical; a description outside 70 to 160 characters; a canonical that does not match the page’s path; a duplicate title; more or fewer than one h1; an image without alt or pointing at a file that does not exist; an internal link to a path that does not exist; a missing sharing image; and a sitemap that lists a page which does not exist or omits a page that does.

The audit runs in CI on every push, so a renamed article with an old link to it somewhere fails before it deploys. It is the test of the SEO contract, and it costs one file with no dependencies.

The contact endpoint

The one dynamic path is POST /api/contact. It accepts a plain form post and a JSON body, validates the fields, and stores the inquiry in a D1 table before trying to send the email, so a mail failure never loses an inquiry. The form path answers with a redirect to a thanks page, or with the contact page re-rendered with the errors and the visitor’s entries put back, because a static page cannot read query parameters without script.

Bots are kept out by a hidden field with a plausible name that people never see and bots fill in; a submission with it filled returns the success response and stores nothing. Floods are kept out by a rate limit of five inquiries an hour per sender, computed as a count over the last hour by a hash of the IP in the same table, so there is no extra store to run. Bodies over 16 KB are rejected before parsing. The endpoint is tested in Cloudflare’s local Workers runtime with a local D1, for validation, the honeypot, the limit, both content types, email failure and the admin export.

Headers

Every response leaves the Worker with the same security headers: HSTS with preload, a Content Security Policy, nosniff, a referrer policy and a permissions policy that turns off camera, microphone and location.

The CSP allows no unsafe-inline for scripts. The inline form script is hashed after the build, and the hash is in the policy, so the browser runs that script and nothing else inline. The analytics origins are allowed by name. Astro inlines each page’s stylesheet, so styles do allow inline; that is the one concession, and it buys pages that render without a second request.

Cache headers come from the same place. Pages are cached for five minutes, so a deploy shows up quickly. Fingerprinted assets are cached for a year as immutable. API responses and the 404 page are never cached.

Images

Every article opens with a public-domain artwork. A script fetches it from the Rijksmuseum or the Metropolitan Museum of Art only after the museum’s own record marks it public domain, resizes it, and prints the credit block the article’s front matter needs; the schema refuses a cover without a title, maker, year and source link. The illustrations on the studio and service pages are generated in the manner of an engraved plate, in the site’s three colours, and each page says so.

The build turns every image into AVIF at several widths with a WebP fallback, so a phone downloads the size it shows. The image at the top of an article or case study is preloaded with its responsive sources, so the browser starts fetching it before it has parsed the page. Fonts are self-hosted and cut to the Latin character set.

Lighthouse

We run Lighthouse against the production site at mobile and desktop settings on the home page, the studio page, a service page, a case study and an article. Performance, accessibility, best practices and SEO score 100 on each, with the largest paint at about a second on a throttled phone. That is what a static page with inlined styles, sized images, no framework and no third-party script looks like, and it holds as long as each new script stays small and waits to be asked.

Deploy

GitHub Actions runs on every push and pull request: type-check, build with the audit, and the Worker’s tests against the built pages. A push to main whose checks pass is deployed, with a concurrency group so two deploys never overlap and a newer one waits for the older. The deploy job applies any pending D1 migration first, then deploys the Worker with the site as its assets.

After the deploy, a script hashes every page in the sitemap, ignoring the inlined stylesheet so a CSS change does not resubmit everything, compares the hashes with those from the previous deploy restored from the CI cache, and posts the changed URLs to IndexNow with a key served from a path on the site. The first deploy submitted everything. A typical one submits a few pages.

Rolling back is a Wrangler command or a reverted commit. Inquiries live in D1 and a code rollback does not touch them.

We build client sites and products with the same list: a schema that fails the build, an audit that checks what ships, headers set once, and a deploy that anyone with a green build can run. The custom AI apps page describes that work, and you can write to us if you want a site or a product built this way.

More writing

  1. AI customer support automation: what to automate first, and what to keep

    AI customer support automation in order: triage, drafted replies, cited answers, account lookups, what stays with people, and measuring real resolution.

  2. Keeping a fleet of wearables synced through phones that want to sleep

    BLE reconnection, iOS background modes and Android Doze, device buffering, idempotent uploads, clock drift, and fleet monitoring that catches a quiet device.

  3. Building an evaluation set from past cases, and keeping it alive

    How we build and keep an evaluation set for an LLM feature: choosing cases, labelling with the people who do the job, size, and growing it from production.