Skip to content
TutorialNuxt

Your first API route in Nuxt's server/ folder

Drop a file into server/api/ and Nuxt serves it as an endpoint. Three handlers make a small todos API, and one page reads and writes it with useFetch and $fetch.

vuejs.school team15 May 20256 min readNuxt 3.17 · Nitro 2.11 · h3 1.15

Some code belongs on a server, not in the browser: a database query, a call to a paid API with a secret key, or a step that shapes data before the page sees it. Nuxt has a place for it. Next to the Vue part, which runs in the browser, every Nuxt app carries a second program in its server/ folder, and any file you put in server/api/ becomes an endpoint your pages can call.

You will build a small todos API in that folder, with GET and POST handlers, validation and a route parameter, then a page that lists the todos and adds new ones.

Where server code runs, and why it can hold secrets

Nitro, the server engine Nuxt is built on, bundles everything under server/ into the server build. That build runs on Node, or on an edge runtime if you deploy to one. Files under pages/ and components/ are compiled for the browser and shipped to it.

The line between the two is the folder. Code under server/ reaches the browser only if a page or component imports it, and Nuxt raises an error when one imports a file from server/api/, server/routes/ or server/middleware/.

That is why secrets live in server/. To keep an API key there:

  1. Put NUXT_API_SECRET=... in .env at the project root.
  2. In nuxt.config.ts, declare apiSecret: '' under runtimeConfig, not under runtimeConfig.public, whose values are sent to the browser.
  3. Inside a handler, read it with useRuntimeConfig(event).apiSecret.

A visitor can view the page source all day and will not find it.

npm run dev loads .env for you, but the built server does not read it. In production, set NUXT_API_SECRET in the host's environment variables instead.

Write a GET handler

Start with the data. Nitro auto-imports anything exported from server/utils/, so a module there is a convenient place for the list and its type:

server/utils/todos.ts
export interface Todo {
  id: number
  title: string
  done: boolean
}

export const todos: Todo[] = [
  { id: 1, title: 'Read the Nuxt server docs', done: true },
  { id: 2, title: 'Write the first handler', done: false },
]

An in-memory array resets when the server restarts and is shared by every visitor. That is fine for learning; a real app swaps it for a database call and nothing else in this post changes.

Now create server/api/todos.get.ts. The .get before the extension is the method suffix: this file answers GET /api/todos and nothing else.

server/api/todos.get.ts
export default defineEventHandler((event) => {
  const query = getQuery(event)
  if (query.done === 'true') return todos.filter((t) => t.done)
  if (query.done === 'false') return todos.filter((t) => !t.done)
  return todos
})

defineEventHandler wraps a function that receives the event, an object holding the request and the response. Whatever you return is sent as JSON with the right content type.

getQuery parses the query string, so /api/todos?done=true gives { done: 'true' }. Every value arrives as a string, which is why the handler compares with 'true' and not true.

To check it, run npm run dev and open http://localhost:3000/api/todos in the browser. You see the array as JSON; add ?done=true and only the finished todo is left.

Add a POST handler that validates its body

server/api/todos.post.ts answers POST /api/todos. It reads the body, checks it, and either creates a todo or answers with a 400:

server/api/todos.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody<{ title?: unknown }>(event)
  const title = typeof body?.title === 'string' ? body.title.trim() : ''

  if (!title) {
    throw createError({ statusCode: 400, statusMessage: 'Title is required' })
  }

  const todo: Todo = { id: Date.now(), title, done: false }
  todos.push(todo)
  setResponseStatus(event, 201)
  return todo
})

What each part does:

  • readBody parses the JSON body. Parsing is asynchronous, so the handler is async.
  • Never trust the shape of a body. The type argument says title might be anything, and the typeof check turns it into a string or rejects it.
  • createError builds an error with a status. Throwing it ends the request: the server answers with that status and a JSON body that includes your statusMessage.
  • setResponseStatus changes the success status from the default 200 to 201, the code for "created".

Test it from a terminal:

curl -X POST http://localhost:3000/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Try curl"}'

Send {} instead and you get a 400 with Title is required.

Read a route parameter

A file named server/api/todos/[id].get.ts answers GET /api/todos/1, GET /api/todos/2 and so on. The square brackets make id a parameter, and getRouterParam reads it:

server/api/todos/[id].get.ts
export default defineEventHandler((event) => {
  const id = Number(getRouterParam(event, 'id'))
  const todo = todos.find((t) => t.id === id)
  if (!todo) {
    throw createError({ statusCode: 404, statusMessage: 'Todo not found' })
  }
  return todo
})

Parameters arrive as strings, like query values, so Number() converts before the comparison. A missing todo is a 404, not an empty object; the page that asks for it can then show a proper not-found state.

Call the API from a page

Two tools call the API from the Vue side, and they do different jobs:

useFetch()

Data the page needs in order to render. During server-side rendering it runs on the server, and the result travels to the browser in the page payload, so the browser does not fetch it again on load.

$fetch

Things a person does: submit, delete, refresh. Call it from a function that runs on a click or a submit, never at the top of <script setup>, where it would run once on the server and once in the browser.

This page uses both: useFetch loads the list, and $fetch sends a new todo.

pages/todos.vue
<script setup lang="ts">
const { data: todos, refresh } = await useFetch('/api/todos')

const title = ref('')
const error = ref('')

async function add() {
  error.value = ''
  try {
    await $fetch('/api/todos', { method: 'POST', body: { title: title.value } })
    title.value = ''
    await refresh()
  } catch (e) {
    const fetchError = e as { data?: { statusMessage?: string } }
    error.value = fetchError.data?.statusMessage ?? 'Could not save'
  }
}
</script>

<template>
  <main>
    <h1>Todos</h1>
    <ul>
      <li v-for="todo in todos" :key="todo.id">
        {{ todo.done ? 'Done' : 'To do' }}: {{ todo.title }}
      </li>
    </ul>
    <form @submit.prevent="add">
      <label for="title">New todo</label>
      <input id="title" v-model="title" />
      <button type="submit">Add</button>
    </form>
    <p v-if="error" role="alert">{{ error }}</p>
  </main>
</template>

After a successful POST, refresh() runs the useFetch again so the list shows the new item.

When the server answers 400, $fetch throws an error whose statusCode is 400 and whose data is the JSON body the server sent. The catch reads statusMessage from that body, and the page shows "Title is required" below the form.

Read the message from data, not from the error's own statusMessage. That one is copied from the HTTP status line, which has no text over HTTP/2, so on a host that serves HTTP/2 the page would show no message at all.

How the page gets its types

Nothing in that page declares a Todo type, yet todo.title autocompletes and todo.titel is an error. Nuxt reads the return type of every handler under server/api/ and builds a map from route and method to type. useFetch('/api/todos') is typed from the GET handler, so todos is a ref to an array of Todo or null. $fetch('/api/todos', { method: 'POST' }) is typed from the POST handler. Change the Todo interface in server/utils/todos.ts and the page's types follow.

The inference has two limits:

  • It works for a string literal URL. A URL built at runtime loses it, and you pass the type yourself with $fetch<Todo>(url).
  • It describes the data after JSON serialisation, so a Date returned from a handler shows up as a string on the page.

Run npx nuxi typecheck to see these types enforced across the whole project.

Pick server/api, server/routes or server/middleware

Nuxt registers handlers from three folders, and the folder decides the URL. A file in server/api/ is served under /api/; use it for data your own pages ask for. A file in server/routes/ is served from the root: server/routes/healthz.get.ts answers GET /healthz. That suits URLs the outside world asks for, such as a health check, a robots.txt or an RSS feed, where /api/ in the path would look odd.

server/middleware/ is different. Every file there runs on every request, before any route handler, and it should not answer the request itself. Use it to log, or to read a cookie and put the result on event.context for handlers to pick up:

server/middleware/log.ts
export default defineEventHandler((event) => {
  console.log(`${event.method} ${event.path}`)
})
Check it stuck3 questions from the Server routes quiz.
Practice now
Was this helpful?

Keep reading