Nuxt Server Routes Explained: How Nitro Builds Your API
Nuxt server routes turn server/api into a real backend via Nitro and h3. Learn routing rules, defineEventHandler, middleware order, and the useState trap.

- 1Nuxt useState vs ref(): Why Server State Leaks Across Users14 min
- 2useAsyncData Keys in Nuxt: Caching, Dedupe & the Sharing Bug14 min
- 3Nuxt Hydration Mismatch: Why It Happens and How to Fix It14 min
- 4Nuxt Server Routes Explained: How Nitro Builds Your APIyou are here
Open any Nuxt project and there's a good chance a server/api folder is already sitting in it — a hello.ts here, a login.post.ts there. Ask most people what it is and the answer is usually "the API routes." Ask what actually runs those files, in what order, and whether they can use the same useState or useRoute composables as the rest of the app, and the answers get much shakier. That gap is where the interesting bugs live: a middleware that silently runs before the one it's supposed to follow, an event handler that dies with useState is not defined for no obvious reason, a readBody() that comes back empty.
This article is written against Nuxt 4.x (verified against the v4.5 release line, August 2026 — Nuxt 3 reached end-of-life on July 31, 2026). The directory names below assume the server/ layout, which — unlike pages/, components/, and the rest of your app code — stays at the project root in both Nuxt 3 and Nuxt 4's new app/-nested structure. If you've read the hydration mismatch episode or the useState vs ref episode, this one picks up the other half of "what runs where": not the Vue app rendering twice, but the completely separate server world sitting next to it.
By the end of this article you'll be able to:
- Explain what Nitro and
server/api/server/routes/server/middlewareactually are, and how a filename becomes a route - Read and write request data correctly with
getQuery,getRouterParam, andreadBody - Predict the real execution order of your server middleware — not the order you assume
- Explain why Vue composables like
useStatedon't work inside a server route, and what to use instead - Know when calling your own API route with
useFetchduring SSR involves the network at all
You've built Nuxt pages and components, and you've probably already dropped a file into server/api and had it work. You don't need prior backend framework experience — this article treats Nitro as its own subject, not "Express with different syntax."
- The problem: it looks like the rest of your app, but it isn't
- The mental model: two runtimes, one project
- Building server routes, stage by stage
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
Say you want a small /api/profile endpoint that returns the current user, and — since you already have a useState('user') that holds the logged-in user everywhere else in the app — reusing it here feels natural:
Run it, and instead of JSON you get a 500: useState is not defined. It's the exact composable you use in every .vue file, so it reads like a missing import — but it isn't one. server/ gets its own auto-imports (h3 helpers, Nitro utilities, server/utils), and Vue composables aren't among them; import it from #app explicitly and the build refuses with "Vue app aliases are not allowed in server runtime." The problem is where it's being called from. useState, useRoute, useFetch — the whole family of Nuxt composables — depend on there being a current Nuxt application instance to attach to. A server/api file has no such thing. It's not part of the Vue app at all; it's a plain request handler that Nitro invokes directly, with nothing Vue-shaped anywhere near it.
This is the trap the "Nuxt is just Vue with routing" mental model sets. server/api looks like it belongs to the same app as your pages because it lives in the same repo, ships in the same deploy, and even shares the same nuxt dev process — but it's a different runtime with a different lifecycle, and the rules that make composables work don't apply there.
A Nuxt project is really two separate request-handling worlds glued together at build time:
The Vue/app world. Pages, components, layouts, and composables. Every request for a page spins up a Nuxt application instance (server-side, then again client-side for hydration), and that instance is what useState, useRoute, and friends attach themselves to. This is the world the last two episodes of this series lived in.
The Nitro/h3 world. server/api, server/routes, and server/middleware. Nitro is the server engine Nuxt is built on — it's what starts the process, decides which file handles which URL, and runs each matched file as a plain function that receives an H3Event (from h3, the tiny HTTP toolkit Nitro is built around) and returns a value. There is no component tree here, no "current instance," nothing for a Vue composable to hook into. It doesn't know or care that a Vue app exists elsewhere in the same process.
The two worlds do talk to each other, but only across an explicit boundary: a page calls useFetch('/api/profile') or $fetch('/api/profile'), which sends a request that Nitro routes to your server/api/profile.get.ts handler exactly like it would route a request from curl or a browser tab. The response crosses back as plain, serializable data — never a live object, never a shared reference, never a ref. Whatever you build inside a server route has to assume it's talking to some client, not sharing memory with one.
Key concept: if you can't point to the .vue file or component setup() a piece of code runs inside, it isn't in the Vue world — and a server/api, server/routes, or server/middleware file never is.
Nitro turns server/api and server/routes into a router by convention, no manual registration:
server/api/hello.ts→ matches any method at/api/helloserver/api/hello.get.ts→ matches onlyGET /api/hello;hello.post.tsonlyPOSTserver/api/users/[id].ts→ dynamic segment, matched withgetRouterParam(event, 'id')server/api/files/[...slug].ts→ catch-all, everything after/files/lands ingetRouterParam(event, 'slug')(an unnamed catch-all,[...].ts, lands inevent.context.params._instead)server/routes/robots.txt.ts→ same rules, but no automatic/apiprefix — useful for exact, non-API paths likerobots.txt,sitemap.xml, or a webhook URL a third party expects at a fixed path
Every handler is wrapped in defineEventHandler, and gets one H3Event to work with:
Whatever you return — an object, an array, a string — gets serialized to the right response automatically (JSON for objects and arrays; strings sent as-is, with a text/html content type unless you set one). createError is the correct way to fail: it sets the real HTTP status and gives the client a structured error body, instead of a generic 500 from an uncaught throw. For a POST/PUT body, readBody(event) parses it based on the request's content type — JSON, form-encoded, or plain text.
server/middleware/*.ts files run before every request Nitro handles — not just /api/*, but page requests too, since a page request is also something Nitro routes. A middleware doesn't return a response (to end a request early, throw createError instead of returning); it inspects or mutates the request and lets it continue, usually by writing to event.context so a later handler can read it:
The order these run in is alphabetical by filename, sorted as a string — not the order you created them in, and not numeric order either. "10.rate-limit.ts" sorts before "2.legacy.ts", because string comparison looks at the character '1' before it ever gets to '2'. If you need explicit ordering, zero-pad: 01., 02., 03. — never bare 1., 2., 10..
When a page calls useFetch('/api/profile') (or the plain $fetch it's built on) while rendering on the server, Nitro doesn't open a real HTTP connection to itself. It recognizes the request is for one of its own routes and calls the matching function directly, in-process — this is documented, intentional behavior, not an implementation detail you're relying on by accident. The same call from the browser, after hydration, does go over real HTTP, because at that point there's no server process to short-circuit into. useFetch also writes the server-side result into the page's payload, so the client doesn't refetch it on hydration — the same payload mechanism the hydration-mismatch episode in this series covers in more depth.
The numeric-prefix sort trap isn't limited to server/middleware. Global route middleware (files ending .global.ts, which run in the Vue/app world, not Nitro's) follow the exact same alphabetical-string rule. If you've zero-padded one and not the other, you now have two different, easy-to-miss ordering bugs in the same project.
event.context.params can be typed as possibly-undefined even on a route where a dynamic segment guarantees it exists, because the type comes from the general Nitro types, not your specific route. Prefer getRouterParam(event, 'id') over reaching into event.context.params directly — it reads the same value with a cleaner, purpose-built API.
defineCachedEventHandler and readBody don't currently mix well — the cached handler's event type deliberately omits body, and the cache key is built from the URL (plus any varies headers), never the body — so two different POST bodies would share one cached response. Don't cache routes whose output depends on the request body.
A server/api route your page never calls directly is still public. There's no implicit auth boundary between "routes I use internally" and "routes anyone can hit" — every file under server/api is a real, reachable HTTP endpoint the moment it ships, whether or not any of your own pages ever call it.
- Never reach for a Vue composable inside a server route. If server-side logic needs to be shared between multiple handlers, put it in
server/utils/as a plain function — it's auto-imported insideserver/, same as composables are insideapp/, but it's just a function, not something tied to a Vue instance. - Zero-pad any filename whose order matters —
01.auth.ts,02.logging.ts— so a later teammate adding03.rate-limit.tsdoesn't silently jump ahead of2.something.tsthat was never renumbered. - Validate input at the top of the handler, before touching a database or an external API —
readValidatedBodywith a schema (Zod or otherwise) turns a malformed request into a clean 400 instead of a confusing failure three lines deeper. - Keep secrets out of the public runtime config.
nuxt.config'sruntimeConfig(server-only) versusruntimeConfig.public(shipped to the client bundle) is the one line standing between an API key and every visitor's browser dev tools — a server route can safely read the private half; in a page component the private keys exist only during the server render and never reach the browser — so never render them or put them inuseState. - Treat every
server/apifile as a public endpoint from the day it's created, and add auth/validation before the first real feature depends on it, not after.
No — those composables require a live Nuxt application instance, which only exists in the Vue/app world (pages, components, plugins). A server/api/server/routes/server/middleware file runs as a plain Nitro/h3 handler with no such instance. Share logic through server/utils/ instead.
Identical routing rules (filenames, method suffixes, dynamic segments) — the only difference is that server/api files are automatically prefixed with /api, and server/routes files are not. Use server/routes for paths that need to be exact, like /robots.txt or a fixed webhook URL.
server/middleware files run in alphabetical order of their filename, sorted as a string — creation order and file-tree position don't matter. Rename the files with zero-padded numeric prefixes (01.auth.ts, 02.logging.ts) to force the order you want.
Only from the browser. During SSR, Nitro recognizes the target is one of its own routes and calls the handler function directly, in the same process — no HTTP round trip. After hydration, the same call from the browser does go over the network like any other request.
Not for a segment your filename guarantees — [id].ts will always have an id on a matched request, even though its TypeScript type may be looser than that. Use getRouterParam(event, 'id') (still typed string | undefined) or getValidatedRouterParams with a schema when you want a guaranteed, typed value.
| Want to... | Do this |
|---|---|
Match any method at /api/x | server/api/x.ts |
Match only GET/POST/etc. | server/api/x.get.ts / x.post.ts |
| Match a dynamic segment | server/api/x/[id].ts → getRouterParam(event, 'id') |
| Match a catch-all | server/api/x/[...slug].ts → getRouterParam(event, 'slug') |
Serve a path with no /api prefix | server/routes/robots.txt.ts |
| Run code before every request | server/middleware/NN.name.ts (zero-padded prefix) |
| Pass data from middleware to a handler | event.context.yourKey = value |
| Read the query string / body | getQuery(event) / readBody(event) |
| Fail with a real HTTP status | throw createError({ status, statusText }) |
| Share logic between server routes | a plain function in server/utils/ |
| Keep a value out of the client bundle | runtimeConfig (not .public) in nuxt.config |
Runs right in your browser — poke at it and watch the concept react live.
server/api,server/routes, andserver/middlewarerun in Nitro — a separate request-handling world from the Vue app your pages render in, with no component instance and no access to composables likeuseStateoruseRoute.- Routing is entirely filename-driven: the path, the HTTP method, dynamic segments, and catch-alls are all decided by how you name the file, not by any registration code.
server/middlewareorder is alphabetical string-sort of the filename, not creation order and not numeric order — zero-pad any prefix that has to hold a specific position.- Calling your own API route with
useFetchduring SSR skips the network and calls the function directly; the same call from the browser after hydration is a real HTTP request.
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
That /api/profile handler from the top of the article has an honest fix now: drop the useState call, read the user from event.context (set by an auth middleware upstream), and return plain data. Nothing about the fix is exotic — it's just respecting that the file it lives in was never part of the Vue app to begin with.
Next time a server route throws useState is not defined, or a middleware runs in an order you didn't expect, you'll know exactly which of the two worlds you're standing in — and that's most of the debugging done before you've even opened the stack trace.
- Nuxt Hydration Mismatch: Why It Happens and How to Fix It
- useAsyncData Keys in Nuxt: Caching, Dedupe & the Sharing Bug
- Nuxt useState vs ref(): Why Server State Leaks Across Users
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.