DEV Community

Cover image for My monorepo setup to ship full-stack, end-to-end type-safe apps with TypeScript
Friedrich WT
Friedrich WT

Posted on

My monorepo setup to ship full-stack, end-to-end type-safe apps with TypeScript

During the previous year, I took quite some time to find my perfect tech-stack that would allow me to ship TypeScript apps fast, with end-to-end type safety. That last part was a little bit tricky. I started simple with Next.js and it worked fine for a while. Server components, server actions. Seemed magical, type contracts shared between the frontend and the backend. And everything was in a single Node.js application. Dockerized it and boom, ready to deploy!

But as I said, it worked “for a while”. Usually in a project, we end up having multiple clients that consume the api, not just one frontend. While building Mooncode (an application to track user coding time, programming languages and files), I hit that roadblock. I needed a VSCode extension and a dashboard that would communicate with a backend, and I couldn’t achieve that easily with Next.js. That’s how I shifted from it. So, what does my current tech stack look like?

  • Nest.js for the api/backend. It is a robust, easy to scale, TypeScript backend framework. Even though it is quite strict, opinionated and takes some time to get comfortable with, the benefits become more visible on the long run. It feels like a Spring Boot for the Node.js ecosystem or an Angular for the backend, but man, I like it. I can also expect a consistent developer experience and file structure between projects too, which is a win, in my opinion
  • React + Vite + React Router on the frontend for any application that doesn’t require any SEO (Search Engine Optimization) or is hidden behind a login wall. This includes dashboards and admin panels. It is simple to get around and to understand
  • Astro/Nextjs for frontend apps that need SEO. I use them for landing pages or blogs in general, with static build
  • Turborepo for a monorepo setup and have all those different applications (frontends, backends) in the same codebase and be able to reuse code between them with shared packages
  • tRPC in the middle to ensure end-to-end type safety. What I mean by that is, I am able to get the types of any data I fetch from the frontend apps. So, when I update an endpoint parameters or return type, if there is any error, it will immediately be reflected in the frontends that consume that endpoint. And if I forget to fix those errors, the build will fail

My goal in this article is to setup such a monorepo with: a Nest.js api, a React+Vite+React Router frontend and another frontend using Astro, all this with tRPC, of course.

Monorepo architecture

Let’s start by creating a turborepo monorepo with the command:

npx create-turbo@latest
Enter fullscreen mode Exit fullscreen mode

This will generate a bare bones Turborepo monorepo with two applications: a docs and a web folder. Both of them are Next.js applications. Let’s remove them and start fresh by creating the Nest.js api with:

# From the root of the monorepo
cd apps && nest new api
Enter fullscreen mode Exit fullscreen mode

Make sure to add the following fields in the tsconfig.json of the api:

{
  "compilerOptions": {
    "module": "nodenext",
    "composite": true,
    "rootDir": ".",
    "outDir": "./dist",
    "paths": {
      "@/*": ["./src/*"]
    },
    "types": ["node"],
    "strict": true,
    "noImplicitAny": true
  }
}
Enter fullscreen mode Exit fullscreen mode

And also install the latest version of TypeScript:

# From the root of the monorepo
npm i -D typescript --workspace=api
Enter fullscreen mode Exit fullscreen mode

Remove the .git folder of the api. The Nest.js cli generates a git repository by default and we don’t want it because the monorepo has already its own git repository.

# From the root of the monorepo
cd apps/api && rm -rf .git
Enter fullscreen mode Exit fullscreen mode

Let’s continue by creating the Vite + React Router frontend

# From the root of the monorepo
cd apps && npm create vite@latest
Enter fullscreen mode Exit fullscreen mode

And install react-router there (I called that app dashboard):

# From the root of the monorepo
npm i react-router --workspace=dashboard
Enter fullscreen mode Exit fullscreen mode

I put in place some boilerplate code, but since it is not the core of the article, I will not go in the details. Here is the link to the GitHub repository if you want access to the full source code: monorepo-setup, on the initial setup branch.

Now, let’s do the same thing with the Astro application with:

# From the root of the monorepo
cd apps && npm create astro@latest
# recommanded setup, no git repository for the same reasons as the api
Enter fullscreen mode Exit fullscreen mode

Here again I will remove the boilerplate, add some styling and some ESLint and Prettier rules. Formatting and linting are a separate topic that I may touch in a part two of this article, with more details on my linting rules especially.

Anyways, once all that is setup, here is what we have currently:

  • dashboard (Vite + React Router)

dashboard initial setup

  • web app (Astro)

web app initial setup

OK, now we can start configuring tRPC in the api. Starting by installing @trpc/server.

# In apps/api
npm i @trpc/server --workspace=api
Enter fullscreen mode Exit fullscreen mode

Now let’s create a tRPC resource (module + controller + service):

# From the root of the monorepo
cd apps/api && nest g res trpc 
Enter fullscreen mode Exit fullscreen mode

(with no CRUD endpoints for the trpc service). So, at the beginning it looks like this:

import { Injectable } from '@nestjs/common';

@Injectable()
export class TrpcService {}
Enter fullscreen mode Exit fullscreen mode

Let’s define some tRPC concepts before going further. The API endpoints are called procedures. And there are two types of procedures: the queries and the mutations. A query is the equivalent of a GET REST method. It is used to get data. And a mutation is almost everything else: create, update, delete data. So it matches the POST, PATCH, PUT, DELETE REST methods. We also often group REST API endpoints under a controller, for example users will have endpoints to create, read, update, delete a user. tRPC calls such collection of procedures a router. Another concept that is worth explaining is the context which contains the request and the response and can be accessed by procedures. A middleware is a function that runs before or after a procedure and can change the context.

Here is the full code of the TrpcService:

import superjson from "superjson";

import { JWTDto } from "@/common/dto";
import { EnvService } from "@/env/env.service";
import { errorFormatter } from "@/filters/error-formatter";
import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { initTRPC, TRPCError } from "@trpc/server";
import * as trpcExpress from "@trpc/server/adapters/express";

export type TrpcContext = {
  req: trpcExpress.CreateExpressContextOptions["req"];
  res: trpcExpress.CreateExpressContextOptions["res"];
  user?: {
    sub: string;
  };
};

export const createContext = async (
  opts: trpcExpress.CreateExpressContextOptions,
): Promise<TrpcContext> => {
  return {
    req: opts.req,
    res: opts.res,
  };
};

@Injectable()
export class TrpcService {
  trpc;
  constructor(
    private readonly jwtService: JwtService,
    private readonly envService: EnvService,
  ) {
    this.trpc = initTRPC.context<TrpcContext>().create({
      transformer: superjson,
      errorFormatter: ({ error, shape }) =>
        errorFormatter({
          environment: this.envService.get("NODE_ENV"),
          error,
          shape,
        }),
    });
  }

  // these routes are publicly accessible to everyone
  publicProcedure() {
    return this.trpc.procedure;
  }

  // these routes requires authentication
  protectedProcedure() {
    const procedure = this.trpc.procedure.use(async (opts) => {
      const payload = await this.getPayload(opts.ctx);

      return opts.next({
        ctx: {
          ...opts.ctx,
          user: { sub: payload.sub },
        },
      });
    });

    return procedure;
  }

  async getPayload(ctx: TrpcContext) {
    // get JWT from cookies
    const accessToken = ctx.req.cookies?.auth_token;

    if (!accessToken) {
      throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "Token not found",
      });
    }

    try {
      const rawPayload = await this.jwtService.verifyAsync(accessToken, {
        secret: this.envService.get("JWT_SECRET"),
      });

      const parsedPayload = JWTDto.safeParse(rawPayload);

      if (!parsedPayload.success) {
        throw new TRPCError({
          code: "UNAUTHORIZED",
        });
      }

      const data = parsedPayload.data;

      return data;
    } catch (error) {
      if (error instanceof Error && error.name !== "JsonWebTokenError") {
        console.error("Unexpected Error:", error);
      }

      throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "An error occurred",
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Let’s explain it piece by piece:

export type TrpcContext = {
  req: trpcExpress.CreateExpressContextOptions["req"];
  res: trpcExpress.CreateExpressContextOptions["res"];
};

export const createContext = async (
  opts: trpcExpress.CreateExpressContextOptions,
): Promise<TrpcContext> => {
  return {
    req: opts.req,
    res: opts.res,
  };
};
Enter fullscreen mode Exit fullscreen mode

As its name suggests, the createContext function is used to define the tRPC context and will be used in the service to create an instance of trpc.

@Injectable()
export class TrpcService {
  trpc;
  constructor(
    private readonly jwtService: JwtService,
    private readonly envService: EnvService,
  ) {
    this.trpc = initTRPC.context<TrpcContext>().create({
      transformer: superjson,
      errorFormatter: ({ error, shape }) =>
        errorFormatter({
          environment: this.envService.get("NODE_ENV"),
          error,
          shape,
        }),
    });
  }
Enter fullscreen mode Exit fullscreen mode

We define and instanciate tprc in the constructor of the TrpcService class. We also inject in the constructor of the class two other services: jwtService to create and decode JWTs and envService to interact with environment variables in a type safe way (it is a wrapper around the provided ConfigService of Nest.js for environment variables). The errorFormatter function is used to format the errors depending of the environment (development or production). I will provide their source codes later in the article. JwtDtoType is the shape of our decoded JWT. And superjson is a package used to easily pass data types like Date, Map, Set (which are not easily json serializable) between client and server without having to recreate them ourselves.

Packages installations:

# From the root of the monorepo
npm i @nestjs/jwt --workspace=api
npm i superjson --workspace=api
Enter fullscreen mode Exit fullscreen mode

In the world of tRPC, endpoints that are protected by authentication are called protected procedures and the remaining, public procedures.

 // these routes are publicly accessible to everyone
  publicProcedure() {
    return this.trpc.procedure;
  }

  // these routes requires authentication
  protectedProcedure() {
    const procedure = this.trpc.procedure.use(async (opts) => {
      const payload = await this.getPayload(opts.ctx);

      return opts.next({
        ctx: {
          ...opts.ctx,
          user: { sub: payload.sub },
        },
      });
    });

    return procedure;
  }
Enter fullscreen mode Exit fullscreen mode

In the case of a public procedure, we just give access to the data of the procedure, but if it is private, we use a tRPC middleware to check the JWT of the user (handled by the getPayload function). If we cannot get the payload, we throw a 401 unauthorized errror, otherwise we add the sub value of our JWT in the tRPC context. About the way we get that payload:

async getPayload(ctx: TrpcContext) {
    // get JWT from cookies
    const accessToken = ctx.req.cookies?.auth_token;

    if (!accessToken) {
      throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "Token not found",
      });
    }

    try {
      const rawPayload = await this.jwtService.verifyAsync(accessToken, {
        secret: this.envService.get("JWT_SECRET"),
      });

      const parsedPayload = JWTDto.safeParse(rawPayload);

      if (!parsedPayload.success) {
        throw new TRPCError({
          code: "UNAUTHORIZED",
        });
      }

      const data = parsedPayload.data;

      return data;
    } catch (error) {
      if (error instanceof Error && error.name !== "JsonWebTokenError") {
        console.error("Unexpected Error:", error);
      }

      throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "An error occurred",
      });
    }
  }
Enter fullscreen mode Exit fullscreen mode

We simply extract the JWT from the cookies (the cookie is named auth_token) and decode it.

The code of:

  • env.service.ts

Create a env resource with the Nest.js cli:

# From the root of the monorepo
cd apps/api && nest g res env
Enter fullscreen mode Exit fullscreen mode

Then paste this code in the env.service.ts

import { Env } from "@/env";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";

@Injectable()
export class EnvService {
  constructor(private configService: ConfigService<Env, true>) {}
  get<T extends keyof Env>(key: T) {
    return this.configService.get(key, { infer: true });
  }
}
Enter fullscreen mode Exit fullscreen mode

The get method of the EnvService will make the environment variables type safe. Also install the @nestjs/config package.

# From the root of the monorepo
npm i @nestjs/config --workspace=api
Enter fullscreen mode Exit fullscreen mode

There is a env.ts file at the root of the src directory of the api, that contains a zod schema of our environment variables.

# From the root of the monorepo
npm i zod --workspace=api
Enter fullscreen mode Exit fullscreen mode
import { z } from "zod";

export const envSchema = z.object({
  NODE_ENV: z.enum(["production", "development"]),

  JWT_SECRET: z.string().trim().min(1),
});

export type Env = z.infer<typeof envSchema>;
Enter fullscreen mode Exit fullscreen mode
  • common/dto.ts Create a dto.ts file in src/common of the api.
import { z } from "zod";

export const JWTDto = z.object({
  sub: z.string().min(1),
  iat: z.number().int(),
  exp: z.number().int(),
});

export type JWTDtoType = z.infer<typeof JWTDto>;
Enter fullscreen mode Exit fullscreen mode
  • formatters/error-formatter.ts
import { z, ZodError } from "zod";

import { EnvService } from "@/env/env.service";
import { TRPCError } from "@trpc/server";

type ErrorShape = {
  data: {
    stack?: string | undefined;
    path?: string | undefined;
    zodIssues?: ZodError[] | undefined;
    code: string;
    httpStatus: number;
  };
  message: string;
};

export const errorFormatter = (
  envService: EnvService,
  {
    shape,
    error,
  }: {
    shape: ErrorShape;
    error: unknown;
  },
) => {
  const isDev = envService.get("NODE_ENV") === "development";

  if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
    if (error.cause && isZodError(error.cause)) {
      return {
        ...shape,
        message: z.prettifyError(error.cause),
        data: {
          code: shape.data.code,
          httpStatus: shape.data.httpStatus,
          ...(isDev && {
            stack: shape.data.stack,
            path: shape.data.path,
            zodIssues: error.cause.issues,
          }),
        },
      };
    }
  }

  // Handle direct Zod errors
  if (isZodError(error)) {
    return {
      ...shape,
      message: z.prettifyError(error),
      data: {
        code: shape.data.code,
        httpStatus: shape.data.httpStatus,
        ...(isDev && {
          stack: shape.data.stack,
          path: shape.data.path,
          zodIssues: error.issues,
        }),
      },
    };
  }

  // Other errors
  let cleanMessage = "An error occurred";

  if (error instanceof Error) {
    cleanMessage = error.message;
    try {
      if (error.message.startsWith("[") && error.message.endsWith("]")) {
        const parsedErrors = JSON.parse(error.message);
        if (Array.isArray(parsedErrors)) {
          cleanMessage = parsedErrors
            .map((err) => `${err.path?.join(".") || "field"}: ${err.message}`)
            .join("; ");
        }
      }
    } catch {
      // Keep original message if parsing fails
    }
  }

  return {
    ...shape,
    message: cleanMessage,
    data: {
      code: shape.data.code,
      httpStatus: shape.data.httpStatus,
      ...(isDev && {
        stack: shape.data.stack,
        path: shape.data.path,
      }),
    },
  };
};

const isZodError = (error: unknown) => {
  return error instanceof ZodError;
};
Enter fullscreen mode Exit fullscreen mode

Let’s fix some Nest.js dependency injection issues. In the trpc.module.ts, add the JwtService and EnvService to the list of providers:

import { EnvService } from "@/env/env.service";
import { Module } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";

import { TrpcController } from "./trpc.controller";
import { TrpcService } from "./trpc.service";

@Module({
  controllers: [TrpcController],
  providers: [TrpcService, JwtService, EnvService],
})
export class TrpcModule {}
Enter fullscreen mode Exit fullscreen mode

In the app.module.ts, let’s add the ConfigModule to the list of imports and make it prevent the app from bootstrapping if we don’t have all the required environment variables:

import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";

import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { envSchema } from "./env";
import { EnvModule } from "./env/env.module";
import { TrpcModule } from "./trpc/trpc.module";

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validate: (env) => envSchema.parse(env), // this will prevent the app from starting if the environment variables are not valid
    }),
    TrpcModule,
    EnvModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Validating our environment variables and having a type safe EnvService that wraps the ConfigService provided out of the box by Nest.js is not necessary. I just find those measures to be a good safety net: we get type safety on our environment variables and the app will fail to start of they are not valid. Better to get the error at the beginning than weird issues (due to misconfigured environment variables) when the app is already running.

Now the api folder structure should like this:

api file structure

And let’s not forget to create a .env with proper environment variables

NODE_ENV=development
JWT_SECRET=...
Enter fullscreen mode Exit fullscreen mode

You can generate a JWT_SECRET with the command:

openssl rand -hex 32
Enter fullscreen mode Exit fullscreen mode

And the api should bootstrap without any issues.

logs of bootstrapped api

Next step is to define the main router of our api. Let’s first delete the trpc.controller.ts and replace it with a trpc.router.ts.

import { INestApplication, Injectable } from "@nestjs/common";
import * as trpcExpress from "@trpc/server/adapters/express";

import { createContext, TrpcService } from "./trpc.service";

@Injectable()
export class TrpcRouter {
  constructor(private readonly trpcService: TrpcService) {}

  appRouter = this.trpcService.trpc.router({}); // here we will define our procedures that will be called by our client
  // we can also create different routers per module and pass them here

  async applyMiddleware(app: INestApplication) {
    app.use(
      "/trpc", // all the procedures that we define will be under the /trpc prefix (eg: http://api/trpc/...)
      trpcExpress.createExpressMiddleware({
        router: this.appRouter,
        createContext,
      }),
    );
  }
}

export type AppRouter = TrpcRouter["appRouter"]; // we export this type! Necessary to get the type safety in the clients (web app and dashboard) 
Enter fullscreen mode Exit fullscreen mode

Let’s also declare it as a provider in the trpc.module.ts.

import { EnvService } from "@/env/env.service";
import { Module } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";

import { TrpcRouter } from "./trpc.router";
import { TrpcService } from "./trpc.service";

@Module({
  providers: [TrpcService, TrpcRouter, JwtService, EnvService],
})
export class TrpcModule {}
Enter fullscreen mode Exit fullscreen mode

Now that we defined the main router, let’s use it at the Nest.js application level by updating the main.ts root file of the api. But we need to install the cookie-parser package which is required to work with cookies when using Nest.js with express.

# From the root of the monorepo
npm i cookie-parser --workspace=api
npm i -D @types/cookie-parser --workspace=api
Enter fullscreen mode Exit fullscreen mode

and

import cookieParser from "cookie-parser";

import { NestFactory } from "@nestjs/core";

import { AppModule } from "./app.module";
import { TrpcRouter } from "./trpc/trpc.router";

const bootstrap = async () => {
  const app = await NestFactory.create(AppModule);
  app.use(cookieParser());

  const trpc = app.get(TrpcRouter);
  trpc.applyMiddleware(app);

  await app.listen(process.env.PORT ?? 3010);
};
bootstrap();
Enter fullscreen mode Exit fullscreen mode

Let’s test this setup by quickly defining a procedure directly in the trpc.router.ts. And we will try to call it from the clients.

import { z } from "zod";

import { INestApplication, Injectable } from "@nestjs/common";
import * as trpcExpress from "@trpc/server/adapters/express";

import { createContext, TrpcService } from "./trpc.service";

@Injectable()
export class TrpcRouter {
  constructor(private readonly trpcService: TrpcService) {}

  appRouter = this.trpcService.trpc.router({
    getHello: this.trpcService
      .publicProcedure()
      .input(
        z.object({
          name: z.string().min(1),
        }),
      )
      .query(({ input }) => `Hello, ${input.name} from trpc server`),
  });

  async applyMiddleware(app: INestApplication) {
    app.use(
      "/trpc",
      trpcExpress.createExpressMiddleware({
        router: this.appRouter,
        createContext,
      }),
    );
  }
}

export type AppRouter = TrpcRouter["appRouter"];
Enter fullscreen mode Exit fullscreen mode

Before going to the clients there is one step in between: create a trpc package in the monorepo. The sole purpose of this package is to re-export the AppRouter type. So the clients will import from @repo/trpc instead of importing directly from the api. The files of that package:

package.json:

{
  "name": "@repo/trpc",
  "version": "0.0.0",
  "private": true,
  "license": "MIT",
  "exports": {
    "./router": "./src/router.ts"
  },
  "scripts": {
    "dev": "tsc --watch",
    "lint": "eslint . --fix",
    "format": "prettier --write \"**/*.ts\"",
    "build": "tsc"
  }
}
Enter fullscreen mode Exit fullscreen mode

tsconfig.json:

{
  "extends": "@repo/typescript-config/base.json",
  "references": [{ "path": "../../apps/api/tsconfig.json" }],

  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "composite": true,
    "incremental": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}
Enter fullscreen mode Exit fullscreen mode

src/router.ts:

export type { AppRouter } from "api/src/trpc/trpc.router";
Enter fullscreen mode Exit fullscreen mode

Now the clients:

  • dashboard (React + Vite)

We are going to use @tanstack/react-query to make our lives easier. It works great with tRPC.

First step is to install the following packages:

npm install @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode

After we need to create a TRPCProvider to wrap our entire application:

// src/utils/trpc.ts
import type { AppRouter } from "@repo/trpc/router";
import { TRPCClientError } from "@trpc/client";
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import { createTRPCContext } from "@trpc/tanstack-react-query";

export const { TRPCProvider, useTRPC, useTRPCClient } =
  createTRPCContext<AppRouter>();

export const isTRPCClientError = (
  error: unknown,
): error is TRPCClientError<AppRouter> => {
  return error instanceof TRPCClientError;
};

export type Inputs = inferRouterInputs<AppRouter>;
export type Outputs = inferRouterOutputs<AppRouter>;
Enter fullscreen mode Exit fullscreen mode

And in the app.tsx file of the dashboard, we create our queryClient then the trpcClient that we pass to the TRPCProvider:

import { useState } from "react";
import { Outlet } from "react-router";
import superjson from "superjson";

import { ThemeProvider } from "@/providers/theme-provider";
import { isTRPCClientError, TRPCProvider } from "@/utils/trpc";
import type { AppRouter } from "@repo/trpc/router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";

// we define our tanstack query QueryClient
const makeQueryClient = () => {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
        refetchOnWindowFocus: true,
        refetchInterval: 60 * 1000,
        retry: (failureCount, error) => {
          if (isTRPCClientError(error)) {
            return failureCount < 1;
          }

          return failureCount < 3;
        },
      },
    },
  });
};

let browserQueryClient: QueryClient | undefined = undefined;

const getQueryClient = () => {
  // if we are on the server, always make a new query client
  if (typeof window === "undefined") {
    return makeQueryClient();
  } else {
    // browser: make a query client if we don't already have one
    if (!browserQueryClient) {
      browserQueryClient = makeQueryClient();
    }
    return browserQueryClient;
  }
};

export const App = () => {
  const queryClient = getQueryClient();
  const [trpcClient] = useState(() =>
    createTRPCClient<AppRouter>({
      links: [
        httpBatchLink({
          // the url of the api, we define it as an environment variable
          url: import.meta.env.VITE_API_URL,
          fetch(url, options) {
            return fetch(url, {
              ...options,
              credentials: "include",
            });
          },
          transformer: superjson,
        }),
      ],
    }),
  );

  return (
    <ThemeProvider defaultTheme="dark">
      <QueryClientProvider client={queryClient}>
        {/* we wrap the entire app in the TRPCProvider */}
        <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
          <Outlet />
        </TRPCProvider>
      </QueryClientProvider>
    </ThemeProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

We don’t forget to define the environment variable that specifies the api url to the trpcClient:

# .env.development in the dashboard
VITE_API_URL=http://localhost:3010/trpc
Enter fullscreen mode Exit fullscreen mode

And enable type safety with that environment variable using a vite-env.d.ts:

interface ViteTypeOptions {
  strictImportMetaEnv: unknown;
}

interface ImportMetaEnv {
  readonly VITE_API_URL: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}
Enter fullscreen mode Exit fullscreen mode

Before doing to the data fetching itself, let’s add the url of the dashboard to the list of the allowed clients in the api, otherwise it will be blocked by CORS.

Back to the api: definition of the allowed clients in a constants.ts in the src directory:

// src/constants.ts

export const ALLOWED_CLIENTS = [
  process.env.DASHBOARD_URL || "http://localhost:5173",
];
Enter fullscreen mode Exit fullscreen mode

We add it as an environment variable:

NODE_ENV=development
JWT_SECRET=...
DASHBOARD_URL=http://localhost:5175
Enter fullscreen mode Exit fullscreen mode

And update our schema:

import { z } from "zod";

export const envSchema = z.object({
  NODE_ENV: z.enum(["production", "development"]),

  JWT_SECRET: z.string().trim().min(1),

  DASHBOARD_URL: z.url(),
});

export type Env = z.infer<typeof envSchema>;
Enter fullscreen mode Exit fullscreen mode

To finally enable the CORS in the entrypoint of the api:

import cookieParser from "cookie-parser";

import { NestFactory } from "@nestjs/core";

import { AppModule } from "./app.module";
import { ALLOWED_CLIENTS } from "./constants";
import { TrpcRouter } from "./trpc/trpc.router";

const bootstrap = async () => {
  const app = await NestFactory.create(AppModule);
  app.enableCors({
    origin: ALLOWED_CLIENTS, // pass the allowed clients here
    credentials: true,
  });

  app.use(cookieParser());

  const trpc = app.get(TrpcRouter);
  trpc.applyMiddleware(app);

  await app.listen(process.env.PORT ?? 3010);
};
bootstrap();
Enter fullscreen mode Exit fullscreen mode

We should be OK with the CORS.
Now, the long awaited moment: data fetching:

//src/app/pages/root.tsx
import { ModeToggle } from "@/components/mode-toggle";
import { useTRPC } from "@/utils/trpc";
import { Button } from "@repo/ui/components/ui/button";
import { useSuspenseQuery } from "@tanstack/react-query";

export const Root = () => {
  const trpc = useTRPC();

  const { data } = useSuspenseQuery(
    trpc.getHello.queryOptions({ name: "Friedrich" }),
  );

  return (
    <main>
      <div className="flex justify-between items-center p-4">
        <h1 className="text-4xl">Root</h1>
        <ModeToggle />
      </div>
      <p>Some text here</p>
      <Button>Shadcn button</Button>
      <p>Data fetched from the server: {data}</p>
    </main>
  );
};
Enter fullscreen mode Exit fullscreen mode

And boom! We get the data:

data fetched dashboard

  • web app (Astro)

Here too, we create a trpc client instance:

// src/utils/trpc.ts
import superjson from "superjson";

import type { AppRouter } from "@repo/trpc/router";
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";

export const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: import.meta.env.PUBLIC_API_URL,
      transformer: superjson,
    }),
  ],
});

export type Inputs = inferRouterInputs<AppRouter>;
export type Outputs = inferRouterOutputs<AppRouter>;
Enter fullscreen mode Exit fullscreen mode

Then define the url of the api in as an environment variable:

# .env.development in the web app
PUBLIC_API_URL=http://localhost:3010/trpc
Enter fullscreen mode Exit fullscreen mode

And of course type safety enabled using a env.d.ts file:

interface ViteTypeOptions {
  strictImportMetaEnv: unknown;
}

interface ImportMetaEnv {
  readonly PUBLIC_API_URL: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}
Enter fullscreen mode Exit fullscreen mode

Similarly to the dashboard, we need to update the allowed clients to not be blocked by the CORS.

// src/constants.ts

export const ALLOWED_CLIENTS = [
  process.env.DASHBOARD_URL || "http://localhost:5173",
  process.env.WEB_APP_URL || "http://localhost:4321",
];
Enter fullscreen mode Exit fullscreen mode
NODE_ENV=development
JWT_SECRET=...
DASHBOARD_URL=http://localhost:5175
WEB_APP_URL=http://localhost:4321
Enter fullscreen mode Exit fullscreen mode

and

import { z } from "zod";

export const envSchema = z.object({
  NODE_ENV: z.enum(["production", "development"]),

  JWT_SECRET: z.string().trim().min(1),

  DASHBOARD_URL: z.url(),
  WEB_APP_URL: z.url(),
});

export type Env = z.infer<typeof envSchema>;
Enter fullscreen mode Exit fullscreen mode

Good for data fetching:

---
import { ModeToggle } from "@/components/mode-toggle";
import Layout from "@/layouts/layout.Astro";
import { trpc } from "@/utils/trpc";
import { Button } from "@repo/ui/components/ui/button";

const data = await trpc.getHello.query({ name: "Friedrich" });
---

<Layout>
  <div class="flex items-center justify-between p-4">
    <h1 class="text-4xl">Web app</h1>
    <ModeToggle client:load />
  </div>
  <p>A text paragraph</p>
  <Button className="w-32">Shadcn button</Button>
  <p>Data fetched from the server: {data}</p>
</Layout>
Enter fullscreen mode Exit fullscreen mode

Perfect!

data fetched web app

We were able to setup a public procedure and call it from the clients, and it is type safe. We get auto-completion for our queries.

  • dashboard

type safety dashboard api

  • web app

type safety web app api

If I change the shape of the data returned on the backend for example, we will get immediate feedback on the frontend side.

frontend udpated trpc

I could try and do the same for a private procedure, but this would make this article way too long. I will stop here and continue in a part two. There, we will add private procedures and a PostgreSQL database if possible.

I really like this monorepo setup. On one hand, we get fully typed queries on our frontends, no way to make mistakes. The feedback is immediate if we change the entry or return type of a procedure in the backend. We can define procedures to get exactly what we need from the server: no under or over fetching. It works great with @tanstack/react-query which is almost a standard for data fetching in React applications. But on the other hand, it requires a bit of setup, especially with tsconfigs. Also tRPC doesn’t integrate well with REST and OpenAPI. There is a plugin to bridge that gap, but it has been archived. A solution I have considered to fix that issue is oRPC, but for the moment tRPC gets the job done.

Thanks you for reading and happy coding!

Top comments (0)