Back to writing

27 June 2026

How little a website actually needs

We built this site to ship as little to the browser as we could. It has one client component, a cookie banner, and everything else renders on the server. The production bundle is 3.2KB of first-party JavaScript.

That was a choice, not a limit. We checked three marketing sites in our own space before starting: they shipped 210KB, 340KB, and 190KB of JavaScript to render text that never changes. We didn't want to be a fourth.

The whole stack is Next.js on the App Router, every page prerendered to static HTML at build time, React running on the server only, Tailwind for styling. That's the entire list. No state library, no data-fetching client, no UI kit.

What we cut

No client state. A marketing page has none worth hydrating. The nav doesn't need useState. The footer doesn't need an effect. We wrote both as plain server components and never reached for the client.

No CMS. Copy lives in one content.json; the writing lives in markdown files in content/writing/posts/. Editing is a text change and a redeploy, not a database and an admin panel. There are 10 posts and they're 10 files.

The markdown-to-HTML step runs at build time, not in the browser. Each post is a .md file with a frontmatter block — title, slug, date, tags, excerpt — and a body. At build the generator reads the directory, parses the frontmatter, compiles the body to HTML, and writes one static page per slug:

const files = fs.readdirSync("content/writing/posts");
const posts = files.map((f) => {
  const { data, content } = matter(fs.readFileSync(f, "utf8"));
  return { ...data, html: markdownToHtml(content) };
});
// posts sorted by date -> one prerendered page each + one index

The browser never sees markdown, never runs a parser, never fetches a post over the network. The compile happens once, on my machine, and the output is HTML on a CDN. Adding an 11th post is dropping an 11th file in the folder and redeploying — no migration, no admin login, no row to insert.

No webfont round trip. The 3 fonts are self-hosted through the framework's font loader, so the browser never calls fonts.googleapis.com. One fewer DNS lookup, one fewer third party that can hang the first paint.

The one exception

There's exactly one "use client" in the codebase, on the cookie banner. It has to be interactive: read consent from localStorage, load analytics only after you accept, remember the choice so it doesn't ask twice. That's the 3.2KB.

That's the rule, not the violation. You reach for a client component when a user does something. A consent choice is something a user does. Text on a page is not, so it stays on the server.

The consent logic is small on purpose. On mount the banner reads one localStorage key. If it's set, the banner doesn't render at all and analytics loads or stays off based on the stored value. If it's unset, the banner shows, and clicking accept or reject writes the key and loads analytics only on accept:

const KEY = "consent.analytics";
const stored = localStorage.getItem(KEY);   // "granted" | "denied" | null

function decide(granted: boolean) {
  localStorage.setItem(KEY, granted ? "granted" : "denied");
  if (granted) loadAnalytics();             // injected only now
  setVisible(false);
}

Analytics is not in the bundle. It's loaded by injecting a script tag inside loadAnalytics, and that only runs after an explicit accept. A visitor who never accepts never downloads the analytics script at all — the code path that fetches it never executes. That's a stronger guarantee than lazy-loading: lazy-loading defers the fetch, this skips it entirely for anyone who doesn't opt in. The 3.2KB is the banner and the consent read. The analytics payload sits behind the accept click, and most visitors never trigger it.

What that bought

Every route is a file on a CDN. No server runs to answer a request, so there's nothing to scale and nothing to page anyone at 2am. The static HTML caches at the edge and paints before any script runs, and there's no hydration flash because there's almost nothing to hydrate.

We ran Lighthouse on the deployed build to check we weren't fooling ourselves: performance 100, first contentful paint at 0.6s on a throttled connection, total blocking time 0ms because there's no long task to block on. The blog index, which lists all 10 posts, is a single prerendered file — no client-side list rendering, no fetch on load, the markdown compiled to HTML at build time.

To be exact about it: the framework still ships a small routing runtime, and the cookie banner adds its few KB on top. We measured 3.2KB of our own code and the rest is the framework floor. We added no state libraries, no analytics until you switch it on, nothing beyond React and Tailwind.

The edge caching is what makes the static-HTML choice pay off at request time. Every route builds to a .html file, and those files sit on the CDN's edge nodes. A request for a page never reaches an origin server — it's served from the nearest edge location, and the cache-control header is long because the content only changes on deploy. A redeploy invalidates the cache; between deploys, every request is a cache hit against a file that was already sitting close to the user. There's no cold start, because there's no function to start. There's no origin round trip, because the origin's job ended at build time.

The blog index makes this concrete. Listing all 10 posts could be a client fetch — load the page, request the post list, render it. Instead the index is prerendered with the 10 post links already in the HTML, sorted by date at build time. The browser gets a complete page in one request. No loading spinner, no fetch-on-mount, no second round trip for the list that's the whole point of the page.

The tradeoff, honestly

This works because the site is content, not an app. The moment we need a real form, a search box, or a logged-in view, we reach for more client code and pay for it. That's fine — you pay for interactivity where you have it, not everywhere by default. We just don't have it yet, on any of these 10 pages.

The common mistake is starting from a heavy client app and trimming down. Trimming is hard — you're removing things and hoping nothing depended on them, and the bundle analyzer never quite gets to zero because some transitive dependency pulls in a client runtime you can't see. We started from static HTML and added client code only at the one spot a user does something, so there was never anything to trim. Most marketing sites never reach a second spot.

The day we need a form is the day this changes, and it's worth being precise about what changes. A contact form needs a client component to hold field state and validate, and an endpoint to receive the submission. That's one more "use client" island and one serverless function — not a rewrite. The rest of the site stays static HTML on the edge. The form is a bounded island of interactivity on an otherwise static page, the same way the cookie banner is. We'd add the endpoint, add the island, wire the two, and every page that isn't the contact page keeps shipping zero client code beyond the banner.

The failure mode to avoid there is letting the form pull in a form library, a validation library, and a state manager, at which point the 3.2KB becomes 80KB for one page with three inputs. A form with three fields needs useState and a fetch, both already in React. We'd write it by hand before we'd add a dependency, for the same reason we wrote the consent read by hand — the moment you reach for a library to hold three strings, the bundle is no longer something you control.

If you're building one

Write server components until something has to be interactive. Keep copy in files until a non-engineer needs to edit it. Self-host the fonts. Prerender everything you can. Then look at what's left — for us it was 3.2KB, which is much less than the 200KB the sites we checked were shipping by default.