
Type-Safe Server Routes: End-to-End Types from server/api to Your Components How Nuxt infers response types from your server routes so useFetch and $fetch calls are fully typed without manual interfaces.
Reza Baar
September 16, 2026
One of the quieter but really useful features of Nuxt is that types flow from your server routes all the way to your components automatically. You write a server/api handler, and when you call it with useFetch or $fetch, the response is already typed. You don't write a shared interface, a manual generic, or a duplicated type definition. In this post, we'll walk through how it works, where it holds up, and the couple of places you have to give it a hand.
When you return a value from a server route, Nitro (the server engine underneath Nuxt) generates a type for that route. Nuxt then makes those types available to $fetch and useFetch based on the URL you pass. The data you get back on the client ends up typed by what the server returns.
Here's a server route that fetches a user from the database:
// server/api/users/[id].get.ts
// looks up a user by ID and returns it
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
const user = await db.user.findUnique({
where: { id: Number(id) },
select: { id: true, name: true, email: true, isAdmin: true },
});
if (!user) {
throw createError({ statusCode: 404, statusMessage: 'User not found' });
}
return user;
});
Now call it from a component:
<!-- pages/profile.vue -->
<script setup lang="ts">
const route = useRoute();
const { data: user } = await useFetch(`/api/users/${route.params.id}`);
// user.value is typed from the query's select:
// { id: number; name: string; email: string; isAdmin: boolean } | null
</script>
<template>
<div v-if="user">
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
</div>
</template>
You didn't write a single type annotation, but user.value knows its shape, right down to matching the fields you selected in the query. Access user.value.username and TypeScript errors, because the route doesn't return a username. The server route is the source of truth for the response type.
For this to work, your handler needs to return a value rather than manually ending the response. Nitro generates types by looking at what you return, so if you send data through the raw response object, there's nothing for it to infer.
// Good: returns a value, so the type is inferred
export default defineEventHandler(async () => {
return { status: 'ok' };
});
// Bad: no return value, so nothing to infer
export default defineEventHandler(async (event) => {
event.node.res.end(JSON.stringify({ status: 'ok' }));
});
Returning values is the idiomatic Nitro style anyway, so you're unlikely to fight it.
The inference covers more than the response. Nuxt also gives you autocomplete on the URL itself, based on the routes in your server/api directory. Start typing a path in useFetch and your editor suggests your actual routes. Typo a route name and you get a type hint that it doesn't match anything. That catches mistyped endpoint paths before you ever run the app.
Nuxt ties routes to HTTP methods through file naming. A file ending in .get.ts handles GET, .post.ts handles POST, and so on. The type system understands this, so the same path with different methods gets different types.
// server/api/posts.get.ts — returns the full list of posts
export default defineEventHandler(async () => {
return await getAllPosts(); // Post[]
});
// server/api/posts.post.ts — creates a post from the request body, returns the new one
export default defineEventHandler(async (event) => {
const body = await readBody(event);
return await createPost(body); // a single Post
});
The GET and POST responses are typed independently:
<script setup lang="ts">
// Typed as Post[]
const { data: posts } = await useFetch('/api/posts');
// Typed as Post, and the method must be specified
async function addPost(newPost: NewPost) {
const created = await $fetch('/api/posts', {
method: 'POST',
body: newPost,
});
}
</script>
Routes with dynamic segments use bracket naming, and the types still flow through. The user route from earlier is a dynamic route: [id].get.ts maps to /api/users/:id, and the response types the same way a static route would.
Building the URL with a template literal like /api/users/${id} has a catch to be aware of. Nuxt sometimes can't match the interpolated string to the dynamic route pattern, because as far as TypeScript is concerned the string is just string. If the type doesn't resolve, assert the route pattern:
// Asserting the pattern when a template-literal URL won't infer on its own
const user = await $fetch(`/api/users/${id}` as '/api/users/:id');
It's a bit ugly, and you need it less often than you used to, but it's the escape hatch for template-literal URLs that don't infer cleanly.
Response types are inferred automatically. The request body is a different story. readBody returns unknown by default, because the server can't trust that an incoming body matches any shape, and that's the correct default. Request bodies come from the outside world and should be validated, not assumed.
Validate the body with a schema (Zod is the common choice), which gives you a typed, trusted value:
// server/api/posts.post.ts — validates the body against a schema before using it
import { z } from 'zod';
const newPostSchema = z.object({
title: z.string().min(1),
body: z.string().min(1),
tags: z.array(z.string()).default([]),
});
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, newPostSchema.parse);
// body is typed and validated: { title: string; body: string; tags: string[] }
return await createPost(body);
});
readValidatedBody runs your schema against the incoming body and returns a typed value, throwing if validation fails. This beats typing the body with a generic like readBody<NewPost>(event), since a generic only asserts the type without checking it, so a malformed request would slip through. Validation gives you the type and the safety together.
The inference is solid, but a few things can flatten your types back to any. The most common is adding certain options to useFetch that change how the return type is computed. Using getCachedData, for one, has historically caused the inferred type to collapse.
When that happens, pull the route's type directly from Nitro's generated InternalApi interface and apply it:
<script setup lang="ts">
import type { InternalApi } from 'nitropack';
// Recover the type for a specific route
type UserResponse = InternalApi['/api/users/:id']['get'];
const { data: user } = await useFetch(`/api/users/${route.params.id}`, {
getCachedData: (key) => {
// ... caching logic
},
});
</script>
InternalApi is the generated interface holding every route's types, keyed by path and method. Reaching into it directly is the workaround when a composable option interferes with automatic inference. You shouldn't need it often, but it's good to know it's there.
Many apps wrap useFetch or $fetch to add a base URL, auth headers, or default error handling. When you do, take care to preserve the generics, or you'll lose inference for every call that goes through the wrapper. You want a wrapper that adds behavior without erasing the types Nitro generated.
A typed $fetch wrapper looks like this:
// utils/api.ts — a fetch instance with a base URL and auth, types intact
export const api = $fetch.create({
baseURL: '/api',
onRequest({ options }) {
// add auth header, etc.
},
});
$fetch.create keeps the typing intact because it returns a fetch instance with the same type machinery, rather than a plain function that would need its own generics. For useFetch, Nuxt's docs recommend building a custom composable rather than a thin wrapper, so the types survive.
The payoff is that your client and server can't drift apart silently. Change a route to stop returning email, and every component that reads user.email gets a type error at build time, not a runtime undefined in production. You get the safety of a shared contract without maintaining one by hand. For a full-stack framework, that's one of the strongest arguments for keeping your API inside Nuxt rather than in a separate service: the types come for free.
useFetch and $fetch automatically.get.ts, .post.ts) means each method gets its own typereadValidatedBody and a schema rather than assumedgetCachedData can flatten types. Recover them from InternalApi in nitropack$fetch.create or a proper custom composableEnd-to-end type safety is one of the best reasons to keep your API inside Nuxt. Your server routes become the single source of truth for response shapes, and the types flow to your components without any manual wiring. Return values from your handlers, validate incoming bodies, and preserve types in your wrappers, and your client and server stay in sync automatically.
Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Type-Safe Server Routes
Type-Safe Server Routes: End-to-End Types from server/api to Your Components How Nuxt infers response types from your server routes so useFetch and $fetch calls are fully typed without manual interfaces.
Reza Baar
Sep 16, 2026

How to create nested routes with Angular?
Learn how Angular nested routes and child routes work with multiple router outlets, and see how to use them to build navigable sections such as tabbed dashboards.
Alain Chautard
Sep 15, 2026

Structuring a Large Vue App: A Feature-Based Folder Architecture
Why the default components/composables/stores split breaks down at scale, and how a feature-first structure fixes it.
Reza Baar
Sep 9, 2026