Blog

The Case for Vanilla

This site has no package.json. No build step, no framework. Just a few HTML files, one stylesheet, a bit of vanilla JS, and some self-hosted fonts. I push to master and Cloudflare serves it.

I'm not trying to make a point with that. It's just the right tool for this particular job. But I think it's worth talking about why, because I keep seeing developers reach for Next.js or Astro for sites that genuinely don't need it. And there's a rule of thumb that I keep coming back to: you can always add tooling later. You can't easily remove it.

Frameworks are great, actually

I should say upfront: I write React and Next.js at work every day. I like these tools. They exist because building interactive applications with raw DOM manipulation gets painful once you have real, interconnected state to manage. Routing, code splitting, server rendering, all of that. Frameworks give you solid answers to hard problems.

The thing is, a personal site with a photo, a few links, and a paragraph of text is not a hard problem. And when the problem is simple, a framework doesn't simplify it. It just adds moving parts. Even lightweight tools like Astro or 11ty, which are genuinely great for larger static sites, come with a build step, a config file, and a dependency tree you now have to maintain. For a single page, that's overhead I didn't need.

What you get from zero dependencies

The obvious one is that there's no supply chain. No node_modules, no lockfile drift, no Dependabot PRs about some transitive dependency you've never heard of. The site I deployed works the same today as when I first pushed it, because there's nothing to go stale.

The less obvious one is that you actually understand everything. When I wanted a 3D tilt effect on my photo, I didn't install a library. I wrote about 30 lines of JavaScript. The core of it is just linear interpolation in a requestAnimationFrame loop:

const lerp = (a, b, t) => a + (b - a) * t;

const tick = () => {
  cx = lerp(cx, tx, 0.1);
  cy = lerp(cy, ty, 0.1);
  photo.style.transform =
    'perspective(600px) rotateY(' + cx + 'deg) '
    + 'rotateX(' + cy + 'deg) scale3d(1.03, 1.03, 1.03)';
  raf = requestAnimationFrame(tick);
};

A lerp at 10% per frame. That's it. It feels smooth and physical, and the whole thing minifies down to less than a kilobyte. Even a lightweight animation utility would add several kB of bundle size plus a build dependency for something you can write yourself in an afternoon.

CSS does a lot now

A lot of things that used to need JavaScript are just CSS now. Scroll-driven animations, container queries, :has() as a parent selector. This site doesn't use all of those, but it does use clamp() for fluid spacing, text-wrap: balance for nicer line breaks, and custom properties for theming. No preprocessor, no utility classes.

The staggered reveal on page load? One keyframe rule and a custom property for the delay:

[data-reveal] {
  animation: reveal 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
  animation-delay: calc(var(--d, 0) * 1ms);
}

Each element gets a --d value in its inline style. The browser handles the rest. No orchestration needed.

Simple i18n without a library

The default advice for i18n in the React world is to reach for a library, and for good reason. If you need pluralization rules, date formatting across locales, or RTL support, something like react-i18next earns its weight fast. But for two languages with simple string translations? You can get away with a lot less. The homepage on this site does English and German with a pretty simple setup: each translatable element has a data-i18n attribute, a plain object holds both translations, and one function swaps the text while updating lang, meta tags, and localStorage. The toggle uses the View Transitions API when the browser supports it, and falls back to a regular swap when it doesn't.

This obviously wouldn't scale to ten languages or complex formatting. Once you need pluralization rules, interpolation, or namespaced translation files, something like i18next or FormatJS is the right call. But for a personal site with two locales and a handful of strings, the manual approach is plenty.

When this doesn't work

There's a clear line where vanilla stops making sense, and that's when your UI has lots of components that need to stay in sync with each other. A dashboard where a filter updates a table and a chart at the same time, a multi-step form with conditional fields, an app with client-side routing. That's when declarative rendering pays for itself, and that's what React is actually for.

Sure, a language toggle is technically "state" too. But it's one value that changes once and updates the page. That's a different universe from managing a shopping cart or a real-time feed. The question isn't "does my page have state?" It's "would managing this state by hand make me miserable?" If the answer is no, you probably don't need a framework.

Longevity

Here's the thing I care about most. A Next.js site from 2021 is already two major React versions behind, probably on a Node version that's hit end of life, and using a routing paradigm that the framework is actively moving away from. It still works, but the pressure to modernize builds up. An HTML file from 2021 renders exactly the same as it did back then. It'll render the same in 2031 too.

The web platform is one of the most stable deployment targets in software. If you use it directly, your site just works. For years, without touching it.

If your site is a handful of pages, just write the HTML. If it grows to the point where that gets tedious, that's when a static site generator starts earning its keep. But start simple. The web platform already does most of the work. Let it.