5 min read

I Replaced My Express API with Nuxt Server Routes. Here's What Actually Happened in Production.

Three months ago I deleted my Express repo. Not because Express is bad — it isn't. But I was running two deploy pipelines, two package.json files, and two different auth implementations for what was essentially one product. The frontend was Nuxt. The backend was Express on Render. Every change that touched both sides meant two PRs and two chances for me to mess up the API contract.

So I moved the entire API into Nuxt's server/ directory. Not the server/api toy examples you see in tutorials. A real API with file uploads, rate limiting, and a Postgres connection. I was skeptical. Nitro is marketed as "the server engine," but marketing doesn't keep your app online at 2 AM.

It's been in production since May. Here's what held up and what fell apart.

What Actually Works

The routing is almost stupidly simple. Drop a file in server/api/users.get.ts and you have a GET endpoint at /api/users. No router setup, no app.use(), no port configuration. Nuxt handles the server startup, HMR, and the dev/prod split automatically.

Here's the handler I use for a simple CRUD endpoint:

import { defineEventHandler, readBody, createError } from 'h3'

export default defineEventHandler(async (event) => {
  const method = event.node.req.method

  if (method === 'GET') {
    const users = await db.selectFrom('users')
      .selectAll()
      .limit(50)
      .execute()
    return users
  }

  if (method === 'POST') {
    const body = await readBody(event)
    if (!body.email?.includes('@')) {
      throw createError({ statusCode: 400, statusMessage: 'Invalid email' })
    }
    const user = await db.insertInto('users')
      .values(body)
      .returningAll()
      .executeTakeFirst()
    return user
  }
})

Notice createError. This is the part that feels weird at first. In Express you throw or call next(error). In Nitro, you throw a specifically created error object. If you throw a plain Error, the client gets a 500 with a stack trace in dev mode. Not great. The createError pattern is verbose but it works.

Type safety is the real win. My Nuxt frontend uses $fetch('/api/users') and gets actual TypeScript types because the server and client share the same codebase. No OpenAPI generation, no codegen step, no stale type definitions. I changed a column name in the database, updated the query, and the frontend immediately knew about it. That never happened with Express.

The Deployment Story

I host on Cloudflare Pages. Before the migration, I had the Nuxt frontend on Pages and the Express API on Render. Two URLs, two CORS configurations to manage. Now it's one wrangler deploy and everything is on the same domain. No preflight requests, no separate environment variable sync.

Honestly, the CORS elimination alone was worth the migration. I spent an embarrassing amount of time last year debugging why cookies weren't cross-domain in Safari.

The server functions run as Cloudflare Workers, which means cold starts are under 50ms. My Express app on Render took 2-3 seconds to spin up after a period of inactivity. For a side project that gets traffic in bursts, that was painful.

What Doesn't Work

Error handling is the first wart. Express has a mature middleware ecosystem for centralized error logging, Sentry integration, and request tracing. Nitro's onRequest hooks exist but the documentation is scattered. I spent an afternoon figuring out how to wrap every handler in a try-catch that reports to Sentry without duplicating the same wrapper in every single file.

The solution is a server plugin:

// server/plugins/sentry.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('error', async (error, { event }) => {
    Sentry.captureException(error, {
      extra: { url: event?.path, method: event?.method }
    })
  })
})

It works, but finding that hook name required reading Nitro source code. Not exactly "batteries included."

File uploads are another rough edge. Express has Multer. Nuxt has readMultipartFormData, which returns a raw array of form parts. You parse it manually, validate sizes, write streams to disk or R2. It's not hard, but it's more code than upload.single('image'). For anything complex, I ended up writing my own wrapper utility. I keep it in Snippet Ark, otherwise I'd be copy-pasting it between projects forever.

Middleware: Where It Gets Weird

In Express, middleware is a stack. You app.use(authMiddleware) and it runs before every route. In Nuxt, server/middleware/ files run on every request, including static assets. That means your auth check runs when the browser fetches /favicon.ico. You have to manually exclude paths or check the URL inside the middleware. It's not elegant.

I eventually moved auth to a composable helper that each handler calls explicitly. More boilerplate, but at least it only runs where I want it.

Should You Do It?

Look, if you're running a high-throughput API with complex orchestration, Express or Fastify is still the right call. Nuxt Server Routes are not a general-purpose backend framework. They're a convenience layer for full-stack apps where the API is tightly coupled to the frontend.

But for SaaS apps, dashboards, and side projects where the frontend and backend are literally the same product? Nuxt server routes are probably enough. The deploy simplicity and type safety are not small wins. They're the kind of wins that compound over months of shipping.

Everything else lives in server/api/ now. Three months in, I haven't regretted it.

If you're organizing your own Nuxt server utilities, Snippet Ark handles the snippet management better than scattered utils/ folders. And for quick API documentation drafts, I still use ZeroPad, a markdown scratchpad that opens in a new tab, which is exactly where I need it when I'm deep in handler code.