Why I Chose Nuxt Over Next.js for AccessHawk

NuxtNext.jsVueReactTypeScriptDeveloper ExperienceSaaSFramework Comparison
By , Senior Full-Stack EngineerUpdated 12 min read

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 on enterprise projects. 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.

React is a good framework with 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 in production React codebases.

Stale Closures

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.

The Rules of Hooks

React's Rules of Hooks - no conditionals, no loops, same order every render - exist because of implementation details in React's reconciler. They take getting used to, and they create a class of bugs that Vue's model doesn't have.

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.

The App Router

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 cost was real.

Server Components solve real problems for some applications. For the ones I build, the extra complexity cost more than it saved.

What Nuxt Gets Right

Nuxt also has real 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. That usually means smaller bundles for the same UI, though it depends a lot on what you pull in.

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, expect you 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-validator

Real-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.

React's lead is as much about timing and momentum as it is about technical merit.

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 reinforces itself.

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
  • Company standards: If your company standardizes on React, you use React

I've since rebuilt this portfolio in React on TanStack Start (here's why), so this isn't a one-framework-forever position. For my own SaaS products, where I control the stack and optimize for my own speed, Nuxt has usually been the faster choice.

The Productivity Difference Is Real

After shipping AccessHawk with Nuxt, the clearest difference is where my time went. I spent it 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: for me, it 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.

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 on something real, not a todo app.

If you want to see a Nuxt application handling complex requirements, check out AccessHawk.

Contact

Drop me a line. I read everything and reply within one business day.

Required fields are marked “(required)”.