tRPC & NestJS Monorepo Integration with Authentication
At Apoco, we've been particularly enjoying working with two technologies: tRPC and NestJS. Since many of our projects are structured as monorepos with shared codebases between the client and server, tRPC has proven to be an excellent choice for our closed APIs. It truly shines in environments where typing can be shared between the client and server, and the API is not public-facing -both of which apply to many of our projects.
One of tRPC's core advantages is that it allows you to decouple the API definition from its implementation. In a monorepo setup, this creates a smoother experience when sharing definitions across different parts of the system.
In practice, our projects typically consist of two applications: a server-side NestJS app and a client-side React app. These two applications are independent of each other, with the only commonality being their communication via the HTTP protocol. To establish the API contract, we use a separate shared package that both the client and server depend on. This package is strictly for defining the API contract -it contains no implementation logic, which remains entirely on the server side. The sole purpose of the shared package is to define the structure for client-server communication.

Boilerplate
In tRPC, there's a concept known as "routers." A router typically defines the input, output, and a query or mutation function that processes the input and returns its output. For example:
import { z } from 'zod'
import { initTRPC } from "@trpc/server";
export const trpc = initTRPC.context().create();
export const router = trpc.router({
getSomeNumber: trpc.procedure
.input(z.void())
.output(z.number())
.query(async () => {
return 42
}),
});
In this case, the procedure is defined with no input and returns a number as the output. However, there's a key issue: while this code snippet defines the API contract, it also includes implementation details -in this case, returning the number 42. As mentioned earlier, it's crucial to maintain a clear separation of concerns. The router should focus solely on defining the contract, while the actual implementation details should be handled by the server.
That's exactly where the concept of Context comes into play.
export const trpc = initTRPC.context<INumberFeature>().create();
export const router = trpc.router({
getSomeNumber: trpc.procedure
.input(z.void())
.output(z.number())
.query(async ({ ctx }) => {
return ctx.getSomeNumber();
}),
});
export type Contract = typeof router;
As you can see, there's no actual implementation here -just the definition of the input, output, and the mapping of the input to an operation that will be provided through the Context. With this small adjustment, it's now possible to share the contract between both the server and the client.
With the contract definition ready, we can now focus on exposing the server that implements the previously defined Feature interface. In NestJS, we'll take a modular approach while incorporating dependency injection. We'll begin by creating a new module specifically for integrating tRPC into our NestJS server. This module will initially include a single service responsible for applying tRPC middleware to the HTTP server. The service will then be used in our main server file to ensure that tRPC request handling is properly applied to the HTTP server.
import * as trpcExpress from '@trpc/server/adapters/express';
import { NestExpressApplication } from '@nestjs/platform-express';
import { Injectable } from '@nestjs/common';
import { router } from '@shared/api';
@Injectable()
export class TrpcService {
applyMiddleware(app: NestExpressApplication) {
const context = {
getSomeNumber: () => {
return 42;
},
};
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({
router,
createContext: () => {
return context;
},
}),
);
}
}
To begin with, it provides the implementation details for the Feature interface, which we previously defined. It also imports the router definition from the shared package. If you attempt to modify anything in the Context implementation, it will fail to type-check, as it's essential at this stage to strictly adhere to the API definition.
Now having both Contract and Server implemented, it's really simple to create strictly typed API client for the client. tRPC provides react-query binding for ease of use, but it's also possible to use @trpc/client directly.
Let's create new file trpc.ts which will contain reference to trpc the API.
import { createTRPCReact } from "@trpc/react-query";
import { type Contract } from "@shared/api";
export const trpc = createTRPCReact<Contract>();
Since the shared contract package imports @trpc/server to create the API definition, it is crucial to import only the Contract type. If you import the entire @trpc/server, it will inadvertently make its entire contents available in the client bundle, leading to exceptions since @trpc/server cannot be run in the browser.
The newly created trpc instance can then be used to create a Provider, enabling the use of React Query within your component hierarchy.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { trpc } from "./trpc";
import { httpBatchLink } from "@trpc/client";
function SomeNiceComponent() {
const query = trpc.getSomeNumber.useQuery()
if (query.isLoading || query.data === undefined) {
return <div>Loading...</div>
}
return <div>{query.data}</div>
}
function App() {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: "/trpc",
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<SomeNiceComponent />
</QueryClientProvider>
</trpc.Provider>
);
}
export default App;
And voilà! You've successfully retrieved data provided by the server.
Leveraging NestJS modularization
The next logical step is to take advantage of NestJS's modular approach to ensure the application logic is separated from the tRPC module. Since we've already defined all the Feature interfaces, it's simply a matter of providing the implementations and exporting them from their respective modules. For example, if we have a NumberModule, all we need to do is implement the Feature interface and export it, allowing the tRPC module to use the feature within the Context.

import { Injectable } from '@nestjs/common';
import { INumberFeature } from '@shared/api';
@Injectable()
export class NumberFeature implements INumberFeature {
getSomeNumber() {
return 42
}
}
All that's left is to update the original tRPC service to utilize the module and incorporate it into the final Context.
import * as trpcExpress from '@trpc/server/adapters/express';
import { NestExpressApplication } from '@nestjs/platform-express';
import { Injectable } from '@nestjs/common';
import { router } from '@shared/api';
@Injectable()
export class TrpcService {
constructor(
private readonly numberFeature: NumberFeature,
) {}
applyMiddleware(app: NestExpressApplication) {
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({
router,
createContext: () => {
return numberFeature;
},
}),
);
}
}
Approach to authentication
You might be wondering, "Great, we have working server communication with a modular structure, but how do I handle authentication?" Typically, you need to send a Bearer token through HTTP headers or use a session via cookies. This is where tRPC middleware comes into play.
First, let's extend the Context to include a Session object. For simplicity, we'll make this a plain object that holds the user's identification.
export interface Session {
userId: string
}
export interface Context {
session: Session | null;
numberFeature: INumberFeature;
}
Keep in mind that the tRPC context is scoped to each individual HTTP request, giving you access to both the request and response objects. Creating a session is as simple as parsing the incoming headers and performing the necessary authentication on your backend.
createContext: ({ req }) => {
const getSession = () => {
// For simplicty, let's just do string comparism
if (req.headers.authorization === 'Bearer Foobar') {
return { userId: 'Testing User' };
} else {
return null;
}
};
return {
session: getSession(),
numberGenerator: this.numberGeneratorFeature,
};
},
While you could technically pass a nullable session to the Features, there's a more efficient way using tRPC middleware. When creating the tRPC instance in the contract package, you can define procedures that utilize middleware to handle this for you:
export const authenticatedTrpcProcedure = trpcInstance.procedure.use(
function withAuth(opts) {
const { ctx } = opts;
const session = ctx.session;
if (session === null) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return opts.next({
ctx: {
...ctx,
session,
},
});
}
);
From there, it's as simple as using authenticated TrpcProcedure instead of trpc.procedure in your router definition:
export const router = trpc.router({
getSomeNumber: authenticatedTrpcProcedure
.input(z.void())
.output(z.number())
.query(async ({ ctx }) => {
// ctx.session is no longer nullable because authenticatedTrpcProcedure
// checks its existence and provide proper typing
return ctx.getSomeNumber(ctx.session);
}),
});
Now, the server API to retrieve the number is protected by a static Bearer token. You might be wondering how to send this header from the client. When creating the HTTP link in the client, you can supply a headers function to include custom headers. Typically, you would retrieve your credentials from secure storage and pass them along with the request to the server.
trpc.createClient({
links: [
httpBatchLink({
url: "/trpc",
headers: () => {
return { Authorization: "Bearer Foobar" };
},
}),
],
})
Working Example
We've created a minimal example repository that demonstrates all the proposed patterns and techniques. Feel free to use it as inspiration and take advantage of the powerful combination of tRPC and NestJS.

