Next.js Sucks; Or Why I Wrote My Own SSG | P.C. Maffey

6 min read Original article ↗

I went to update my site's bio this past weekend and wrote a custom static site generator instead.

It's an itch I've wanted to scratch. Ditch Next.js. Convert my blog posts to MDX (JSX in markdown). Autogenerate posts and an RSS feed. Write the , styles in CSS modules. Output only HTML+CSS, with islands of client-side React progressively hydrated as needed.

I like JSX and the composability of React components. IMO this is why React "won"; it made writing frontend systems easy.

On a Saturday morning in between playing with my kids, I dove in with Opus 4.5. After a few hours of tinkering, I got exactly what I wanted.

Here's a template you can fork and play with: https://github.com/pcmaffey/bun-ssg. It includes the "less than 25kb of HTML+CSS" build output.

The last time I posted was un-ironically, the last time I rebuilt my blog engine. I had gone deep on a hypothesis that the scrollable UX of the web contributes mightily to the demise of reading comprehension; I thought "paging" presented a better, more intentional UX for reading online. I over-engineered an engine on top of Next.js. No one liked it. So I tore it out, pushed a quick update and haven't checked back since.

The implementation was fine. It ended up similar to a presentation framework (like reveal.js) powered by a blog engine. My idea was to then create a reader app you could point at any article and convert into pages / slides.

Two years later I can't even get it to run. Updating the dependencies looks like hell. Sigh. I know it's time to start over. I've just been avoiding it.

I'd been waiting for Bun for months. But it keeps getting delayed, and the likelihood it will support MDX out of the box is low. I'd also almost certainly have to implement React islands myself, given But I figured, they'd have enough primitives in place to support React SSG as a first-class citizen, as opposed to Next.js post app-router.

Or whatever they're calling it now. I'm not sure what their plans are now that they no longer need to compete with Vercel...

Dan Abramov argues that RSC is React's take on island architecture, which makes sense to some degree. My counter-argument to him: it's not the solution the ecosystem was asking for, and the incentives developing it in collaboration with Vercel are suspect.

Not to pile on, but the recent security flaws expose how short-sighted it is to embrace the deep-composability of RSC architecture. Unless it solves a very specific problem for you, the complexity tradeoff is rarely worth it.

Eureka!

Staring at the errors in my CLI, I realized I did not want to use another framework. It's why I had already discarded the idea of switching to Astro. Twiddling around someone else's abstractions and incentives, frustrations fitting together the final 20% of a project... I've been down that road too many times before. It's never fun. The tradeoffs you don't know you're making are the biggest risk.

When you build it yourself, the initial 80% takes longer. But you can custom fit the final implementation to your needs. Now with AI, if you know what you want, the risk calculus shifts strongly in favor of DIY. A strange inversion: "n ot invented here" syndrome gets a boost from LLMs trained on everyone's data.

So I built it myself.

What I learned

You can read how it works in the README. This site pcmaffey.com loads < 50kb of HTML and CSS, not including the inlined SVG's. There are no islands on the site, so no React. Only a small JS script for the subscribe form. But still, I built support for progressive hydration because I intend to use it for my main side project.

Globbing a pages directory and hydrating each with a { posts } prop on build was straightforward enough. Likewise with MDX and frontmatter support. Getting the DX right for detecting and rendering islands within MDX took some tinkering. The components <Counter /> get replaced with placeholders (<div data-island="counter" /> ) and a script to hydrate them client side. That required a component registry with a bit of scaffolding to get right.

Bun supports loading , but to get the prefixed classnames to match up, I needed to maintain a map of components to classnames inside a .cache directory.

Also, I use a lot of SVG's; so I added SVGR to inline these, so that fill="currentColor" works and they're targetable via CSS. Unfortunately, importing these into components as logo.svg conflicted with bun-types native svg file support. To workaround this, I had to auto-generate logo.svg.tsx files and redirect the imports behind the scenes.

I went to the extra lengths to get CSS Modules to work, mostly because I could, and they are easily my favorite composition method for design systems. In the template, they're entirely optional, and I imagine it would be straightforward enough to add whatever flavor of styling you prefer.

The last subsystem I had to hack together was hot reloading, since my component registry setup doesn't jive with import.meta.hot HMR. Instead, I created a dev-runner.ts that watches the entire src directory and uses server-sent events, which were new to me. Client pages get hydrated in dev with a liveReloadScript that listens for events and reloads the page. Basic, but sufficient for my blog.

For the sake of long-term stability, I'd be curious to replace Bun with Vite. I like Bun; and so long as I don't lean too heavily on their abstractions, I'm not too worried about that dependency. But if I hit some error the next time I go to update my site, no doubt I'll make the switch.

Final thought

I've come to accept some things about myself. Yes, my side projects have side projects. This is perhaps my favorite development with AI. I can now explore these rabbit holes without spending weeks or sometimes months sifting through implementation details. I get to scratch an itch I wouldn't normally consider a worthwhile energy tradeoff and build a thing that works exactly how I want it to.

There's a catharsis to it.