Skip to content

Tier 2a · Chapter 4: API & Server Routes in Nitro

JS/TS Fullstack (Nuxt) Track · ~30 min read + exercise


Why this chapter exists

The last chapter established that client data needs should flow through your server layer; this one is about designing that server layer well. In a Nuxt app the server layer is Nitro server routes — the files in server/api/ and server/routes/ that handle requests — and they are a real API, deserving the same contract discipline as any backend's API, even when the only consumer is your own frontend. The temptation, precisely because the consumer is "just our own client," is to treat server routes as casual glue and skip the rigor — inconsistent shapes, ad-hoc errors, no clear contract — which works until the app grows, a second consumer appears (a mobile app, a partner), or a developer six months later can't tell what a route accepts and returns. This chapter is about giving Nitro routes the contract, validation, and error discipline that keep the server layer maintainable.

The throughline: your Nitro server routes are an API contract, and the work is making that contract explicit, validated, and consistent — even though your own frontend is the main consumer. "It's just our own API" is not a reason to skip the discipline; it's the reason the discipline pays off, because you control both sides and can keep them rigorously in sync (the shared-types superpower from chapter 1). A well-designed server layer is one where every route's input is validated, every response has a predictable shape, every error is consistent, and the contract is a single source of truth the client is type-checked against.

What this chapter covers

  • Server route structure — organizing Nitro routes as a coherent API, not scattered glue.
  • Validation at every route — the trust boundary discipline (chapter 1) applied systematically.
  • Consistent responses and errors — one shape for success, one for failure, across all routes.
  • The contract and the client — leveraging shared types so the contract is enforced end to end.

Server route structure

Nitro routes are files: server/api/tickets.get.ts handles GET /api/tickets, server/api/tickets/[id].patch.ts handles PATCH /api/tickets/:id, and so on — the filename encodes the path and method. This file-based routing is convenient, and the architectural risk is that convenience leads to a sprawl of route files each doing everything inline (validation, business logic, data access, response shaping all in one handler) — the fat-controller anti-pattern from the foundations, in Nitro form.

The discipline is the same as any well-structured API: the route handler is a thin edge; the business logic lives behind it. A Nitro handler should validate input, call into a service/use-case function (which lives in a server utility module, not in the route file), and shape the response. The business logic — the rules about creating or resolving a ticket — belongs in server/utils/ or a server services module, callable from the route, from a different route, from a scheduled task, testable without HTTP. The route file is the HTTP adapter; the logic is separate:

typescript
// server/api/tickets.post.ts — thin route: validate, delegate, respond
import { createTicket } from "~/server/services/ticketService";
import { CreateTicket } from "~/shared/ticket";            // shared Valibot/Zod schema

export default defineEventHandler(async (event) => {
  const input = await readValidatedBody(event, CreateTicket); // parse + narrow, or 400
  const ticket = await createTicket(input);                   // delegate to the service
  return ticket;                                              // shape/return the response
});

This is the layering lesson (the boundaries from the foundations, and the PHP track's controller-vs-service split) applied to Nitro: keep the route thin and the logic in a callable, testable unit behind it. As the server layer grows, this is what keeps it navigable — routes are a thin map of the API surface, and the logic lives in an organized set of services the routes call.

A thin route is only half the win, though. The layer it delegates to has to earn its place, and the test for that comes from Ousterhout's A Philosophy of Software Design: a good module is deep — its interface is much simpler than the work it hides. A createTicket(input) that enforces real rules (validation beyond shape, assignment logic, emitting a TicketCreated event) behind a one-line call is deep: callers get a lot of hidden machinery through a small interface. A "service" whose every method forwards one-to-one to the ORM (createTicket.create, findTicket.findById) is shallow — a pass-through that adds a layer and a file without hiding anything, so it's pure cost. The point of separating logic from the route isn't the separation itself; it's that the thing behind the route hides enough to be worth calling. If a service method's body is one line that just forwards its arguments, that's the pass-through smell — either let the route make the call directly, or the real logic hasn't been found yet.


Validation at every route

Every server route is a trust boundary (chapter 1), so validation isn't optional per-route politeness — it's the systematic perimeter of your server layer. The discipline established in chapter 1 applies to every route: parse the input (body, query, params) against a shared schema with h3's readValidatedBody / getValidatedQuery, producing a trusted typed value or a clean failure, never a cast.

The points that make this systematic rather than ad-hoc:

Validate the whole input surface on every route. Body, query parameters, route params, and any headers you act on — all client-controlled, all validated. The common gap is validating bodies carefully while trusting query params or route IDs; a route that does getRouterParams(event).id and passes it straight to a database query without validation is the kind of hole that becomes an injection or an error. Validate everything you act on.

Make validation a consistent, hard-to-skip pattern. Rather than hand-rolling checks differently in every route, lean on h3's readValidatedBody / getValidatedQuery with a shared schema — the validated helpers are the one pattern, so every route validates the same way and a new route can't accidentally skip it by reaching for the raw readBody. (Keep the schemas in shared/ so the same one drives the form; the architectural point is one consistent, shared definition, whatever the library.) This is the "validation as middleware" idea from any API, in Nitro form.

Validate external responses inside routes too. A server route that calls an external API must validate that response before trusting it (chapter 1) — the external service's shape is as untrusted as a client's request. This is part of the route's boundary job and the BFF pattern from chapter 3: the route is where external data gets validated and shaped before reaching your client.

Systematic validation means your server layer's interior — the services behind the routes — receives only valid, typed data, and the messy untrusted reality is handled uniformly at the route perimeter.


Consistent responses and errors

A coherent API has predictable shapes — a consumer (your frontend, especially) shouldn't have to handle each route's idiosyncratic success and error formats. The discipline is one success-shape convention and one error-shape convention, across all routes.

For successes, decide and hold a convention for what routes return — the resource directly, or a wrapped envelope — and apply it consistently so the client always knows the shape. Nuxt's data-fetching composables type this from the route's return (chapter 1's shared types), so consistency here directly improves the client's type safety.

Errors are where API discipline most often collapses and where consistency matters most, because errors are what the client hits under pressure. Nitro provides createError for throwing structured HTTP errors, and the discipline is using it consistently:

typescript
// Consistent, structured errors via Nitro's createError
if (!ticket) {
  throw createError({
    statusCode: 404,
    statusMessage: "Ticket not found",
    data: { code: "TICKET_NOT_FOUND" },  // stable machine-readable code for the client
  });
}

The principles, carried from any well-designed API:

Use HTTP status codes for their meaning — 400 for bad input, 401/403 for auth, 404 for missing, 409 for conflict, 422 for validation, 5xx only for your faults. Never a 200 with an error body, which forces the client to parse the body to know if the call worked. Nitro's createError with the right statusCode makes the status the first layer of the error contract.

Give errors stable, machine-readable codes (in the data), separate from human messages, so the client can branch on the code programmatically while you improve messages freely. Same lesson as any API: the code is the contract, the message is for humans.

Validation failures become consistent 4xx responses — a parse failure at the edge should map to a structured 400/422 with which field failed, not an unhandled throw that becomes an opaque 500. Wiring your validation to produce consistent error responses is part of the error discipline.

Never leak internals. A 500 should be a generic error to the client, with the detail logged server-side and a correlation ID returned — not the exception and stack trace, which is both an information leak and useless to the client. Nitro's error handling can be configured so unexpected errors don't leak internals to the browser.

Consistent successes and errors make your server layer predictable to consume — which, since the main consumer is your own type-checked frontend, pays off directly in client reliability.


The contract and the client

The chapter's payoff, and the reason "it's just our own API" is an argument for rigor: in a Nuxt app, the server route's contract can be enforced against the client by the compiler, because they share a language and types (chapter 1's superpower). This closes the loop that a separate frontend and backend can never quite close.

When a server route's input is validated by a shared schema, and its output is a typed return, the client gets both for free: useFetch('/api/tickets') is typed from the route's return type, so a change to what the route returns surfaces as a type error at every client call site, and the shared request schema (the same one the form validates against) means the client constructs requests the server will accept. The contract is one definition, checked on both ends — the strongest possible version of the API-contract discipline, available precisely because it's your fullstack app:

typescript
// shared/ticket.ts — the contract, one source of truth
import * as v from "valibot";
export const CreateTicket = v.object({ /* fields + rules */ });   // validates the body AND the form
export type CreateTicket = v.InferOutput<typeof CreateTicket>;
export interface TicketResponse { id: string; status: "open" | "resolved"; subject: string }

// server route: readValidatedBody(event, CreateTicket) → a typed CreateTicket
// client useFetch is typed TicketResponse automatically — contract enforced end to end

This also makes the contract evolvable safely (the API-evolution lesson from any API): because the contract is one typed definition, a change shows you — via compile errors — every client site affected, so you can evolve the API and immediately see what needs to adapt, rather than discovering breakage at runtime. Additive changes (a new optional field) don't break clients; breaking changes (renaming, removing) light up the call sites so you handle them deliberately. The shared, compiler-checked contract turns API evolution from a runtime guessing game into a compile-time checklist.

That's the server layer designed well: Nitro routes as a real API with thin handlers over separated logic, systematic validation at every route's trust boundary, consistent success and error shapes (via createError) so the client can rely on predictable responses, and a shared, compiler-enforced contract that makes the whole thing type-safe end to end and safely evolvable — the discipline paying off precisely because you own both sides.


Practice

Budget about ninety minutes.

Exercise 1: Audit a server route (20 min)

Pick a Nitro route in a Nuxt app you work on. Is the handler thin (validate, delegate, respond) or fat (everything inline)? Is its input validated or trusted? Is its error shape the same as other routes', and does it use proper status codes? Note every gap from "thin, validated, consistent."

Exercise 2: Harden a route (35 min)

Take that route and bring it to standard: extract the business logic into a server service the route calls, parse all input at the edge against a shared schema via readValidatedBody / getValidatedQuery, and make its errors consistent structured createError responses with proper status codes and stable codes. Note what you were able to delete (inline logic, ad-hoc error responses).

Exercise 3: Make the contract shared (35 min)

Move the route's request/response types into a shared location as the single source of truth, and confirm the client useFetch is typed from it. Then take a change you'd want to make to the route and classify it (additive vs. breaking); if breaking, observe how the shared type surfaces every affected client call site as a compile error — the evolution-as-checklist payoff.

Deliverable

A short before/after for one Nitro route: thin handler over a separated service, input validated at the edge, consistent structured errors with status codes — plus the request/response contract moved to a shared type that the client is checked against, and a one-line note on how a breaking change would surface at compile time.

Check yourself

  • Explain why a Nitro route deserves API contract discipline even when only your own frontend consumes it.
  • Describe a thin route handler and where the business logic should live instead.
  • Explain the validation, status-code, stable-error-code, and no-leak disciplines for route responses.
  • Explain how a shared contract enforces the API against the client at compile time and makes evolution a compile-time checklist.

What a strong answer demonstrates: a strong answer treats the Nitro route as a real API contract even when only your own frontend consumes it — thin handler over a separated, testable service, every input parsed at the trust boundary, and consistent structured errors with proper status codes. The clearest signal of understanding is showing how moving the request/response types to a shared definition turns a breaking change into a compile-time checklist: the type lights up every affected client call site.

Going deeper (optional)

  • The Nuxt/Nitro documentation on server routes, defineEventHandler, createError, and event utilities is the reference for the mechanics. (Flagged: Nitro server-route specifics and createError are current-ecosystem knowledge, not from the course's book set; the API-contract principles are general and echo any backend API design.)
  • The JS/TS API-design principles (shared typed contracts, consistent errors, compatible evolution) are general backend wisdom that applies directly here, just expressed in Nitro.
  • Primary source: Roy T. Fielding, "Architectural Styles and the Design of Network-based Software Architectures" (doctoral dissertation, UC Irvine, 2000) — the dissertation behind REST, HTTP status-code semantics, and the uniform-interface and statelessness constraints that this chapter's response- and error-discipline rests on. It's the theory on-ramp for why a route's status code and contract carry real meaning.

Key takeaways

  • Nitro server routes are a real API contract — give them the discipline of one, even when your own frontend is the only consumer; owning both sides is the reason rigor pays off.
  • Keep route handlers thin (validate, delegate, respond) with business logic in separated, testable services behind them — the layering lesson in Nitro.
  • Validate every route's full input surface at the trust boundary, and return consistent success and error shapes via createError with proper status codes, stable machine-readable codes, and no leaked internals.
  • A shared, compiler-enforced contract (one typed definition for request and response) makes the server/client API type-safe end to end and turns API evolution into a compile-time checklist.

Next: State Management & Persistence — client state with Pinia and server-side persistence and caching, and keeping them consistent across the boundary.