Exploring the Power Duo: Leveraging tRPC with NestJS for Seamless Backend Development

Fully type-safe nest app for frontend frameworks

Exploring the Power Duo: Leveraging tRPC with NestJS for Seamless Backend Development

INTRODUCTION

In the realm of backend development, simplicity, efficiency, and scalability are paramount. Achieving these qualities often requires a delicate balance of choosing the right tools and frameworks. In this article, we'll explore how combining tRPC with NestJS can elevate your backend development experience to new heights, showcasing code examples and practical implementations along the way.


Understanding tRPC and NestJS

Before we dive into the integration, let's briefly understand what tRPC and NestJS bring to the table.

tRPC: tRPC is a framework-agnostic, bi-directional TypeScript RPC (Remote Procedure Call) framework. It simplifies API development by automatically generating TypeScript types for your API endpoints, providing a strongly-typed interface between the client and server.

NestJS: NestJS is a progressive Node.js framework for building efficient, reliable, and scalable server-side applications. It leverages TypeScript's features to enable robust module-based architecture and dependency injection.

Setting Up the Project

First, let's create a new NestJS project and install the necessary dependencies:

1nest new trpc-nest-app
2cd trpc-nest-app

At this point, we have a nest app ready to be integrated with tRPC. Let's start by adding the tRPC server and other required libraries:

1yarn add @trpc/server trpc-openapi zod ua-parser-js @nestjs/jwt jwt swagger-ui-express

IMPLEMENTATION

We have everything to write our first code block, which is going to be the main tRPC router. This router will be responsible for mapping all our tRPC procedures to the tRPC server so users can interact with our tRPC api.

1import { INestApplication, Injectable } from '@nestjs/common';
2import { createTRPCContext } from '@api/trpc/trpc-context';
3import * as trpcExpress from '@trpc/server/adapters/express';
4
5@Injectable()
6export class TrpcRouter {
7   constructor(
8        private readonly trpcService: TrpcService,
9   ){}
10
11   appRouter = this.trpcService.router({})
12
13   async applyMiddleware(app: INestApplication) {
14        app.use(
15            '/trpc',
16            trpcExpress.createExpressMiddleware({
17                router: this.appRouter as any,
18                createContext: createTRPCContext
19            })
20        );
21   }
22}
23
24export type AppRouter = TrpcRouter['appRouter'];
25

The router has a reference of a `tRPC context`, you must be wondering what that is? Well, that's a secret sauce which takes care of authentication along with the trpcservce which has a bunch of procedures, namely:

1. Procedure: Global procedure handler which can process any requests

2. Protected Procedure: Global procedure handler which can process any authenticated requests.

3. Admin Procedure: Global procedure handler which can process only admin based requests.

Let's write our trpc context file:

1import { JwtService } from '@nestjs/jwt';
2import { inferAsyncReturnType } from '@trpc/server';
3import * as trpcExpress from '@trpc/server/adapters/express';
4import { AppConfig } from '@api/app.config';
5import { UAParser } from 'ua-parser-js';
6import { UserMetadata } from './types';
7
8export async function createTRPCContext({ req, res }: trpcExpress.CreateExpressContextOptions) {
9    // Creating context based on the request object
10    // Will be available as `ctx` in all resolvers
11    const unProtectedRoutes = [
12        '/auth/test',
13    ];
14    const getUserFromHeader = async () => {
15        const uaParser = new UAParser(req.headers['user-agent']);
16        const uaData = uaParser.getResult();
17        const clientIP = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
18        const metadata: UserMetadata = {
19            device: uaData.device,
20            browser: uaData.browser,
21            ip: clientIP as string
22        };
23        if (unProtectedRoutes.includes(req.path)) {
24            return {
25                authorized: true,
26                metadata,
27                message: 'Authorization not required'
28            };
29        }
30        if (req.headers.authorization) {
31            const token = req.headers.authorization.split('Bearer ')[1];
32            if (!token) {
33                return {
34                    authorized: false,
35                    metadata,
36                    message: 'Please provide an access token'
37                };
38            }
39            const jwtService = new JwtService({
40                secret: AppConfig.get('JWT_ACCESS_TOKEN_SECRET')
41            });
42            const isVerified = jwtService.verify(token, {
43                secret: AppConfig.get('JWT_ACCESS_TOKEN_SECRET')
44            });
45            if (!isVerified) {
46                return { authorized: false, metadata, message: 'Malformed token' };
47            }
48            const decodedUser: any = jwtService.decode(token);
49            return {
50                metadata,
51                authorized: true,
52                message: 'Authorized',
53                id: decodedUser.id,
54                rank: decodedUser.rank
55            };
56        }
57        return {
58            metadata,
59            authorized: false,
60            message: 'No authentication header found'
61        };
62    };
63    try {
64        const response = await getUserFromHeader();
65        return {
66            authorized: response.authorized,
67            user: response.authorized ? { id: response.id, rank: response.rank } : null,
68            message: response.message,
69            metadata: response.metadata,
70            getCookie: (name: string) => (req?.cookies ? req?.cookies[name] : ''),
71            setCookie: res.cookie
72        };
73    } catch (e) {
74        return {
75            authorized: false,
76            user: null,
77            metadata: null,
78            message: 'Failed to create TRPC context due to an unexpected error',
79            getCookie: (name: string) => (req?.cookies ? req?.cookies[name] : ''),
80            setCookie: res.cookie
81        };
82    }
83}
84export type Context = inferAsyncReturnType<typeof createTRPCContext>;
85

The context has a `UserMetadata` type, you can find it below:

1import { IBrowser, IDevice } from 'ua-parser-js';
2
3export interface UserMetadata {
4    device: IDevice;
5    browser: IBrowser;
6    ip?: string;
7}
8

Now let's create the trpc service file with the following contents:

1import { Injectable } from '@nestjs/common';
2import { TRPCError, initTRPC } from '@trpc/server';
3import { OpenApiMeta } from 'trpc-openapi';
4import { Context } from './trpc-context';
5
6@Injectable()
7export class TrpcService {
8    trpc = initTRPC.context<Context>().meta<OpenApiMeta>().create();
9
10    isAuthed = this.trpc.middleware(opts => {
11        const { ctx } = opts;
12        if (!ctx.authorized) {
13            throw new TRPCError({
14                message: ctx.message,
15                code: 'UNAUTHORIZED'
16            });
17        }
18        return opts.next({
19            ctx: {
20                user: ctx.user
21            }
22        });
23    });
24
25    isAdmin = this.trpc.middleware(opts => {
26        const { ctx } = opts;
27        // rank === 1 is admin
28        // rank === 0 is user
29        if (!ctx.authorized || ctx?.user?.rank !== '1') {
30            throw new TRPCError({
31                message: 'Unauthorized',
32                code: 'UNAUTHORIZED'
33            });
34        }
35        return opts.next({
36            ctx: {
37                user: ctx.user
38            }
39        });
40    });
41
42    // unauthenticated calls
43    procedure = this.trpc.procedure;
44
45    // admin calls - requires bearer token and admin role
46    adminProcedure = this.trpc.procedure.use(this.isAdmin);
47
48    // authenticated calls - requires bearer token
49    protectedProcedure = this.trpc.procedure.use(this.isAuthed);
50
51    router = this.trpc.router;
52
53    mergeRouters = this.trpc.mergeRouters;
54}
55

We have the router and context implemented and now it's time to glue our trpc router to the nest app we made earlier.

Open the main.ts file that's situated in the source directory of the nest app and make the following changes.

Firstly, import the tRPC router and other required packages:

1import { TrpcRouter } from './trpc/trpc.router';
2import cors, { CorsOptions } from 'cors';
3import express from 'express';

Now replace your bootstrap function with the following code:

1async function bootstrap() {
2    const app = await NestFactory.create(AppModule, { rawBody: true });
3        const dnsWhiteList = ['http://localhost:3000'];
4    const corsOptions: CorsOptions = {
5        credentials: true,
6        origin: (origin, callback) => {
7            // Skip CORS check in development
8            if (process.env.MODE !== 'prod') return callback(null, true);
9
10            // Perform origin check in production
11            if (origin === undefined || dnsWhiteList.includes(origin)) return callback(null, true);
12            return callback(new Error(`Not allowed by CORS ${origin}`));
13        }
14    };
15    app.use(cors(corsOptions));
16    
17    // tRPC Rest API
18    const trpc = app.get(TrpcRouter);
19    trpc.applyMiddleware(app);
20    app.use(
21        '/rest',
22        createOpenApiExpressMiddleware({
23            router: trpc.appRouter,
24            createContext: createTRPCContext
25        })
26    );
27
28    // Swagger
29    const swagger = express();
30    const openApiDocument = generateOpenApiDocument(trpc.appRouter, {
31        title: 'nestjs trpc open API docs',
32        description: 'OpenAPI compliant REST API built using tRPC with Express',
33        version: '1.0.0',
34        baseUrl: 'http://localhost:3001/rest',
35        tags: ['auth', 'user']
36    });
37    swagger.use('/api-docs', swaggerUi.serve);
38    swagger.get('/api-docs', swaggerUi.setup(openApiDocument));
39
40    // Start servers
41    const server = await app.listen(process.env.PORT!);
42    server.setTimeout(1800000);
43    console.log(`Server running on port http://localhost:${process.env.PORT}`);
44    swagger.listen(process.env.SWAGGER_PORT, () => {
45        console.log(`API docs started on http://localhost:${process.env.SWAGGER_PORT}/api-docs`);
46    });
47
48}
49

Now let's write a test procedure and glue it to our main trpc router. Create a new router file named app.router.ts with the following contents:

1import { z } from 'zod';
2import { ForbiddenException, Injectable, Logger } from '@nestjs/common';
3
4
5@Injectable()
6export class AppRouter {
7    logger = new Logger(UserRouter.name);
8
9    constructor(
10            private trpcService: TrpcService
11    ){}
12
13    router = this.trpcService.router({
14        test: this.trpcService.procedure
15        .meta({
16                  openapi: { method: 'GET', path: '/test/', tags: ['test'] }
17              })
18        .input(z.void())
19        .output(z.string())
20        .query(() => 'hello world')
21    });
22}

That's it, you can now start interacting with this tRPC router running inside nest with all it's goodies like services, modules, interceptors and it's great tooling!

Related blogs