Nuxt 3 server routes and Typescript
How server routes get typed up in Nuxt 3
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 đ