Skip to content
➜cat blog/type-safe-apis-nuxt3-server-routes.md

Building Type-Safe APIs with Nuxt 3 Server Routes

How I replaced a standalone Express server with Nuxt 3 server routes and gained end-to-end type safety across the full stack.

8 min

There's a moment in every project where you realize the API layer has become a source of friction. You rename a field in the response, forget to update the frontend type, and spend twenty minutes debugging why a component renders undefined. I hit that wall on a client project last year, and it pushed me to rethink how I build APIs entirely.

The Problem with Separate API Servers

For years, my default was a standalone Express server sitting beside a Nuxt or Next frontend. Two repositories, two deployment pipelines, two sets of types that had to stay in sync manually.

The pain points accumulated quietly:

  • Response shapes drifted between what the server sent and what the client expected
  • Shared type packages became stale because nobody remembered to publish them
  • Local development meant running two processes and hoping CORS was configured correctly

It worked. But "works" and "pleasant to maintain" are different things.

Enter Nuxt 3 Server Routes

Nuxt 3's server engine (Nitro) treats API routes as first-class citizens. You drop a file in server/api/, export a handler, and it's available at /api/whatever. That part is straightforward. What makes it interesting is what happens when you combine it with TypeScript.

// server/api/projects.get.ts
export default defineEventHandler(async () => {
  const projects = await db.project.findMany({
    select: { id: true, title: true, status: true, createdAt: true },
  })
  return projects
})

On the frontend, useFetch('/api/projects') infers the return type directly from the handler. No manual type definitions. No shared packages. The types flow from the server handler to the component automatically.

<script setup lang="ts">
// data is automatically typed as the return type of the handler
const { data: projects } = await useFetch('/api/projects')
</script>

Change the server response shape, and TypeScript flags every consuming component immediately.

Validation at the Boundary

Type safety at build time is half the story. Runtime validation at the API boundary is the other half. I use zod for this: define the schema once, infer the type from it, and validate incoming requests against it.

// server/utils/schemas.ts
import { z } from 'zod'

export const createProjectSchema = z.object({
  title: z.string().min(1).max(200),
  description: z.string().optional(),
  tags: z.array(z.string()).max(10),
})

export type CreateProjectInput = z.infer<typeof createProjectSchema>
// server/api/projects.post.ts
import { createProjectSchema } from '~/server/utils/schemas'

export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, createProjectSchema.parse)

  const project = await db.project.create({ data: body })
  return project
})

readValidatedBody is built into Nitro. If the body doesn't match the schema, it throws a 400 with a structured error before your handler logic ever runs. No manual if (!body.title) checks scattered through the code.

The Pattern I Settled On

After using this on three projects, here's the structure that works:

server/
  api/
    projects/
      index.get.ts      # list
      index.post.ts     # create
      [id].get.ts       # read
      [id].patch.ts     # update
      [id].delete.ts    # delete
  utils/
    schemas.ts          # zod schemas
    errors.ts           # createError helpers

Each route file does one thing. The HTTP method is in the filename. Schemas live in a shared utility so both validation and type inference pull from the same source.

What I'd Do Differently

On the first project, I put too much logic in the route handlers: database queries, business rules, error mapping, all in one file. By the third project, I'd learned to keep handlers thin and push logic into service functions that the handlers call. Same principle as thin controllers in NestJS, just without the decorator ceremony.

The other lesson: don't skip error handling. Nitro's createError lets you throw structured errors that serialize cleanly to the client. Define a small set of error shapes early and use them everywhere.

throw createError({
  statusCode: 404,
  message: 'Project not found',
  data: { code: 'not_found', resource: 'project' },
})

When This Approach Fits

This works well when your frontend and API are tightly coupled (portfolio sites, dashboards, internal tools). For services consumed by multiple clients or third-party integrations, a standalone API with OpenAPI documentation is still the better choice.

But for the projects where the frontend is the only consumer, collapsing the API into the same codebase removes an entire category of bugs. The types are shared because they're literally the same types. That's not a workaround. It's the architecture working for you.