Nuxt 3 server routes and Typescript

Jamie Curnow
7 min readMar 27, 2024

--

How server routes get typed up in Nuxt 3

Photo by Matthew Brodeur on Unsplash

Nuxt 3 is amazing. I love the day-to-day dev experience, especially the seamless transition between writing backend code and front end code 👌

Server routes in Nuxt 3 are where you can write your backend logic in the form of API endpoints. All you need to do is to make a /server/api directory at the root of your Nuxt project and start coding — in typescript of course.

Server routes use H3 under the hood which is a modern, minimal HTTP framework for Javascript runtimes. Notably it doesn’t rely on Node internals so it can be run from service workers or edge runtimes like Cloudflare Workers. Think a modern version of Express Js. It’s part of the massive effort that Unjs is undertaking to modernise javascript tooling while making them platform agnostic. If you don’t know them — check them out!

Server routes are super easy to write in Nuxt 3. I’m not going to dig too deep into them for the purposes of this article, but one thing that I’d like to point out is how you return responses from them, and how that affects the types on the front end. The most simple version of a server route would be something like:

// server/api/hello-world.ts
export default defineEventHandler(() => 'Hello, world!')

With that, you’ll be able to navigate in your browser to http://localhost:300/api/hello-world and get your response. So cool.

As you may have seen, just like pages in Nuxt, the directory structure and filename determine the route. So a file under /server/api/things/list.ts would map to a route at /api/things/list . The handler in that file will handle all kinds of request methods, but if you want to define a precise request method to a file, you can just add a postfix to the file name like:

  • /server/api/things/list.post.ts
  • /server/api/things/list.get.ts
  • /server/api/things/list.delete.ts
  • etc

My first tip is to make a habit of doing this — even when you don’t need to because it will help with Types later down the line, and it makes your code much more easy to extend in the future. And it’s really cool.

So how do we call these endpoints to get data to the front end?…

There are a few ways of doing this in Nuxt — useAsyncData() , useFetch() , but I’m going to focus on the humble $fetch — it’s the one that I use the most because most API calls come from user actions like a button click and this is exactly what $fetch is for. It works kinda like how you would use axios but it comes with some really cool features, the most exciting one for me when I started to use it was the Typescript integration which is simply amazing.

Consider hitting our endpoint that we defined above:

const getTheData = async () => {
const response = await $fetch('/api/server/hello-world')
}

If you try that out, you’ll notice that TS starts to auto-complete the route path for you! So helpful! No more worrying about if the route path is correct 👍

Try it out — but if it doesn’t work, make sure you’re running npm run dev and in VSCode you sometimes need to reload the window when you make a new route.

Not only do you get type hints for the route, but you also get automatically typed responses. Here TS already knows that response is going to be a string.

This means that we can do some Vue stuff with it like:

const message = ref<string>('')

const getTheData = async () => {
const response = await $fetch('/api/hello-world')
message.value = response
}

Typescript is so happy about this, and so am I 😊

Consider the case that things start getting a little more complex though, lets say our endpoint returns an object now, with a message, a timestamp and a boolean like so:

export default defineEventHandler(() => {
const message = 'Hello, world!'
const timestamp = new Date().toISOString()
const thisIsCool = true

return { message, timestamp, thisIsCool }
})

You can see how easy it is to return different types from endpoints. On the server side, H3 makes sure that this object gets JSON’ed up for transport, and then on the front end $fetch will take care of turning it back into a JS obect, so it appears as if you’re just calling a function.

If you’re following along, the first thing you may notice when going back to the front end is that typescript is now not so happy. Which actually makes me happy — this is good, we changed the response from the endpoint and now TS is letting us know that we’ve introduced an error 🙏

Thanks TS!

So we better update our front end code accordingly. But here is where I actually got a little stuck and annoyed for a while… I started typing up what the response should look like to satisfy the type of the ref like so:

interface ApiResponse {
message: string
timestamp: string
thisIsCool: boolean
}

const message = ref<ApiResponse | null>(null)

const getTheData = async () => {
const response = await $fetch('/api/hello-world')
message.value = response
}

But that is long and annoying, and means I have to manually maintain both places. So I dug a little deeper into Nuxt’s types and found this little gem:

import type { InternalApi } from 'nitropack'

This InternalApi type is maintained in /.nuxt/types/nitro-routes.ts and is part of Nuxt 3’s genius “Takeover mode” where it generates types based on your code. We can then use this type to get the type of our API response and it will always be kept in sync:

import type { InternalApi } from 'nitropack'
type ApiResponse = InternalApi['/api/hello-world']['get']

const message = ref<ApiResponse | null>(null)

const getTheData = async () => {
const response = await $fetch('/api/hello-world')
message.value = response
}

Pretty darn cool. And pretty hidden. I may do a little PR to get this in the docs somewhere…

But that got me thinking — it’s also a pain to remember where to import InternalApi from all the time, especially when you get used to Nuxt 3’s auto import feature where you barely need import statements. So I made a little composable to handle it for me, and to make it a little nicer than the sqare bracket approach:

// composables/ApiResponse.ts

import type { InternalApi } from 'nitropack'
export type ApiRoutes = keyof InternalApi

export type ApiResponse<T extends ApiRoutes, M extends keyof InternalApi[T]> = InternalApi[T][M]

Because that file is a composable, it will be automagically imported so we can shorten our front end code to:

const message = ref<ApiResponse<'/api/hello-world', 'get'> | null>(null)

const getTheData = async () => {
const response = await $fetch('/api/hello-world')
message.value = response
}

We’re getting there now!

I then took it one step further and made a little composable for refs from API routes:

// composables/ApiResponse.ts
import type { InternalApi } from 'nitropack'
export type ApiRoutes = keyof InternalApi

export type ApiResponse<T extends ApiRoutes, M extends keyof InternalApi[T]> = InternalApi[T][M]

export const apiRef = <T extends ApiRoutes, M extends keyof InternalApi[T], D>(opts: {
route: T
method: M
defaultValue: D
}) => ref<ApiResponse<T, M> | D>(opts.defaultValue)

That looks a bit funky with all the typescript, but it works like magic:

const message = apiRef({
route: '/api/hello-world',
method: 'get',
defaultValue: null
})

const getTheData = async () => {
const response = await $fetch('/api/hello-world')
message.value = response
}

And we get all of our nice type hints along the way

DevX 💯

A tip on sending back Errors from Nuxt server routes

There’s a small, and simple convention that you should follow when handling error responses from Nuxt server routes. It’s tempting to do something like:

export default defineEventHandler(() => {
const message = 'Hello, world!'
const timestamp = new Date().toISOString()
const thisIsCool = true

if (!thisIsCool) {
return { code: 400, message: 'This is not cool' } // ❌ don't do this!
}

return { message, timestamp, thisIsCool }
})

❌ But that actually sends back a 200 response with your object in it. Which, besides being incorrect, it just messes up all the types. TS now thinks that the response from this endpoint is:

{
code: number;
message: string;
timestamp?: undefined;
thisIsCool?: undefined;
} | {
message: string;
timestamp: string;
thisIsCool: boolean;
code?: undefined;
}

Which sucks.

So instead, whenever you want to return an error status, do it the proper way with the built in createError() funtion. And make sure you throw it, because that way TS will not count it as a return value from the function so your types will stay clean as a whistle. It also means you can define proper http status codes to return on errors:

export default defineEventHandler(() => {
const message = 'Hello, world!'
const timestamp = new Date().toISOString()
const thisIsCool = true

if (!thisIsCool) {
throw createError({ status: 400, message: 'This is not cool' }) // ✅
}

return { message, timestamp, thisIsCool }
})

Now TS is happy, and one last step is just to catch this error on our frontend:

const message = apiRef({
route: '/api/hello-world',
method: 'get',
defaultValue: null
})

const getTheData = async () => {
try {
const response = await $fetch('/api/hello-world')
message.value = response
} catch (e) {
console.error(e)
// e.code === 400
// e.message === 'This is not cool'
}
}

Oooah now that feels nice and clean and tidy and pretty darn powerful.

Happy Nuxting folks 👋

--

--

Jamie Curnow

I am a javascript developer living in beautiful Cornwall, UK. Ever curious, always learning.