Nuxt 3 Server Routes and API Endpoints
One of the underrated features of Nuxt 3 is the built-in server engine, Nitro. You can build a full API right inside your Nuxt project without reaching for a separate Express or Laravel backend — and for a lot of projects, that's all you need.
In this post, I'll walk through how server routes work, covering GET and POST handlers, reading request bodies, middleware, and connecting to a database.
How Server Routes Work
Any file you drop inside server/api/ becomes an API endpoint automatically — no router configuration needed. The file name maps to the route, and the HTTP method comes from the filename suffix.
server/
└── api/
├── users/
│ ├── index.get.ts → GET /api/users
│ ├── index.post.ts → POST /api/users
│ └── [id].get.ts → GET /api/users/:id
└── health.get.ts → GET /api/health
Each file exports a default handler function using defineEventHandler.
A Simple GET Endpoint
// server/api/health.get.ts
export default defineEventHandler(() => {
return {
status: 'ok',
timestamp: new Date().toISOString(),
}
})
Hit http://localhost:3000/api/health and you get:
{
"status": "ok",
"timestamp": "2025-12-03T10:00:00.000Z"
}
Nitro automatically serialises the returned object as JSON with the right content-type header. No res.json(), no boilerplate.
Reading Route Parameters
// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
// In a real app, query your database here
const user = await getUserById(Number(id))
if (! user) {
throw createError({
statusCode: 404,
statusMessage: 'User not found',
})
}
return user
})
getRouterParam pulls :id from the URL. createError returns a proper HTTP error response — Nitro handles the rest.
Reading Query String Parameters
// server/api/users/index.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const page = Number(query.page) || 1
const perPage = Number(query.per_page) || 15
const search = String(query.search || '')
// Use these to paginate your DB query
return {
data: [],
page,
per_page: perPage,
}
})
getQuery parses the query string into an object. GET /api/users?page=2&search=john gives you { page: '2', search: 'john' }.
Handling POST Requests
// server/api/users/index.post.ts
import { z } from 'zod'
const createUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
})
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const result = createUserSchema.safeParse(body)
if (! result.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation failed',
data: result.error.flatten().fieldErrors,
})
}
const { name, email } = result.data
// Create the user in your database
const user = await createUser({ name, email })
setResponseStatus(event, 201)
return user
})
readBody parses the request body as JSON. I'm using Zod for validation here — it's the standard for Nuxt 3 server routes. If you haven't used it, it's well worth the install:
npm install zod
Server Middleware
Files in server/middleware/ run on every request before it hits a handler. Useful for auth checks, logging, or setting headers.
// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
// Only protect /api/admin routes
if (! event.path.startsWith('/api/admin')) {
return
}
const token = getHeader(event, 'authorization')?.replace('Bearer ', '')
if (! token) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorised',
})
}
// Verify the token and attach the user to the event context
const user = await verifyToken(token)
if (! user) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid token',
})
}
event.context.user = user
})
The authenticated user is attached to event.context.user and available in any handler that runs after this middleware.
Using the Context in Handlers
// server/api/admin/dashboard.get.ts
export default defineEventHandler(async (event) => {
const user = event.context.user
return {
message: `Welcome back, ${user.name}`,
stats: await getDashboardStats(user.id),
}
})
Clean and simple. No passing the user around manually.
Connecting to a Database with Drizzle
Nitro doesn't ship with a database layer, but Drizzle ORM is a great pairing. Install it and your driver of choice:
npm install drizzle-orm mysql2
npm install -D drizzle-kit
Create a database client as a Nitro plugin so it's initialised once:
// server/plugins/database.ts
import { drizzle } from 'drizzle-orm/mysql2'
import mysql from 'mysql2/promise'
declare module 'nitropack' {
interface NitroApp {
db: ReturnType<typeof drizzle>
}
}
export default defineNitroPlugin(async (nitroApp) => {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
})
nitroApp.db = drizzle(connection)
})
Access it in a handler:
// server/api/posts/index.get.ts
import { posts } from '~/server/schema'
export default defineEventHandler(async (event) => {
const db = useNitroApp().db
const allPosts = await db.select().from(posts).limit(20)
return allPosts
})
Calling Server Routes from Components
From the frontend, use useFetch or $fetch — both are built into Nuxt and handle the base URL automatically:
<script setup>
const { data: users, pending, error } = await useFetch('/api/users')
</script>
<template>
<div v-if="pending">Loading...</div>
<ul v-else>
<li v-for="user in users.data" :key="user.id">
{{ user.name }}
</li>
</ul>
</template>
On the server during SSR, Nuxt short-circuits the HTTP call and calls the handler directly — no actual network request. On the client it goes over HTTP as you'd expect.
Final Thoughts
Nuxt 3 server routes are genuinely one of its best features for full-stack work. You get a typed, file-based API with middleware support, built-in error handling, and seamless integration with your Vue components — all without spinning up a separate backend.
For smaller projects or internal tools, this alone might be all you need. For larger apps, you can still reach for Laravel or another API if you need the full power, but don't underestimate how far Nuxt's server layer can take you.