The Framework I Didn’t Need to Build

null

There is a specific kind of exhaustion reserved for developers who try to maintain a custom Webpack configuration.

In 2022, my portfolio and blog were running on a bespoke, Server-Side Rendered (SSR) React application powered by Webpack, Material-UI, and Draft.js. It was heavy, but it worked. Then, I accepted a role at a high-growth startup, and like most personal projects, the codebase was placed in cryogenic sleep.

Fast forward to 2024. Following a layoff, I opened the repository to update my resume and add some new posts. I quickly realized I wasn’t opening a project. I was excavating a time capsule. Dependencies were deprecated, build times were glacial, and the architecture was frozen in a 2022 React ecosystem.

What followed was a multi-month modernization odyssey—a journey through headless text editors, the allure of Vite, the nightmare of Node-based JSX compilation, and ultimately, the realization that building your own framework is the ultimate form of productive procrastination.

Phase 1: The Editor Wars and the Death of Draft.js

My first major roadblock was the content engine itself. Draft.js, once the industry standard backed by Meta, had become a ghost ship. It was effectively deprecated, lacking support for modern React paradigms.

I needed a replacement that gave me total control over the DOM. I pivoted to Tiptap, a headless editor built on ProseMirror. It was a massive architectural upgrade, moving from a rigid component structure to a highly extensible JSON document model backed by ProseMirror’s schema system.

However, "headless" means you build the UI and the rendering logic yourself. I spent weeks wrestling with custom extensions just to handle basic editorial needs:

  • Percentage-Based Image Scaling: Extending Tiptap's image node to allow for responsive, percentage-based widths that survived the transition from JSON to static HTML.

  • The Whitespace Dilemma: When users hit "Tab" or "Enter," Tiptap generates \t characters or empty <p></p> tags. When rendered statically via generateHTML(), the browser ruthlessly collapses this whitespace. I had to engineer a post-processing pipeline using Regex and CSS (white-space: pre-wrap) just to ensure an empty line in the editor actually looked like an empty line on the live blog.

I had successfully modernized the content layer, but the surrounding infrastructure was still suffocating under Webpack.

Phase 2: Chasing Speed and Falling into the Vite Trap

To escape Webpack's sluggish rebuilds, I decided to migrate the build toolchain to Vite. The promise was intoxicating: native ES modules, instant Hot Module Replacement (HMR), and zero-configuration JSX.

The client-side migration was a breeze. I swapped out react-scripts, changed index.js to index.jsx, and suddenly my dev server booted in milliseconds.

Then, I tried to render a page on the server. The illusion shattered.

The SSR Nightmare

Vite is phenomenal, but bridging it with a custom Express server for Server-Side Rendering is a battle against Node.js itself. I encountered a cascading sequence of esoteric errors that consumed days of engineering time:

1. The Node ESM vs. CommonJS Conflict By switching to Vite, I moved to ES Modules ("type": "module"). Suddenly, my server crashed because __dirname is not defined in ES module scope. I had to rewrite my entire file-path architecture using fileURLToPath(import.meta.url).

2. Node Doesn't Speak JSX I wanted to render my React Router on the server. I wrote <StaticRouter location={req.url}> inside my express.js file. Node immediately panicked with a SyntaxError: Unexpected token '<'.

I realized that without Babel or Webpack actively transpiling the server file, Node couldn't read my React components. I was forced to either stringify React using React.createElement manually or pipe my server code through vite.ssrLoadModule on every request.

3. The Express 5 Wildcard Crash To catch all routes for the React SPA, I used a standard app.get('*'). Express 5 (which Vite's middleware interacts with) updated its underlying path-to-regexp engine, instantly crashing my app with TypeError: Missing parameter name. I had to refactor my routing to use named wildcards like app.get('/*splat') just to get the server to boot.

Building a Bespoke Framework

Despite the friction, I pushed forward.

I designed a custom data-loading system inspired by patterns used in modern SSR frameworks. Routes were centralized into a manifest, and each route defined its own loadData function. On the server, middleware intercepted incoming requests, matched the route, executed the corresponding data loaders, and injected the results into the HTML through a window.__PRELOADED_DATA__ script tag to ensure the client could hydrate without mismatches.

In theory, it was elegant.

In practice, it was fragile.

Between serializing MongoDB documents, preventing XSS during JSON injection, coordinating server-side rendering with vite.transformIndexHtml, and proxying Vite’s HMR WebSocket, the system developed multiple failure points. Server instability became frequent, and even verifying correct SSR behavior through SEO tools — including LinkedIn’s crawler — produced inconsistent results.

What started as an attempt to build a clean SSR architecture had slowly turned into two months of writing infrastructure instead of product code.

Phase 3: Senior Enlightenment and the Pivot to Next.js

There is a moment in every engineer’s career when the instinct to build everything from scratch gives way to a more pragmatic realization:

The goal is not to build frameworks.
The goal is to ship software.

I had spent two months writing plumbing: route matching, JSON serialization, XSS prevention, CSS injection via vite.transformIndexHtml, and custom HMR WebSocket proxying.

I stopped. I opened a terminal and ran: npx create-next-app@latest

The "Blessing" of the App Router

Moving my hard-earned Tiptap logic and MongoDB models into the Next.js 15 App Router felt like stepping out of a blizzard into a heated room.

  • Data Fetching: My complex loadData route arrays evaporated. I just wrote await BlogPostModel.findOne() directly inside a Server Component.

  • Routing: The Express wildcards and StaticRouter were deleted. I created an app/blog/[slug]/page.tsx file.

  • Infrastructure: I pushed to GitHub, and Vercel deployed the site globally with ISR and on-demand cache invalidation. Blog pages stay cached until the interval expires or my publishing API triggers revalidatePath().

The infrastructure I had spent years fighting with became a silent, invisible background process.