When I started building AccessHawk - an AI-powered web accessibility scanner that uses Playwright, Lighthouse, and LLMs to analyze websites for WCAG compliance - I had a choice to make. I could reach for React and Next.js, the safe, popular choice that dominates tech discussions. Or I could use Nuxt and Vue, the framework that had consistently made me more productive on personal projects.
I chose Nuxt. Not because I don't know React - I've shipped React applications for enterprise clients. I chose Nuxt because after spending too many hours debugging stale closures, memorizing hooks rules, and fighting with Next.js's App Router, I wanted to build something without the framework getting in my way.
This isn't a hit piece on React. React is fine. It has an enormous ecosystem. But after building AccessHawk - a real-time distributed system with Redis pub/sub, BullMQ job queues, SSE streaming, and full TypeScript coverage - I'm more convinced that Nuxt makes me faster.
The React Pain Points I Left Behind
Here's what drove me away from React. These aren't theoretical concerns - they're problems I hit repeatedly on client projects.
Stale Closures: The Silent Bug Factory
React's hooks capture values at render time, creating closures that can become stale if dependencies aren't managed perfectly. I've spent hours debugging issues where a callback referenced an old state value, or an effect ran with outdated props. The mental overhead of constantly asking "is this closure stale?" is exhausting.
Vue's reactivity system doesn't have this problem. Reactive references are always current because they're proxied objects, not captured values. When I access myRef.value in a callback, I get the current value - not whatever it was when the function was created.
Hooks Rules: Arbitrary Complexity
React's Rules of Hooks - no conditionals, no loops, same order every render - exist because of implementation details in React's reconciler. They're not intuitive, they're not obvious, and they create a class of bugs that simply don't exist in other frameworks.
Vue's Composition API has no such restrictions. Call composables wherever you want, conditionally if needed. The framework doesn't care about call order because it tracks dependencies through reactive proxies, not array positions.
App Router: Complexity Without Proportional Benefit
Next.js's App Router introduced React Server Components, which sounds great in theory - render on the server, ship less JavaScript. In practice, it created a maze of "use client" directives, confusing hydration errors, and an entirely new mental model for what was previously straightforward.
I watched teams struggle with which components could be server components, when to add "use client", and how to share state across the boundary. The complexity tax was real, and for most applications, the benefits were marginal.
Server Components are an interesting technology, but they solve a problem most applications don't have while creating complexity every application must pay for.
What Nuxt Gets Right
Nuxt isn't just "not React." It has actual advantages that made me faster on AccessHawk.
Auto-Imports: Less Typing, More Building
In a React project, the top of every file looks the same: import { useState, useEffect, useCallback, useMemo } from 'react', followed by component imports, utility imports, type imports. It's boilerplate that adds up across hundreds of files.
Nuxt auto-imports Vue's reactivity primitives, all your composables, all your components, and all your utilities. The ref, computed, and watch functions are just available. Your useScan composable is just available. Your ScanResults component is just available. The cognitive load of "where does this come from" disappears because the answer is always "Nuxt handles it."
Reactivity That Makes Sense
Vue's reactivity system uses JavaScript Proxies to track dependencies automatically. When you access a reactive value, Vue knows. When you modify it, Vue knows. There's no dependency array to forget, no stale closure to debug.
// Vue: Just works
const count = ref(0)
const doubled = computed(() => count.value * 2)
// The callback always sees current values
setInterval(() => {
console.log(count.value) // Always current
}, 1000)It's a different model. I spent zero time debugging stale closures in AccessHawk.
Server Routes Without Ceremony
AccessHawk's backend handles complex workflows: validating URLs, creating BullMQ jobs, streaming results via SSE, managing user authentication, encrypting API keys. Nuxt's server routes made this straightforward.
// server/api/scan/stream.get.ts
export default defineEventHandler(async (event) => {
const { id } = getQuery(event)
const stream = createEventStream(event)
// Subscribe to Redis, stream results, clean up on close
return stream.send()
})The file name defines the route and method. The handler is a simple function. No ceremony, no boilerplate, no confusion about request/response types.
Smaller Bundles, Faster Apps
Vue's compiler can analyze templates at build time, tree-shaking unused features and generating optimized render functions. The result is consistently smaller bundles compared to equivalent React applications.
For a SaaS product where every kilobyte affects load time and user experience, this matters. AccessHawk's initial bundle is lean despite including a full accessibility scanning interface with real-time updates.
Full TypeScript Without Compromise
A common criticism of Vue is that TypeScript support isn't as good as React. This was true years ago. It's not true now. AccessHawk is fully typed, and I haven't hit any typing issues that React wouldn't also have.
Type-Safe Composables
Every composable in AccessHawk has full type inference. Return types are inferred, reactive refs are properly typed, and the IDE knows exactly what's available.
// composables/useScan.ts
export function useScan() {
const status = ref<ScanStatus>('idle')
const results = ref<ScanResult | null>(null)
const error = ref<string | null>(null)
async function startScan(url: string): Promise<string> {
// Full type safety throughout
}
return { status, results, error, startScan }
}API Route Types
Nuxt generates types for all API routes automatically. When I call $fetch('/api/scan'), TypeScript knows the expected request body and response type without manual type annotations.
Shared Types Across Packages
AccessHawk is a monorepo with web, worker, and shared packages. The shared package exports TypeScript types and Zod schemas used everywhere. This pattern works identically to how it would work in a Next.js monorepo - the framework doesn't limit your architecture.
Testing with Vitest: Fast, Modern, Integrated
Testing is non-negotiable for production software. AccessHawk uses Vitest, and it's been good.
Why Vitest Over Jest
Vitest shares Vite's config and transformation pipeline, so there's no duplicate configuration. It's fast because it uses Vite's native ES module handling instead of transforming everything through Babel.
- Native ESM: No CommonJS transformation overhead
- Watch mode: Near-instant re-runs on file changes
- Jest-compatible API: Same
describe,it,expectyou already know - Built-in coverage: No separate package needed
- TypeScript native: No ts-jest configuration required
Testing in a Monorepo
AccessHawk's tests live alongside source files (*.test.ts), and Turbo orchestrates running them across packages. The shared package has unit tests for validators and schemas. The web package has tests for server utilities. Everything is typed, everything is fast.
# Run all tests in watch mode
pnpm test
# Single run across all packages
pnpm test:run
# Run specific test file
pnpm --filter @accesshawk/web test url-validatorReal-Time Features: Where Nuxt Shines
AccessHawk streams scan progress to the client via Server-Sent Events. The architecture looks like this: user requests a scan, the API creates a BullMQ job and returns a job ID, the client opens an SSE connection, the worker publishes progress to Redis, and Nitro streams those events to the client.
Implementing this in Nuxt was straightforward. Nitro provides utilities for handling SSE streams with minimal boilerplate. The client uses a simple composable to manage the connection.
Vue's reactivity makes the UI updates trivial. When new data arrives via SSE, I update a reactive ref, and Vue automatically re-renders the affected components. No setState batching concerns, no worrying about stale callbacks in event handlers.
The Ecosystem Tradeoff
There's a tradeoff. React's ecosystem is larger. More UI libraries, more developers, bigger talent pool if you're hiring.
But ecosystem size isn't ecosystem quality. Vue has Headless UI, Radix Vue, PrimeVue for components. Pinia for state management (I prefer it to Redux). VeeValidate and FormKit for forms. Vitest and Vue Test Utils for testing. I haven't been blocked by a missing library yet.
Occasionally I find a library that's React-only. It's a minor friction. For AccessHawk, everything I needed either had a Vue equivalent or was framework-agnostic.
Why React is More Popular (And Why It Doesn't Matter)
React's dominance isn't about technical superiority. It's network effects and corporate momentum.
First-Mover Advantage
React launched in 2013, backed by Facebook. It arrived at the right moment - when jQuery was showing its age and developers wanted something better. Vue launched a year later without corporate backing. React got enterprise adoption first, which created jobs, which attracted developers, which created more adoption.
Corporate Adoption Begets More Adoption
Large companies (Meta, Netflix, Airbnb) standardized on React early. Their engineering blogs praised React. Their open-source tools were React-first. This created a perception that React was the "serious" choice for "real" engineering.
Job Market Momentum
More React jobs meant more developers learned React. More React developers meant companies could hire React developers. The cycle is self-reinforcing and has little to do with technical merit.
None of this means React is bad. It means popularity isn't a technical argument. PHP is more popular than Elixir. That doesn't make it better for building concurrent, fault-tolerant systems.
When I Would Choose React
I'm not dogmatic. There are situations where React makes sense:
- Team composition: If your team knows React and doesn't know Vue, the learning curve cost may not be worth it
- Specific library needs: If you need a library that's React-only and central to your product
- React Native: If you're building a mobile app alongside web, React's ecosystem is stronger here
- Hiring at scale: If you need to hire 20 frontend developers quickly, the larger React talent pool helps
- Client requirements: If a client mandates React, you use React
For my own SaaS products, where I control the tech stack and optimize for my productivity? Nuxt wins every time.
The Productivity Difference Is Real
After shipping AccessHawk with Nuxt, I can point to concrete time savings. Zero hours debugging React and Next.js quirks.
Instead, I spent that time on features: Lighthouse integration, the SSE streaming pipeline, the report UI, auth, scheduled scans. The framework stayed out of my way.
That's my argument for Nuxt. Not that React is bad. It's that Nuxt has less friction. Auto-imports mean less boilerplate. Proxy-based reactivity means no closure bugs. I don't have to think as hard about the framework.
For a solo developer shipping a SaaS product, those small savings add up.
Build With What Makes You Productive
AccessHawk is a production application with real users, real-time features, full TypeScript coverage, and a distributed architecture. I built it with Nuxt because Nuxt made me faster, not because Vue is trendy.
Framework debates are mostly noise. Both React and Vue can build good software. But they're not identical, and the differences matter when you're writing code every day.
If you've lost hours to stale closures, if hooks rules feel arbitrary, if the App Router feels over-engineered, give Nuxt a try. Not for a todo app. For something real. You might find the grass is greener.
If you want to see a Nuxt application handling complex requirements, check out AccessHawk.
