How to create API routes in Nuxt?
Nuxt API routes are server-side endpoints that live in server/api/ and map automatically to /api/* URLs. No separate server, no router config. The file path is the route.
Theory
TL;DR
- Drop a
.tsfile inserver/api/and Nuxt registers it as an endpoint automatically - Wrap every handler with
defineEventHandler(required by H3, Nuxt's server engine) - Add
.get.tsor.post.tsto the filename to restrict a route to one HTTP method - Read request data with
getQuery(event),readBody(event), andgetRouterParam(event, 'name') - Throw errors through
createError({ statusCode: 404, message: 'Not found' })
Quick example
// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({ statusCode: 400, message: 'Missing id' })
}
return { id, name: 'Alice' }
})
// GET /api/users/42 → { id: '42', name: 'Alice' }
// POST /api/users/42 → 405 Method Not Allowed (automatic)The .get.ts suffix locks this file to GET requests. Any other method returns 405 without any extra code from you.
File naming and URL mapping
Nuxt reads the file path and builds the URL directly:
server/api/users.ts→/api/usersserver/api/users/index.ts→/api/usersserver/api/users/[id].ts→/api/users/:id(all methods)server/api/users/[id].get.ts→GET /api/users/:idserver/api/users/[id].delete.ts→DELETE /api/users/:id
Bracket syntax [id] marks a dynamic segment. Multiple params work in a single path too: [userId]/posts/[postId].ts.
Reading request data
Three helpers cover most cases:
// Query string: GET /api/search?q=nuxt&limit=10
const query = getQuery(event) // { q: 'nuxt', limit: '10' }
// Request body: POST /api/users
const body = await readBody(event) // { name: 'Bob', email: '...' }
// Route param: GET /api/users/42
const id = getRouterParam(event, 'id') // '42'readBody is async. Forgetting await returns a Promise object, not your data. That is the most common bug in new Nuxt projects.
Where server routes run
Server routes execute in Nitro, Nuxt's server engine built on H3. This is a Node.js environment, not a browser. You have access to process.env, useRuntimeConfig(), and Nitro storage. You do not have window, document, or any Vue reactivity. Mixing those up causes failures that are painful to debug.
Common mistakes
1. Skipping await on readBody
const body = readBody(event) // Bug: this is a Promise
const body = await readBody(event) // Correct2. Manual method check instead of per-method files
// Works, but not recommended
export default defineEventHandler(async (event) => {
if (event.method === 'GET') { ... }
if (event.method === 'POST') { ... }
})Better: create users.get.ts and users.post.ts. Each file is smaller, easier to test, and TypeScript gets accurate types per method.
3. Throwing a raw Error
throw new Error('Not found') // Wrong: status 500, leaks stack trace
throw createError({ statusCode: 404, message: 'Not found' }) // Correct4. Using event.context.params instead of getRouterParam
const id = event.context.params?.id // Works, not the recommended H3 API
const id = getRouterParam(event, 'id') // PreferredBoth work today. getRouterParam handles edge cases in nested routes more reliably.
Calling routes from Nuxt pages
From any component or page, use useFetch or $fetch:
// pages/users/[id].vue
const { data } = await useFetch(`/api/users/${id}`)useFetch is SSR-aware. On the server it calls the handler in-process, skipping the HTTP layer entirely. On the client it makes a real HTTP request. I've seen this behavior confuse developers who profiled the app and expected a network call in both environments.
Follow-up questions
Q: What is the difference between server/api/ and server/routes/?
A: Files in server/api/ get the /api prefix automatically. Files in server/routes/ map to any URL without that prefix. Use server/routes/ for webhooks, /sitemap.xml, or any endpoint that should not live under /api.
Q: How do you share logic between multiple API routes?
A: Extract it into a function in server/utils/. Nitro auto-imports everything from that folder, so you can call the function in any handler without an explicit import.
Q: Can you protect API routes with authentication middleware?
A: Yes. Create a file in server/middleware/ that validates the token and sets event.context.user. Each route handler then reads event.context.user without repeating the auth check.
Q: How do you return a custom HTTP status code like 201?
A: Call setResponseStatus(event, 201) before returning. For errors, pass the status code directly to createError({ statusCode: 422, message: '...' }).
Examples
Minimal GET endpoint
// server/api/ping.get.ts
export default defineEventHandler(() => {
return { status: 'ok', timestamp: Date.now() }
})
// GET /api/ping → { status: 'ok', timestamp: 1720000000000 }No async here because there is no I/O. The returned object is serialized to JSON automatically by Nitro.
POST endpoint with body validation
// server/api/users.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody<{ name: string; email: string }>(event)
if (!body.name || !body.email) {
throw createError({
statusCode: 422,
message: 'name and email are required',
})
}
const user = await db.users.create({ data: body })
return user
})The generic on readBody<T> tells TypeScript the shape of body, so body.name and body.email get autocomplete and type checking throughout the handler.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.