Type safe APIs with Nuxt 3 and tRPC

Created by human

Type safety everywhere

TypeScript is 10 years old now! Even if it seems like a long time (especially in a web dev world) its adoption is thriving like never before. According to the Stack Overflow Developer Survey, TypeScript is one of the most loved languages.

Partytown

The love for TypeScript has caused a "type-safe movement" in the world of JavaScript. More and more libraries are designed with types in mind. One of the core examples is schema validation library Zod. The API is almost the same as in the industry standard (not anymore I think) library Yup. There is one huge difference tho. Zod is fully type-safe which means that you can infer the types based on your validation schema. If you are using TypeScript you can have a single source of truth which is your Zod schema and TypesScript types are created based on that schema. It's just one example of a library that is built with TypeScript as a core utility.

Why would you need tRPC?

Can type safety be introduced to network requests and API calls? Well, yes! But first things first. REST is the industry standard for many years and is not going anywhere. It has many benefits like full control over your endpoints, well-documented standards, and most developers are familiar with REST APIs. In most cases REST will be the perfect form of speaking to APIs but other standards like GraphQL can solve specific problems which are not possible in the classic REST approach. But what about type safety? Of course, you can write your interfaces and type your business logic on a backend and a frontend but the biggest downside to this is that you don't have a single source of truth. If some field would change its type on a backend, you need to reflect those changes on the frontend as well. It can easily become cumbersome in larger projects. Any ways to solve this particular issue? Yes! It's tRPC!!

tRPC stands for TypeScript Remote Procedure Call and it's just a definition of executing some procedures on different address spaces. It's a standard like REST or GraphQL which describes the way of talking to APIs. It has some amazing benefits like:

  • Automatic type-safety
  • Snappy DX
  • Is framework agnostic
  • Amazing autocompletion
  • Light bundle size

Keep in mind that tRPC will only be beneficial when you are using TypeScript inside a full-stack project like Nuxt, Next, Remix, SvelteKit, etc. In this article, we will build an example API using Nuxt.

tRPC + Nuxt API routes = Type safety 💚

Ok, let's start by creating a new Nuxt 3 project.

npx nuxi init nuxt-trpc

navigate to the project directory, then install the dependencies:

npm install

Ok, since we have Nuxt project ready, we need to install the following packages:

  • @trpc/server - tRPC package used on a backend
  • @trpc/client - tRPC package used on a frontend
  • trpc-nuxt - tRPC adapter for Nuxt
  • zod - for schema validation

We can install all packages at once:

npm install @trpc/server @trpc/client trpc-nuxt zod

Ok now that we have all packages installed, we are ready to go!

let's run the project:

npm run dev

First, let's create a file inside server/trpc directory server/trpc/trpc.ts and fill the content of the file with the following code:

import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

// Base router and procedure helpers
export const router = t.router;
export const publicProcedure = t.procedure;

This file is fairly simple, we are importing initTRPC which is a builder for our tRPC handler, and creating an instance called t with initTRPC.create();. Next, we are exporting the router and public procedure as our own variables. This is mainly to avoid conflicts in global namespace for example with i18n libraries which sometimes also use t keyword. Since we'll be only importing the router and public procedure from this file we can move on to the next parts.

Let's create the main heart of our API which is our router!

Please make a new file inside server/trpc/routers called index.ts server/trpc/routers/index.ts and paste the following code:

import { z } from 'zod';
import { publicProcedure, router } from '../trpc';

export const appRouter = router({
    getBook: publicProcedure
        .input(
            z.object({
                id: z.number()
            })
        )
        .query(({ input }) => {
            const id = input?.id;

            const books = [
                {
                    id: 1,
                    title: 'Harry Potter',
                    author: 'J. K. Rowling'
                },
                {
                    id: 2,
                    title: 'Lord of The Rings',
                    author: 'J.R.R. Tolkien'
                }
            ];

            return books.find((book) => {
                return book.id === id;
            });
        }),
    addBook: publicProcedure
        .input(z.object({
            title: z.string(),
            author: z.string()
        }))
        .mutation((req) => {
            const newBook = {
                title: req.input.title,
                author: req.input.author
            };

            return newBook;
        })
});

export type AppRouter = typeof appRouter

Let's take a closer look at what is happening here:

import { z } from 'zod';
import { publicProcedure, router } from '../trpc';

We are importing z function from zod library which will provide schema validation. On the second line, we are importing our publicProdedure and router from the previously created file.

The next lines are about appRouter config. We have to initialize the router using a constructor and export the router because we will need it later in our API handler.

export const appRouter = router({
    //config
});

Now we will create our first procedure called getBooks and it will be a simple endpoint responsible for fetching a piece of information about a book with a specific id.

getBook: publicProcedure
    .input(
        z.object({
            id: z.number()
        })
    )
    .query(({ input }) => {
        const id = input?.id;

        const books = [
            {
                id: 1,
                title: 'Harry Potter',
                author: 'J. K. Rowling'
            },
            {
                id: 2,
                title: 'Lord of The Rings',
                author: 'J.R.R. Tolkien'
            }
        ];

        return books.find((book) => {
            return book.id === id;
        });
    }),

Inside the router object config, we are creating a new route called getBook by calling publicProcedure and chaining it with input and query functions. Input is responsible for declaring the shape of our request parameters:

.input(
        z.object({
            id: z.number()
        })
    )

As you can see in the code above, we are using zod to make the structure of API parameters. This particular endpoint expects to receive an object like this one:

{
    id: 4
}

Let's move to the query part.

.query(({ input }) => {
        const id = input?.id;

        const books = [
            {
                id: 1,
                title: 'Harry Potter',
                author: 'J. K. Rowling'
            },
            {
                id: 2,
                title: 'Lord of The Rings',
                author: 'J.R.R. Tolkien'
            }
        ];

        return books.find((book) => {
            return book.id === id;
        });
    })

The query is responsible for the logic of our query and returning the response. Firstly we have to read the id from the input object. Let's create the new variable holding this id:

const id = input?.id;

Now that we have our ID we can write our custom logic and return what we want. In this example code, we are searching for the book with provided ID through the static array and returning the result.

const books = [
    {
        id: 1,
        title: 'Harry Potter',
        author: 'J. K. Rowling'
    },
    {
        id: 2,
        title: 'Lord of The Rings',
        author: 'J.R.R. Tolkien'
    }
];

return books.find((book) => {
    return book.id === id;
});

Great, we have our first endpoint which is a GET request! But what about POST requests? The steps will be almost the same but instead of query function we will use mutation:

addBook: publicProcedure
        .input(z.object({
            title: z.string(),
            author: z.string()
        }))
        .mutation((req) => {
            const newBook = {
                title: req.input.title,
                author: req.input.author
            };

            return newBook;
        })

We have created an addBook mutation that expects an object like this one:

{
    title: 'some book title',
    author: 'some author name '
}

Inside mutation, we are writing our business logic for this endpoint and returning the response. Here we are just passing back the book object for demo purposes.

Last but not least we have to export the types of our router which is the core part of tRPC!

export type AppRouter = typeof appRouter

We will use this type later on our client.

Great we have our router ready!

Our API will be based on Nuxt server routes. We need to find a way to aggregate all endpoints starting with api/trpc. An example URL can look like this: http://localhost:3000/api/trpc/getBook.

Let's create a new file inside the server directory /server/api/trpc/[trpc].ts. Creating a structure like this one and using wildcard [] syntax will cause all endpoints with api/trpc/* pattern to land inside this file.

Please take a look at the content of this file:

import { createNuxtApiHandler } from 'trpc-nuxt';
import { appRouter } from '@/server/trpc/routers';

// export API handler
export default createNuxtApiHandler({
    router: appRouter,
    createContext: () => ({})
});

Inside this file, we have a Nuxt API handler (special package from trpc-nuxt) and we are passing the previously created appRouter with the routing of our API. Every request call will land on this file and make decisions based on routing.

Now we can move to the frontend. We need a way to provide a tRPC client to every Vue component in our app. The best way to achieve it would be using plugins. Let's create a new file inside the plugins directory: plugins/client.ts.

Your file should look like this:

import { httpBatchLink, createTRPCProxyClient } from '@trpc/client';
import type { AppRouter } from '@/server/trpc/routers';

export default defineNuxtPlugin(() => {
    const client = createTRPCProxyClient<AppRouter>({
        links: [
            httpBatchLink({
                url: 'http://localhost:3000/api/trpc'
            })
        ]
    });

    return {
        provide: {
            client
        }
    };
});

In this file, we are defining our Nuxt plugin which will expose the tRPC client under $client variable. You can notice that we are passing AppRouter type to createTRPCProxyClient which is the type imported from our router. Because of that, we will have a fully typed client! The only thing you have to set is the URL of the API. We can achieve it with links config:

links: [
    httpBatchLink({
        url: 'http://localhost:3000/api/trpc'
    })
]

In the end, we have to return and provide the client:

return {
    provide: {
        client
    }
};

We have a plugin created so we are ready to use our tRPC client inside one of our Vue components.

We can test it inside app.vue file:

<script setup lang="ts">
const { $client } = useNuxtApp();

// for client side request
const data = await $client.getBook.query({ id: 1 });

// for server side request
// const { data } = await useAsyncData(() => $client.getBook.query({ id: 1 }));
</script>

<template>
    <div>
        <h1>{{ data?.title }}</h1>
        <h2>{{ data?.author }}</h2>
    </div>
</template>

As you can see it's really simple. The code above shows two methods of fetching the data:

  1. Client side:
const data = await $client.getBook.query({ id: 1 });
  1. Server side by using nuxt built-in useAsyncData composable:
const { data } = await useAsyncData(() => $client.getBook.query({ id: 1 }));

Let's take a look at the main power of tRPC:

  • Intellisense and code completion [Intellisense]
  • Type safety: When you will try to pass the param with the wrong type, for example, string instead of a number:
const data = await $client.getBook.query({
        id: 'someID'
});

You will get a TS error: [Types]

  • Refactoring tools: When you want to change the name of one of the procedures, you can use the VSCode Rename Symbol option and the change will be reflected both on the server and client! Let's try changing getBook to getBookById.

[Refactoring]
[Refactoring]
[Refactoring]
[Refactoring]

Final words

tRPC is a great library/standard to write your APIs and provide full type safety between your backend and frontend code. It's not only about types but also about amazing developer experience and autocomplete. Refactoring and code navigation also works well in the context of tRPC, so I would consider using it in a full-stack project with the help of frameworks like Nuxt.

Check out the source code:
Source code