React as a dev dependency

Sep 2026

TL;DR This site, as a plain React app, took seconds to load on bad Wi-Fi. I wanted to see how much faster and smaller I could make it. Turning every page into HTML at build time made it fast: text shows up in 0.36 seconds instead of 2.3. Not sending React to the browser made it small: 1 KB of JavaScript instead of 69. I still write the site in React. You just never download it.

I rebuilt my portfolio recently. It’s a pretty light site, nothing that needed heavy artillery. So I reached for my sweet, minimal, comfy stack: React and Vite. I ported my blog posts over, shipped it, and moved on.

A few days later, I opened it on my phone. I tapped a post and waited.

And waited. A couple of seconds, for a page of text.

The reason wasn’t a mystery. My room is the furthest one from the router, so in there I normally use mobile data. This time I’d forgotten to turn the Wi-Fi off. But a page of text shouldn’t need good Wi-Fi.

But it reminded me of a post I’d read a while ago: Why your website should be under 14kB in size. If you’ve never heard why 14 KB is special, go read it. I’ll wait.

That post had been sitting in the back of my head ever since. Now I had an excuse.

How small could I make this site?

This post is my answer. It’s also how I ended up with React as a dev dependency.

An empty div

Let’s look at what my phone actually got when I tapped that post:

<body>
  <div id="root"></div>
  <script type="module" src="/assets/post.js"></script>
</body>

That’s it. That’s the whole page. Everything you’re reading right now, the title, these paragraphs, the code blocks, wasn’t in it. It was sitting inside a JavaScript file, waiting to be downloaded and run. Only then could React build the page and put it on the screen.

This is how most React apps work, and usually nobody notices. On good Wi-Fi the JavaScript arrives almost instantly. For my last post, the browser had to download 74 KB (gzipped) before it could show you a single word. 69 KB of that was JavaScript, and 58 KB of the JavaScript was React itself.

Drawing it once

I set out to make the site small. But the thing that actually annoyed me on my phone wasn’t the size. It was the blank screen. Once there’s text in front of you, everything else can take its time.

So I started there: how do I get text on the screen sooner?

Rather than sending you an empty div and making your phone build the page, I could send you the finished page. It’s the same for everyone. It doesn’t change based on who’s asking for it. So I can generate it once, when I run npm run build, and save it as an HTML file. Every reader gets that same file.

Instead of an empty div, the page arrives with everything inside:

<body>
  <div id="root">
    <main>
      <h1>React as a dev dependency</h1>
      <p>Sep 2026</p>
      <article>
        <p>I rebuilt my portfolio recently…</p>
        …
      </article>
    </main>
  </div>
  <script type="module" src="/assets/post.js"></script>
</body>

This idea is older than React. Jekyll was generating pages ahead of time back in 2008, five years before React came out, and newer static site generators like Hugo and Astro still work the same way today.

React can do it too. It ships a function for exactly this, renderToString, in react-dom/server. You give it a component, and it gives you back a string of HTML:

function Post() {
  return (
    <article>
      <h1>Hello</h1>
      <p>This is my post.</p>
    </article>
  );
}

renderToString(<Post />);

Which gives you back:

<article><h1>Hello</h1><p>This is my post.</p></article>

If you’ve used Next.js’s static export, this is the same kind of function it calls under the hood. Next.js just wraps it in a lot more machinery. I only needed the function. My whole setup is one small Vite plugin: for each post in a list, it calls renderToString and saves the result as that post’s HTML file.

That string goes straight into the empty <div id="root">. Now when you tap a post, the text arrives with the page itself. For that same post, it’s 13.4 KB before you can start reading, instead of 74.

It worked. The text showed up almost as soon as I tapped. But I’d broken something.

The part that stopped working

My site isn’t just text. There are two small things on it that need JavaScript: a card that follows your cursor when you hover over a post on the home page, and an “On this page” outline that keeps track of the section you’re reading. Here they both are, working:

The hover card
Hover over the link, or tap it on a phone. Then switch to HTML only.
The outline
On this page
An empty div
Drawing it once
The part that stopped working
Two things that move
Scroll inside the box, or click a heading. Then switch to HTML only.

Now switch either one to “HTML only”. That’s what the pre-rendered page gave me: the card never shows up, and the outline just sits there.

To see why, look at the string renderToString gave back earlier:

<article><h1>Hello</h1><p>This is my post.</p></article>

There are tags and there’s text. There’s no onMouseEnter. No useState. Nothing that says “follow the cursor” or “watch the scroll”. That code lived in my components, but it didn’t make it into the string. It couldn’t. A string of HTML can say what a page looks like. It can’t say what the page does.

There’s a simple picture of how a web page works that most of us learned on day one and quietly outgrew: HTML is the skeleton, CSS makes it look good, JavaScript makes it move. It’s too simple for most things, but in our case the analogy fits just right. renderToString gave me a skeleton, and a skeleton doesn’t move.

To bring them back, I did what React sites usually do. I kept sending React to the browser, but gave it a different job.

Before, React had two jobs in the browser: build the skeleton, then make it move. Now the skeleton arrived already built, so React only had to make it move.

React has a separate way to start for exactly this. You’ve probably seen createRoot, the usual way a React app starts in the browser. It builds everything inside #root from scratch. Its sibling, hydrateRoot, starts from HTML that’s already there:

hydrateRoot(document.getElementById("root"), <Post />);

That line tells React: the HTML inside #root was made from <Post />, so don’t build it again. React still runs your components in the browser, but only to work out which piece of HTML belongs to which component. Then it attaches everything the string couldn’t carry: the onMouseEnter on the link, the state, the code that watches the scroll.

This is called hydration. The name fits: the page arrives dry, and React adds the water that brings it back to life.

Here’s what bugged me. The text was fast now, but the page still downloaded the same 69 KB of JavaScript. And React didn’t just wake up the two features. It re-ran every component on the page, including every paragraph of text, all of which were already correct and would never change.

Sixty-nine kilobytes, to bring back a hover card and a list of links.

Did the browser even need React?

Two things that move

A hover card and an outline are the only things on this site that move. Neither of those needs a framework. A framework earns its keep when a page is full of things that change and depend on each other. Mine has two small things that don’t even know the other one exists.

I’m not the first person to notice this. Astro has a name for it: islands. Most of the page stays plain HTML, and only the small interactive pieces, the islands, get hydrated. I took that one step further. My islands are so small that they don’t need React at all. They became two tiny plain scripts, one for the home page and one for posts.

Each script starts by asking whether it should run at all. A card that follows your mouse is pointless on a phone, so the home page script checks for a mouse first:

const hasMouse = matchMedia(
  "(hover: hover) and (pointer: fine)",
).matches;

if (postLinksWithCover.length > 0 && hasMouse) {
  setUpHoverCard(postLinksWithCover);
}

On a phone, that’s false. Nothing gets set up, and a tap is just a tap. The outline works the same way. It’s now rendered to HTML along with the rest of the page, and its script only runs on screens wide enough to show it.

That left React with nothing to do in the browser. So it moved to where build tools live:

"devDependencies": {
  "react": "^19.2.8",
  "react-dom": "^19.2.8",
  "tailwindcss": "4.3.3",
  "vite": "^8.2.2",
  ...
}

dependencies is empty now. I still write every page as a React component. You just never download React to read it.

So why not just write the site in plain HTML, CSS and JavaScript? I could. But that isn’t much fun. I like splitting a page into small components and building it up from there. I wanted writing the site to feel like React, and the finished site to be as light as plain HTML.

Two small extras

While I was at it, I added two tricks I couldn’t resist.

The first one is about the moment before you open a post. Chrome can start loading a page before you even click on it, with something called speculation rules. It’s one small script tag in the page’s head:

<script type="speculationrules">
{
  "prerender": [
    {
      "where": { "href_matches": "/writing/*" },
      "eagerness": "moderate"
    }
  ]
}
</script>

It tells Chrome: if someone looks like they’re about to open a post, start loading it now, in the background. With a mouse, that’s when you rest the cursor on a link. On a phone, Chrome makes a guess: when you stop scrolling, it starts loading the links near where you last touched the screen. Either way, by the time you click, the post is often already loaded, so it shows up right away.

Browsers that don’t support it just ignore the tag. Nobody gets anything worse, and people on Chrome get something better.

The second one is about font flicker. This site uses a font called GT Walsheim, and it takes a moment to download. The text doesn’t wait for it. You see Arial first, and the real font swaps in when it arrives. But the two fonts are different sizes, so when they swap, the whole page jumps. To stop that, I resized Arial to take up exactly the same space as GT Walsheim:

@font-face {
  font-family: "GT Walsheim Fallback";
  src: local("Arial");
  size-adjust: 101.62%;
  ascent-override: 96.44%;
  descent-override: 25.19%;
}

Now when the real font shows up, the letters change shape, but nothing on the page moves.

Nice. But did any of this actually make a difference?

Counting bytes

To find out, I built all three versions of the site and loaded the same page on each one. Not this post, since this one didn’t exist yet, but my last post. To recreate my room, I slowed everything down: 300 ms for every trip to the server and back, a 1 Mbps connection, and a phone four times slower than my laptop.

Here’s what each column means. Before the first word is everything the browser has to download before it can show you any text: the HTML, the CSS, and for the plain React app, all of its JavaScript too. JavaScript is how much JavaScript the page downloads in total. Total downloaded is everything the page downloads, apart from images and fonts. (Fonts are actually the biggest download, about 144 KB, but the text doesn’t wait for them, and they’re a story for another day.) Text on screen is how long it takes, after you open the post, for the first paragraph to appear.

VersionBefore the first wordJavaScriptTotal downloadedText on screen
Plain React app74 KB69 KB77 KB2.3 s
Rendered at build time, then hydrated13.4 KB69 KB85 KB0.36 s
Rendered at build time, no React in the browser14.1 KB1 KB17.5 KB0.36 s
All sizes are gzipped.

Look at the last two rows. The text shows up at exactly the same time. (The final version even downloads a little more before that first word, 14.1 KB instead of 13.4 KB, because the outline is part of the HTML now.)

Wait. So the last step didn’t help?

It did, just not with the first word. With hydration, the page kept downloading 69 KB of JavaScript after the text was already on screen. Without React, it downloads 1 KB, and the whole page comes in at a fifth of the size.

That still matters after the text shows up. On mobile data, every kilobyte is one you pay for. And your phone has to run all that JavaScript once it arrives, so the page isn’t really done until it has. (And when you open a post from the home page, Chrome’s head start gets the text on screen in 0.21 seconds.)

That surprised me a little. I thought fast and small were the same thing. Rendering at build time is what made the page fast. Leaving React behind is what made it small.

So, did I get it under 14 KB? Almost. The final version sends 14.1 KB before the first word, and with the extra bits the server sends along with every file, it’s about 15.4 KB. Just over the line. I’m calling it close enough.

None of this is new

Static site generators have been around for as long as websites have had build steps. Islands come from Astro and the people before it. The 14 KB rule is just how the internet has sent data for years. None of this is mine. I did it mostly out of curiosity, to see how small my own site could get, and because it was fun to find out.

It isn’t free. Going from one page to another is a normal page load, with no clever routing in between. The interactive bits are written by hand instead of as components. That’s fine for a hover card and an outline. If this site ever gets a lot more interactive, I’ll reach for islands properly.

But for now, I like the split. React is how I write the site. HTML is what you get. I get to keep one without making you download the other. Pretty cool.