---
title: "Your first API route in Nuxt's server/ folder · vuejs.school"
description: "Write GET and POST handlers in Nuxt's server folder with defineEventHandler, read bodies, queries and params, return typed data, and call it all from a page."
canonical_url: "https://vuejs.school/community/blog/your-first-api-route-in-nuxt"
meta:
  "og:description": "Write GET and POST handlers in Nuxt's server folder with defineEventHandler, read bodies, queries and params, return typed data, and call it all from a page."
  "og:image": "https://vuejs.school/_og/d/a_~WW91ciBmaXJzdCBBUEkgcm91dGUgaW4gTnV4dCdzIHNlcnZlci8gZm9sZGVyIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~WW91ciBmaXJzdCBBUEkgcm91dGUgaW4gTnV4dCdzIHNlcnZlci8gZm9sZGVy%2Cdescription_~V3JpdGUgR0VUIGFuZCBQT1NUIGhhbmRsZXJzIGluIE51eHQncyBzZXJ2ZXIgZm9sZGVyIHdpdGggZGVmaW5lRXZlbnRIYW5kbGVyLCByZWFkIGJvZGllcywgcXVlcmllcyBhbmQgcGFyYW1zLCByZXR1cm4gdHlwZWQgZGF0YSwgYW5kIGNhbGwgaXQgYWxsIGZyb20gYSBwYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy95b3VyLWZpcnN0LWFwaS1yb3V0ZS1pbi1udXh0Ig%2Cs_qfD9Cnw9UBe6fjJt.png"
  "og:image:alt": "Your first API route in Nuxt's server/ folder · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Your first API route in Nuxt's server/ folder · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~WW91ciBmaXJzdCBBUEkgcm91dGUgaW4gTnV4dCdzIHNlcnZlci8gZm9sZGVyIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~WW91ciBmaXJzdCBBUEkgcm91dGUgaW4gTnV4dCdzIHNlcnZlci8gZm9sZGVy%2Cdescription_~V3JpdGUgR0VUIGFuZCBQT1NUIGhhbmRsZXJzIGluIE51eHQncyBzZXJ2ZXIgZm9sZGVyIHdpdGggZGVmaW5lRXZlbnRIYW5kbGVyLCByZWFkIGJvZGllcywgcXVlcmllcyBhbmQgcGFyYW1zLCByZXR1cm4gdHlwZWQgZGF0YSwgYW5kIGNhbGwgaXQgYWxsIGZyb20gYSBwYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy95b3VyLWZpcnN0LWFwaS1yb3V0ZS1pbi1udXh0Ig%2Cs_qfD9Cnw9UBe6fjJt.png"
  "twitter:image:alt": "Your first API route in Nuxt's server/ folder · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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 stuck**3 questions from the Server routes quiz.

[**Practice now**](https://vuejs.school/learn/nuxt/server-routes?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 7 min read**What's new in Nuxt 4.6: nuxt/server, typed $fetch, cookie sessions and Vapor**](https://vuejs.school/community/blog/whats-new-in-nuxt-4-6) [Tutorial · 6 min read**useFetch or $fetch? Picking the right one in Nuxt**](https://vuejs.school/community/blog/usefetch-or-fetch-picking-the-right-one-in-nuxt)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/your-first-api-route-in-nuxt#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-05-15","description":"Write GET and POST handlers in Nuxt's server folder with defineEventHandler, read bodies, queries and params, return typed data, and call it all from a page.","name":"Your first API route in Nuxt's server/ folder","url":"https://vuejs.school/community/blog/your-first-api-route-in-nuxt","breadcrumb":{"@id":"https://vuejs.school/community/blog/your-first-api-route-in-nuxt#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/your-first-api-route-in-nuxt"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/your-first-api-route-in-nuxt#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-05-15","description":"Write GET and POST handlers in Nuxt's server folder with defineEventHandler, read bodies, queries and params, return typed data, and call it all from a page.","headline":"Your first API route in Nuxt's server/ folder","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~WW91ciBmaXJzdCBBUEkgcm91dGUgaW4gTnV4dCdzIHNlcnZlci8gZm9sZGVyIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~WW91ciBmaXJzdCBBUEkgcm91dGUgaW4gTnV4dCdzIHNlcnZlci8gZm9sZGVy%2Cdescription_~V3JpdGUgR0VUIGFuZCBQT1NUIGhhbmRsZXJzIGluIE51eHQncyBzZXJ2ZXIgZm9sZGVyIHdpdGggZGVmaW5lRXZlbnRIYW5kbGVyLCByZWFkIGJvZGllcywgcXVlcmllcyBhbmQgcGFyYW1zLCByZXR1cm4gdHlwZWQgZGF0YSwgYW5kIGNhbGwgaXQgYWxsIGZyb20gYSBwYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy95b3VyLWZpcnN0LWFwaS1yb3V0ZS1pbi1udXh0Ig%2Cs_qfD9Cnw9UBe6fjJt.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/your-first-api-route-in-nuxt#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/your-first-api-route-in-nuxt#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/your-first-api-route-in-nuxt#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Your first API route in Nuxt's server/ folder","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~WW91ciBmaXJzdCBBUEkgcm91dGUgaW4gTnV4dCdzIHNlcnZlci8gZm9sZGVyIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~WW91ciBmaXJzdCBBUEkgcm91dGUgaW4gTnV4dCdzIHNlcnZlci8gZm9sZGVy%2Cdescription_~V3JpdGUgR0VUIGFuZCBQT1NUIGhhbmRsZXJzIGluIE51eHQncyBzZXJ2ZXIgZm9sZGVyIHdpdGggZGVmaW5lRXZlbnRIYW5kbGVyLCByZWFkIGJvZGllcywgcXVlcmllcyBhbmQgcGFyYW1zLCByZXR1cm4gdHlwZWQgZGF0YSwgYW5kIGNhbGwgaXQgYWxsIGZyb20gYSBwYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy95b3VyLWZpcnN0LWFwaS1yb3V0ZS1pbi1udXh0Ig%2Cs_qfD9Cnw9UBe6fjJt.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~WW91ciBmaXJzdCBBUEkgcm91dGUgaW4gTnV4dCdzIHNlcnZlci8gZm9sZGVyIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~WW91ciBmaXJzdCBBUEkgcm91dGUgaW4gTnV4dCdzIHNlcnZlci8gZm9sZGVy%2Cdescription_~V3JpdGUgR0VUIGFuZCBQT1NUIGhhbmRsZXJzIGluIE51eHQncyBzZXJ2ZXIgZm9sZGVyIHdpdGggZGVmaW5lRXZlbnRIYW5kbGVyLCByZWFkIGJvZGllcywgcXVlcmllcyBhbmQgcGFyYW1zLCByZXR1cm4gdHlwZWQgZGF0YSwgYW5kIGNhbGwgaXQgYWxsIGZyb20gYSBwYWdlLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy95b3VyLWZpcnN0LWFwaS1yb3V0ZS1pbi1udXh0Ig%2Cs_qfD9Cnw9UBe6fjJt.png"}]}
```