Skip to content
All articles

How this site is built: static where it can be, streaming where it must

This site is also a reference project. It has three languages, dozens of pages, more than a dozen interactive tools and an AI guide that answers while it writes. It scores 100 in all four Lighthouse categories on mobile and runs on the free tiers of Azure Static Web Apps and the Gemini API. None of that needs an exotic stack: React, Vite, a prerender step and a handful of checks that refuse to deploy anything broken. Here is how the pieces fit, including two things I only found out by measuring.

10 min read

1. One source of truth, many outputs

Articles, service pages, projects and tools live as typed data in the repository, one file per article and per service. The build turns that data into everything a reader or a machine needs: the pages themselves, an index for lists, a sitemap with all three language versions of every URL, an RSS feed per language, a share image per article and language, the llms.txt summary for AI assistants, and the knowledge file the AI guide answers from.

The last two used to be maintained by hand, and they drifted the first time a product was renamed. Now a script regenerates them from the same data on every build, and a check fails if the committed version doesn't match. Nothing gets written twice, so nothing can disagree.

json
// package.json
"build": "node scripts/gen-posts-meta.mjs && node scripts/gen-knowledge.mjs
          && tsc --noEmit -p tsconfig.app.json && vite build
          && node scripts/sitemap.mjs && node scripts/prerender.mjs"

2. Prerender every page, in every language

The site is a React app, but nobody downloads an empty shell. After the Vite build, a script starts the built site locally, opens every route in every language in a headless Chrome and saves the finished HTML to its own file: every page times three languages. Each has its own title, description, canonical URL, hreflang links to the other two languages and structured data for articles, FAQs and breadcrumbs. The same run screenshots a share image per article.

Languages live in the path: English at the root, German under /de, Spanish under /es. Azure Static Web Apps serves each prerendered file directly through rewrite rules the build generates, and a guard fails the build if two rules ever collide, which happened once with /de and /de/. For crawlers this is ordinary static HTML; for people the React app takes over and every click after that is instant.

One repository, one pipeline: static HTML for every page and language, one streaming function for the AI guide, and checks before and after every deploy.
Architecture of this site: content data in the GitHub repository is built by GitHub Actions (generate data, type check, Vite build, sitemap, prerender every page with headless Chrome, end-to-end tests) and deployed to Azure Static Web Apps; visitors get static HTML per language with an inline language redirect, and the AI guide calls a managed Azure Function that streams answers from Gemini 3.5 Flash with Flash-Lite as fallback; after deploys IndexNow is notified, and scheduled jobs check pages hourly and links weekly.

3. Don't waste the first paint

Three measurements changed the code. First, German and Spanish visitors on the home page were redirected to /de or /es only after the 150 KB main bundle had loaded, which cost a second on mobile. The redirect now runs as a ten-line inline script at the top of the HTML. The Content Security Policy allows exactly that script by its SHA-256 hash, and the build fails if script and hash ever drift apart.

Second, React used to replace the prerendered page with an empty placeholder until the page's code chunk arrived, so the content blinked and Lighthouse counted the second paint. Now the browser loads that chunk, and for articles the article itself, before React mounts, and the prerendered HTML stays on screen until React shows the same thing. Third, the web font swapped in after the first paint and nudged the text. With the font preloaded and font-display set to optional, it is either there in time or not used for that visit. Together this took the largest paint on mobile from about 2.5 seconds to 1.1 on the home page.

html
<!-- index.html: runs before CSS, fonts and the app bundle -->
<script>
  (function () {
    var l = location, p = l.pathname, s = new URLSearchParams(l.search), q = s.get("lang"), w;
    if (/^\/(de|es)(\/|$)/.test(p)) return;
    var ok = function (x) { return x === "en" || x === "de" || x === "es"; };
    try { w = ok(q) ? q : p === "/" ? [localStorage.getItem("dominik-lang"), (navigator.language || "").slice(0, 2)].filter(ok)[0] : ""; } catch (e) {}
    if (w !== "de" && w !== "es") return;
    s.delete("lang");
    var r = s.toString();
    l.replace("/" + w + (p === "/" ? "" : p) + (r ? "?" + r : "") + l.hash);
  })();
</script>
<!-- staticwebapp.config.json: script-src 'self' 'sha256-…' -->

4. An AI guide that streams on a static host

The guide at the bottom right is my own chat engine, the one behind the product Helptempo: a web component in a Shadow DOM, so the site's CSS can't break it, and one host-neutral server handler. On this site the handler runs as a managed Azure Function. It answers from the generated knowledge file plus the headings of the page the visitor is on, with Gemini 3.5 Flash and Gemini 3.5 Flash-Lite as fallback, which only steps in before the first word has been sent. Pre-written answers make the suggested starter questions instant, and a per-visitor rate limit keeps the free key alive.

Streaming needed the Azure Functions v4 programming model with HTTP streams switched on. The surprise was a single header: with Content-Type 'text/event-stream; charset=utf-8', Azure Static Web Apps held the whole answer back until it was complete; with exactly 'text/event-stream', every piece arrived as it was written. I isolated it with eight variants on a preview environment before touching production. Now the first words appear after about 1.2 to 1.5 seconds instead of the full answer after 2.5 to 3.

js
// api/chat/index.js — Azure Functions v4 on Static Web Apps
const { app } = require("@azure/functions");
app.setup({ enableHttpStream: true });
app.http("chat", { methods: ["GET", "POST"], authLevel: "anonymous", route: "chat",
  handler: async (req) => (await chat).azureFunctions(req) });

// in the engine: exactly this content type, no charset suffix
// "Content-Type": "text/event-stream"

5. Checks that refuse to deploy

Every push to main builds the site, type-checks it and runs end-to-end tests against the built files before anything is deployed: every route in every language at phone and desktop width must have exactly one h1, no horizontal scrolling, no broken images and no console errors. The questionnaire, the language switch, the shared tool links and the boot sequence get their own checks, including one that slows the JavaScript down and fails if the prerendered heading ever disappears. The chat API has its own checks, and the AI guide's knowledge is tested against 30 visitor questions with facts it must and must not say.

After the deploy, the site tells IndexNow which URLs changed. Outside of deploys, a scheduled job checks every hour that all pages, articles, guide PDFs, feeds and the chat answer, and a weekly job checks every source link and video in the articles. A broken link or a dead page turns a workflow red and lands in my inbox before a visitor finds it.

6. What it costs, and what I'd reuse

Hosting is the Free plan of Azure Static Web Apps with its managed functions, and the guide runs on a free Gemini key with a fallback model, so the running cost is close to zero. The trade-offs are honest ones: the free model quota sometimes runs out during the day and the fallback answers, and a static host means the contact form posts to a form service instead of my own backend.

What I'd reuse for any company site: prerender instead of server rendering when content changes a few times a day, generate every machine-readable file from one data source, measure before optimising, and let the pipeline refuse to deploy rather than relying on people to remember the checks. The chat engine is reusable by design; that's why it became a product.

Takeaway

A fast, multilingual site with a live AI guide doesn't need a server farm: prerender everything, protect the first paint, stream only what has to stream, and let the pipeline say no.

More articles

    Get in touch

    Tell me what you're building. I'll reply within 1–2 business days.

    Dominik Heinrich
    Company
    MADO Consulting

    Available worldwide, remote. On-site on request, depending on the country.

    I reply within 1–2 business days. Free slots: see the calendar

    Opens Calendly's booking calendar here. Loading it sends data to Calendly (see Impressum).