Skip to main content

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 .ts file in server/api/ and Nuxt registers it as an endpoint automatically
  • Wrap every handler with defineEventHandler (required by H3, Nuxt's server engine)
  • Add .get.ts or .post.ts to the filename to restrict a route to one HTTP method
  • Read request data with getQuery(event), readBody(event), and getRouterParam(event, 'name')
  • Throw errors through createError({ statusCode: 404, message: 'Not found' })

Quick example

typescript
// 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/users
  • server/api/users/index.ts/api/users
  • server/api/users/[id].ts/api/users/:id (all methods)
  • server/api/users/[id].get.tsGET /api/users/:id
  • server/api/users/[id].delete.tsDELETE /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:

typescript
// 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

typescript
const body = readBody(event) // Bug: this is a Promise const body = await readBody(event) // Correct

2. Manual method check instead of per-method files

typescript
// 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

typescript
throw new Error('Not found') // Wrong: status 500, leaks stack trace throw createError({ statusCode: 404, message: 'Not found' }) // Correct

4. Using event.context.params instead of getRouterParam

typescript
const id = event.context.params?.id // Works, not the recommended H3 API const id = getRouterParam(event, 'id') // Preferred

Both 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:

typescript
// 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

typescript
// 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

typescript
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?