---
title: "How I built this site: near-perfect PageSpeed, no framework"
description: "The practical recipe behind this site's PageSpeed scores: hand-written static HTML, self-hosted fonts, webp images and one _headers file, deployed free to Cloudflare's edge."
author: Stephen Sumner
date: 2026-06-17
modified: 2026-09-18
canonical: https://stephensumner.com/blog/how-this-site-is-built/
---

# How I built this site: near-perfect PageSpeed, no framework

*No WordPress, no JavaScript framework, no bundler. Hand-written static HTML, self-hosted fonts and images, one short script at deploy time and a single file of edge configuration — served from Cloudflare for free. The result is a clean 100 for SEO, accessibility and best practices. Here is the whole recipe, updated for what the platform can do in 2026.*

By Stephen Sumner · Published 2026-06-17 · Updated 2026-09-18 · Canonical: https://stephensumner.com/blog/how-this-site-is-built/

I tell clients to build fast, clean sites. It would be a poor look if my own were slow, so when I rebuilt this one I held it to the standard I hold theirs to: open it in Google's [PageSpeed Insights](https://pagespeed.web.dev/), run it, and don't ship until the numbers are honest. Here is exactly what they came back as — desktop and mobile, unedited.

PageSpeed Insights · stephensumner.com
Lighthouse · 17 June 2026

Mobile

![PageSpeed Insights mobile scores for stephensumner.com: Performance 93, Accessibility 100, Best Practices 100, SEO 100, Agentic Browsing 3 of 3.](https://stephensumner.com/blog/how-this-site-is-built/assets/pagespeed-mobile.webp)

Desktop

![PageSpeed Insights desktop scores for stephensumner.com: Performance 99, Accessibility 100, Best Practices 100, SEO 100, Agentic Browsing 3 of 3.](https://stephensumner.com/blog/how-this-site-is-built/assets/pagespeed-desktop.webp)

**The real numbers, not a cherry-picked screenshot.** A clean 100 for SEO, Accessibility and Best Practices on both, and a 3/3 pass on Agentic Browsing. The only score below 100 is Performance — 99 on desktop, 93 on mobile, where Lighthouse throttles to a slow connection and a weak CPU. I'll come back to that one honestly rather than pretend it's perfect.

None of this needed a clever trick. It came from a handful of unglamorous decisions, made in order, each of which I can hand to you below. If you build sites, you can copy the lot. If you'd rather not, that's a service I can potentially help you with.

## Fast is a subtraction problem

The single most useful idea in web performance is that speed is mostly about what you *leave out*. Every framework, every analytics tag, every web font, every "just one more" script is weight the browser has to fetch, parse and run before a visitor sees anything. Most slow sites aren't slow because of one big mistake; they're slow because of forty small additions nobody removed.

So the brief I set myself was almost rude in its simplicity: a personal site is text, a few images and a couple of small interactions. It does not need a render pipeline, a hydration step or a 200 KB JavaScript bundle. Strip the project back to what the content actually requires, and the scores mostly take care of themselves.

Most slow sites aren't slow because of one big mistake. They're slow because of forty small additions nobody removed.

0

JS frameworks

~8 KB

Total site JS

0

Third-party requests

3×100

SEO · A11y · Best Practices

## Step 1 · Build it as static HTML

There is no CMS behind this site and no framework in front of it. Each page is a hand-written `.html` file with the critical CSS inlined in the `<head>`, so the browser can render the page from the very first response without waiting on a separate stylesheet. The markup is semantic — real `<header>`, `<nav>`, `<main>`, `<article>` and `<footer>` landmarks rather than a soup of nested `<div>`s — which helps screen readers, search crawlers and AI extractors alike find the substance.

I designed and assembled the whole thing inside Claude, iterating on layout and copy as editable HTML rather than mocking it in a design tool and rebuilding it later. That kept one source of truth: what I previewed is byte-for-byte what deployed. The only JavaScript is a single `site.js` of roughly eight kilobytes, loaded with `defer` and written with no inline handlers — a deliberate choice, because keeping all script in one external file is what lets the site ship a strict Content-Security-Policy without tripping over its own code.

- **Inline the critical CSS** in the head; don't block first paint on an external stylesheet for a page this small.
- **Use semantic landmarks** so the document structure is legible to machines, not just to humans.
- **One small, deferred script**, no inline JavaScript, so a tight CSP is possible later.

## Step 2 · Self-host the fonts

Web fonts are the most common quiet performance tax I see. Pulling them from a third-party host means an extra DNS lookup, an extra connection, and a render-blocking request to a domain you don't control — plus, post-*Schrems II*, a genuine GDPR question about leaking visitor IPs to that host.

So all three families here (Newsreader, Hanken Grotesk and JetBrains Mono) are self-hosted as `woff2` variable fonts, subset to the Latin ranges the site actually uses, and served from the same origin. Each face is declared with `font-display: swap` so text is visible immediately in a fallback while the webfont loads, and the two faces needed above the fold are `preload`ed so they arrive early. No Google Fonts request, no layout surprise, no data leaving the origin.

Two lines in the <head> — preload the above-the-fold faces

```
<link rel="preload" as="font" type="font/woff2" href="/fonts/hanken.woff2" crossorigin>
<link rel="preload" as="font" type="font/woff2" href="/fonts/newsreader.woff2" crossorigin>
```

Subsetting the glyphs turned out to be only half of it. A variable font also carries *axes*, and Newsreader shipped with weight 200–800 and optical size 6–72 when the CSS only ever asks for 300–700 at heading sizes. Re-instancing each file to the range the CSS declares took the preloaded Newsreader from 132 KB to 90 KB with no visible change. The other half is the swap itself: `font-display: swap` shows a fallback first, and when the webfont lands the text reflows. A fallback `@font-face` with `size-adjust` and ascent and descent overrides measured from the real font makes Georgia occupy exactly the space Newsreader will, so the swap moves nothing.

Metric-matched fallback — measured from the font file, not guessed

```
@font-face {
  font-family: 'Newsreader Fallback'; src: local('Georgia');
  size-adjust: 98.8%; ascent-override: 74.4%; descent-override: 26.8%; line-gap-override: 0%;
}
--font-display: 'Newsreader', 'Newsreader Fallback', Georgia, serif;
```

## Step 3 · Ship images as sized webp

Images are usually the heaviest thing on a page, so they get the most discipline. Everything here is `webp` — markedly smaller than JPEG or PNG at the same quality — and every `<img>` carries explicit `width` and `height` attributes. That second part matters more than people expect: when the browser knows an image's dimensions before it loads, it reserves the space, so nothing jumps as the page fills in. That's how you keep Cumulative Layout Shift near zero, which is a direct Core Web Vitals input.

- **`webp` everywhere**, sized to the largest box each image is shown in — no shipping a 2000px photo into a 400px slot.
- **Explicit `width` and `height`** on every image, so the layout never shifts (the screenshots above included).
- **`loading="lazy"` and `decoding="async"`** on anything below the fold, so off-screen images never delay the first paint.

## Step 4 · Deploy to Cloudflare

Because the output is just static files, hosting is almost embarrassingly simple. There's no server to run, no database, nothing to patch. I deploy to [Cloudflare Pages](https://pages.cloudflare.com/), which serves the files from a global edge network — so the site loads from a data centre near the visitor rather than from one origin box — with automatic HTTPS, HTTP/3 and a free tier that comfortably covers a site like this.

Getting it live is genuinely a drag-and-drop. In the Cloudflare dashboard you create a project and choose *Upload your static files*, point your custom domain at it, and you're done. That is how this site ran for its first three months, and it is still the right first step. In September 2026 it moved to a GitHub repository with a single Python script that runs at deploy, because a few of the things in Step 7 — re-instancing the fonts, generating a markdown twin of every post, writing the feeds — can't be hand-written into HTML. The exported HTML is still the HTML that ships; the script only adds the files it couldn't write itself.

![The Cloudflare dashboard 'Ship something new' panel, showing options to connect GitHub or GitLab, start with Hello World, select a template, or upload your static files.](https://stephensumner.com/blog/how-this-site-is-built/assets/cloudflare-deploy.webp)

**No pipeline required.** A static site is just a folder. In Cloudflare, `Upload your static files`, point the domain at it, and it's served from the edge worldwide — for free, with HTTPS and HTTP/3 included.

## Step 5 · One `_headers` file for caching and security

This is the highest-leverage file on the whole site, and it's about fifty lines long. Cloudflare Pages reads a plain `_headers` file at the project root and applies the rules to matching responses. It started with two jobs and now does five.

First, **caching**: fonts, images and other assets get a one-year, `immutable` cache, so a returning visitor re-downloads almost nothing. Second, **security headers**: a strict Content-Security-Policy that only allows scripts from the site's own origin, plus HSTS, `X-Content-Type-Options`, a sensible `Referrer-Policy` and a locked-down `Permissions-Policy`. Those headers are most of what a "Best Practices" audit looks for, and they cost nothing but a few lines.

Third, **103 Early Hints**: the same two font preloads, sent as `Link` headers so Cloudflare can push them before the HTML body has even arrived. Fourth, **prerendering**: a `Speculation-Rules` header pointing at a small JSON file tells Chrome to prerender a link the moment you hover it — done as a header rather than an inline script so the strict CSP stays intact. Fifth, a `rel="canonical"` `Link` on each post's markdown twin, pointing back at the HTML page, so search engines never see two versions competing.

\_headers — excerpt

```
# Cache static assets hard — a year, immutable
/assets/*
  Cache-Control: public, max-age=31536000, immutable
/fonts/*
  Cache-Control: public, max-age=31536000, immutable

# Security headers on every response
/*
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
  Content-Security-Policy: default-src 'self'; script-src 'self'; ...

# Early Hints + prerender — headers, not scripts
  Link: </fonts/newsreader.woff2>; rel=preload; as=font; crossorigin
  Speculation-Rules: "/speculation.json"

# Each post's markdown twin points back at the page
/blog/how-this-site-is-built/index.html.md
  Link: <https://stephensumner.com/blog/how-this-site-is-built/>; rel="canonical"
```

## Step 6 · The plumbing that earns the 100

A perfect SEO score in Lighthouse isn't a ranking promise — it's a checklist confirming the page is technically legible to crawlers. It's worth getting to 100 because it means none of the basics are silently broken. Here's what carries it:

- **A self-referencing `canonical`** on every page, a unique `<title>` and a meta description in the right length band.
- **Valid JSON-LD structured data** — `BlogPosting`, `Person` and `Organization` — that matches what's visibly on the page, never contradicts it.
- **A real `sitemap.xml`, `robots.txt` and an `llms.txt`**, so both search crawlers and AI systems get a clean map of the site.
- **Open Graph and Twitter card tags**, descriptive `alt` text on every image, and legible tap targets and contrast.
- **A markdown twin of every post**, linked with `rel="alternate" type="text/markdown"` and served directly when a client sends `Accept: text/markdown` — the link relations my [llms.txt v2](https://stephensumner.com/blog/llms-txt-v2/) post recommends, done properly on my own site.
- **RSS and JSON feeds**, a `Content-Signal` line in `robots.txt` stating what search and AI systems may do with the content, and a `security.txt`.

If the honesty of that structured data sounds like a small thing, it isn't — it's the spine of how AI search decides whether to trust a page. I went deep on exactly that in [the 120-point AI readiness framework](https://stephensumner.com/blog/ai-readiness-framework/); this post is the technical floor that sits underneath it.

## Step 7 · Use what HTML can do in 2026

The most interesting changes since June cost no JavaScript at all. Browsers have shipped a run of features that give a plain multi-page site the feel people used to reach for a framework to get, and each one is a few lines of CSS or a header that older browsers simply ignore.

- **Cross-document view transitions.** One at-rule gives a crossfade between pages, and naming the nav bar holds it steady while the content changes underneath. Wrapped in `prefers-reduced-motion` so it respects the visitor's settings.
- **A scroll-driven progress bar.** The reading-progress bar used to be a scroll listener in `site.js`. It is now a CSS animation on the document's scroll timeline, off the main thread; the script only runs where the browser lacks it.
- **`content-visibility: auto`** on the blocks below the fold, so the browser skips laying them out until they approach the viewport.
- **Prerender on hover** via the `Speculation-Rules` header from Step 5: by the time you click a link to another post, it has usually already rendered.

Three lines — cross-document view transitions, no JavaScript

```
@media (prefers-reduced-motion: no-preference) {
  @view-transition { navigation: auto; }
  .nav { view-transition-name: site-nav; }
}
```

## The one score that isn't 100

Everything except Performance comes back a perfect 100 — SEO, Accessibility and Best Practices alike — and Agentic Browsing, Lighthouse's newest category, passes 3/3. That last one checks whether an AI agent can actually read and operate the page: layout stability, a clean accessibility tree, properly labelled controls. The only number below 100 anywhere is Performance: 99 on desktop, 93 on mobile.

I'll be straight about that 93 rather than crop it out. Lighthouse's mobile run deliberately simulates a mid-range phone on a slow connection with a throttled CPU — a punishing, worst-plausible-case scenario, not the device most visitors hold. A 93 under those conditions is a strong, honest result. I could likely nudge it to 100 by inlining or deferring a little more, but past a point you're optimising for the test rather than the person, and the page already paints almost instantly on real hardware.

The screenshots and the 93 above are the June run. The September changes — smaller fonts, Early Hints, the CSS progress bar — should move the mobile number, and I'll re-run the test and replace them here once the edge cache has turned over, rather than quote a figure I haven't measured.

The principle

**A score is a guide, not a boss.** I'd rather ship a transparent 93 on a deliberately harsh mobile test than chase a round number with changes that help the lab and not the person holding the phone. The 3/3 on Agentic Browsing matters more to me anyway — it's the same readiness I score client sites on in [the AI readiness framework](https://stephensumner.com/blog/ai-readiness-framework/), and it's the part most sites will be caught out by next.

That's the whole recipe. Static HTML, self-hosted fonts, sized webp, a static host, fifty lines of edge config, one script at deploy, and the SEO basics done properly. None of it is exotic. The hard part isn't knowing the steps — it's the discipline to keep leaving things out.
