UpWeb.

WRITINGUW-039

How AI changed the way I pick frameworks, and the two places React survived

· ZACH · SOLIDJSREACTFRONTEND-ARCHITECTUREVANILLA-EXTRACT · RAW .MD

That 130-file PR that shipped KeyEcho 1.0 contained a decision I never wrote about: the desktop app moved from Tauri 1 + Vue to Tauri 2 + SolidJS.

The same summer, upweb.dev moved from Nuxt 4 to SolidStart, and keyecho.app (SSR, five languages, Stripe checkout) was built on SolidStart from scratch. Three separate decisions, same answer every time.

Scope first. This post is about defaults for my personal projects. Near the end I'll cover two places where I still use React, one of which runs very well.

The criteria changed

Most of the code in my repos is AI-generated now. My job has shifted from writing code to reviewing it.

The old framework checklist put most of its weight on learning curve and developer experience. Both are worth zero now: the model writes five frameworks fluently and knows the rules of hooks better than most humans do. AI crushed the price of writing code. Two costs didn't move: the runtime bytes every user downloads, and the human hours spent reviewing. Pick your stack by the new prices.

Runtime cost lives in the architecture. No prompt removes it, so it decides the framework. Review bandwidth is finite, and AI throughput will grind down any consistency that is maintained by verbal agreement, so it decides the styling layer. I have seen more than one React codebase a few years into its life: two animation libraries, two carousel components, three styling systems, each introduced for a perfectly good reason on some ticket, and the sum is a mess nobody can clean up. AI did not invent that drift. It made it an order of magnitude faster.

React is no longer my default: size and performance

Let me discard the weak arguments first. Dependency arrays, stale closures, re-render storms: I am not going to relitigate any of it. The model knows the rules of hooks cold, eslint-plugin-react-hooks catches most slips, and React Compiler now handles memoization. The ergonomics debate no longer produces a winner.

What still produces a winner is the client. react-dom is roughly 40 kB compressed, a virtual DOM plus a reconciler, downloaded, parsed, and executed on every cold visit by every user. React Compiler can remove wasted re-renders. It cannot remove the runtime itself. Developers never feel this cost. Users on phones pay it constantly.

My projects are one person plus a fleet of agents. Hiring pools do not help me, and content sites barely touch the component-library ecosystem. Once the weights shift like that, React has no slot left in my personal stack.

The same app, written ten times

Loren Stewart ran an experiment this year: the same kanban app implemented in ten frameworks, identical features (drag and drop, comments, tags, server validation), then compared compressed JS on the board page.

Framework Compressed JS
Next.js 16 (React 19) 176.1 kB
TanStack Start (React 19) 118.2 kB
Analog (Angular 20) 103.9 kB
Nuxt 4 (Vue 3) 74.7 kB
TanStack Start + Solid 60.4 kB
Qwik City 58.4 kB
SvelteKit (Svelte 5) 54.1 kB
SolidStart (SolidJS 1.9) 41.5 kB
Astro + HTMX 34.3 kB
Marko 28.8 kB

Two comparisons stand out. Same meta-framework, TanStack Start, with React swapped for Solid: 118.2 kB becomes 60.4 kB. Next.js against SolidStart: a 4.2x gap. His conclusion was that React's architectural cost is something no meta-framework can optimize away.

The boundaries, as always. All ten implementations had FCP between 35 and 71 ms, indistinguishable by feel. Real-site bottlenecks are usually images and video, and framework JS is a small share of total bytes. But the two costs behave differently: media optimization is ongoing labor for the life of the site, while framework weight is fixed forever at pnpm create. AI already flattened the cost of writing the code. For the same feature set, there is no reason to start four times heavier.

First-party numbers, same site, before and after. On Nuxt 4, upweb.dev scored 70/100/100/100 on mobile PageSpeed. I did round after round of tuning at the Cloudflare layer and the performance score would not move; the lab bottleneck kept pointing at JS weight. After the move to SolidStart: 99/100/100/100, simulated Moto G Power on slow 4G, FCP 1.5 s, LCP 1.8 s, CLS 0.006, TBT 0 ms. That zero TBT is where fine-grained reactivity pays off most: there is no virtual tree to reconcile on the main thread.

Squeeze the water out of this comparison before quoting it: Lighthouse lab data, a single-page session, a sample of one site, and the framework was not the only variable. People will remind you that lab scores are not real user experience, and they are right. RUM tells you how users are doing today; the lab tells you how much headroom the architecture leaves. Same domain, same CDN config, framework swapped, and the score went from 70 to 99. Free points. I took them.

Desktop follows the same logic: inside a Tauri WebView, every kilobyte shaved off the runtime comes back as cold-start time and memory.

I did look at Svelte, Vue, Qwik, and Marko

Svelte 5's runes are signals too, and SvelteKit's 54.1 kB is small enough. Vue's reactivity comes from the same family, and Nuxt's 74.7 kB is the best result among the big-three meta-frameworks. What ruled them out is the language layer.

.svelte and .vue are custom file formats. TypeScript is one region inside the file, and type checking runs through a bridge: svelte-language-tools on one side, Volar on the other. The bridge works fine day to day. The bill arrives when the toolchain moves. TypeScript 7.0 went GA on the 8th of this month, a native Go compiler, officially about 10x faster, with type-checking of the VS Code codebase dropping from 125.7 seconds to 10.6. JSX is plain TS, so a Solid project switches the day it ships, zero changes. The compiler API that Vue and Svelte template checking depends on has no stable version in 7.0. They wait for 7.1 in October. Every extra language layer is another place in the upgrade queue.

One more against Nuxt: streaming SSR is still locked behind the experimental ssrStreaming flag, while streaming in SolidStart is one of createHandler's three built-in rendering modes. No flag to flip.

Qwik's resumability solves huge, interaction-sparse landing pages, which my sites are not, and I did not want to carry its mental model. Marko's 28.8 kB is the smallest of the ten, but the ecosystem is too small a bet for two sites I plan to maintain for years.

Reactivity models have been converging. Vue refs, Svelte 5 runes, Angular and Preact signals, plus the TC39 Signals proposal, all point the same way. React is the lone exception, patching around it with a compiler. Signals, JSX, plain TS: ask for all three at once and Solid is what is left.

Astro is smaller than SolidStart. Why not Astro?

One row in that table deserves a straight answer: Astro + HTMX is 34.3 kB, 7 kB smaller than SolidStart. Both of my sites are content-heavy marketing sites. By byte logic alone, Astro wins.

Astro's model is content first, zero JS by default, interactivity added through islands. The catch is that when you write the interactive parts, each island hosts a component framework anyway, and the project now speaks two dialects: .astro templates for page structure, the island framework for behavior, state passed across the seam. And .astro is one more custom file format, so the upgrade-queue problem from the last section applies here too.

Neither site is actually static. keyecho.app runs Stripe checkout, a subscribe flow, and SSR in five languages, with sound-pack sales pages on the way. upweb.dev has quietly grown into a small system: double-opt-in subscriptions, posts and newsletter archives managed in Broadside, and on every publish a webhook hits the site, verifies its signature, dedupes replays, clears the server content cache, then purges Cloudflare by cache tag. New articles go live without a deploy. All of that lives in SolidStart API routes, and the interaction density only goes up from here. One component model covering both the static and the dynamic parts is worth 7 kB to me.

If your site is a pure blog or docs and changes three times a year, Astro is the better answer, not SolidStart.

The ecosystem bill: TanStack covers the worst of it, components you grow yourself

SolidStart is not a silver bullet, and its biggest weakness is the ecosystem. Most things React has, Solid has an equivalent for, one maturity tier down and with fewer options. Part of that you tolerate. Part of it has workarounds.

The workaround with the widest coverage is TanStack. Router, Query, Form, Table, Virtual: every package is a core plus adapters, and Solid gets first-class ones. I run the Solid version of TanStack Start in production at difflore.dev: solid-router, solid-query, and oRPC, a full backend with auth, a database, and AI calls, and no missing parts so far. That one vendor crosses off the heaviest items on the list.

The larger trend helps too: infrastructure is going framework-agnostic. Vite, Nitro, Drizzle, Zod, oRPC, better-auth do not care what renders your UI, and headless UI has moved from React-only toward state-machine cores with adapters for everyone. The framework-exclusive share of the ecosystem is shrinking. The flip side deserves saying out loud: every convenience you take from a React-only library raises the wall you would have to climb to ever leave. An ecosystem is an asset and a shackle at the same time.

The gap that remains is the polished component library. On the React side the standard is shadcn/ui; Solid has community ports, at a fraction of the traction. This does not slow me down, and shadcn itself demonstrates why: it ships no npm package. It copies component source into your repo, turning components from a dependency into your own code. Definitions like that are exactly what AI produces fast. One component, written twice, a React version and a Solid version, is minutes of work. None of my current interfaces use a prebuilt library: both sites are hand-written components on top of tokens, the React admin uses Base UI, the Solid side uses Kobalte, all headless primitives with the look and behavior defined by me. AI solved the output problem. What it did not solve is how to design a component API, where state should live, and how tokens get enforced. That skill transfers between React and Solid completely, and the shallower the component-library moat gets, the more it is worth practicing.

Styling: utility classes lost the default slot

The current state first, so this does not read like a takedown: KeyEcho desktop shipped 1.0 on UnoCSS, still runs it today, and I have no plan to rip it out. That UI is small and fixed, there is no design file to match, and the panels rarely change once settled, so utility classes have little room to go wrong there. Admin platforms and marketing sites are a different shape: pages keep multiplying, styles track redesigns, and there is a design language to defend. The two failure modes below belong to that second shape.

Utility classes fail in AI hands at two points.

The first is token drift, and Tailwind and UnoCSS are equally exposed. The project has its own design tokens, and the model still reaches for the default scale in its training memory, or drops in an arbitrary value. gap-3 and gap-[13px] look nearly identical in review. Every line is legal, the page renders fine, and the design system forks a little more with each PR. I have watched this happen more than once. Tailwind v4's @theme cleans up token definitions; arbitrary values remain forever legal.

The second is size. Atomic CSS rests on class reuse: the stylesheet is supposed to stop growing with page count. Enough arbitrary values and that premise collapses. Every w-[347px] is a single-use rule, the stylesheet grows with the PR count, and so do the class strings in the HTML. A constraint model this permissive compiles everything, and the cost gets billed somewhere else.

vanilla-extract turns tokens into TypeScript exports. Styles live in .css.ts files, colors have to be imported from the theme, a misspelled variable is a type error, and a hex value invented from memory does not survive type checking. The theme contract on upweb.dev defines 16 tokens with createThemeContract; light and dark must each assign every key, and one missing dark value fails the build. The rule no longer depends on anyone's memory, or on whether the model read the docs. The output is plain CSS with zero runtime.

The oldest complaint about CSS-in-TS is naming every style block. AI inverted that one: the model does the naming, I only review, and a named style block is far easier to review than a thirty-character class string. The honest costs that remain: dynamic styles detour through CSS variables, and models are visibly more fluent in Tailwind than in vanilla-extract, so prompts need more steering. First-draft speed traded for enforceable constraints. On a content site I will sign that trade.

The rest of the CSS field

Zero runtime is the floor, which clears the runtime approaches immediately. styled-components and Emotion generate styles in the browser and need extra serialization for SSR, and styled-components is officially in maintenance mode. CSS Modules are zero-runtime, but tokens are string conventions the type system cannot see, so the theme contract is enforced by hoping.

Panda CSS and StyleX are in vanilla-extract's lane: typed, compile-time.

Panda loses on operational shape. It codegens an entire styled-system output directory that must be regenerated whenever tokens change, and the call sites look like a runtime library while behaving like build-time static extraction, so any dynamic object shape (computed keys, spread from a function) gets silently dropped from the stylesheet. Too much pipeline for a content site.

StyleX has nothing wrong with it as a design, and defineVars enforces tokens well, but it is stuck on ecosystem status: two community Vite plugins died in sequence, the official unplugin integration arrived late, and there is no established path in SolidStart. For infrastructure I only pick what the current build chain treats as a first-class citizen. vanilla-extract's official vite-plugin has been stable for years and drops into any Vite-based framework.

Where React still runs

A heavily interactive product I work on at my day job runs React 19 with React Compiler on TanStack Start, and runs well. A team's weight table is different from a solo one: the heavyweight components, editors above all, are written in React, so changing runtimes means a rewrite plus a full regression pass, and QA hours cost more than any framework difference. That alone settled the runtime. At the meta-framework layer they picked TanStack Start over Next.js for equally practical reasons: the product barely touches RSC, so Next.js's architectural complexity buys nothing there, and the Vite toolchain plus fully typed routing came along for free. Loren's table prices that choice at 58 kB: React 19 either way, 118.2 against 176.1, a third of the bytes.

Broadside's admin is a different story. It also runs React with TanStack Start, for a less flattering reason: I needed the platform running and had no time to switch stacks. The plan is to move it onto TanStack Start's Solid adapter, the same combination as difflore.dev: keep the ecosystem, swap the runtime.

Broadside itself is my self-hosted, headless newsletter platform; keyecho.app's subscriptions and sending run on it. It was designed AI-first from day one: an MCP server is built in, and drafts, sends, subscriber and engagement data all go through APIs with typed contracts, so an agent gets the same capabilities a human does. That is how this newsletter actually operates: an agent pushes drafts into the platform, models handle the subscription and delivery analysis, and the human part is reviewing and pressing send. Once it is cleaned up I am open-sourcing it. Separate post then.

Five rules from three decisions

  1. Pick frameworks by client-side bytes. AI erased learning curves from the weight table. Runtime cost lives in the architecture, and no prompt removes it.
  2. A constraint only counts if the compiler enforces it. A rule that fails silently does not exist at AI throughput. Docs get diluted over a long session; type errors do not.
  3. Every extra language layer is another place in the upgrade queue. TS 7 this month: JSX switched on day one, template DSLs wait until October.
  4. Prefer framework-agnostic ecosystems, and grow your own component layer. A single-framework library's convenience and its lock-in come from the same place. Headless primitives plus your own tokens: AI supplies the volume, you supply the rules.
  5. Change the default, do not relitigate the past. UnoCSS still runs the desktop app, and the day-job React product runs fine. New defaults apply to line one of new repos.

upweb.dev and keyecho.app both run on SolidStart + vanilla-extract today. The next repo I open will probably start there too, until some project's shape changes the answer again.