Skip to content

Mythica: tRPC Server with Prisma

Posted on:May 22, 2023 at 07:00 PM

image

This series of articles focuses on building a full-stack app with the following technology stack: PlanetScale - Prisma - tRPC - React. The project name is mythica and it will allow user to collect random mythical creatures. Here is the full list of the articles:

Table of contents

Open Table of contents

Project Structure

We will be adding multiple new files to the server directory such that it will look like this:

.
|____server
| |____prisma
| | |____schema.prisma
| |____.gitignore
| |____package.json
| |____.env
| |____src
| | |____main.ts
| | |____lib
| | | |____trpc.ts
| | | |____db.serve.ts
| | |____router
| | | |____creatureRouter.ts
| | | |____index.ts
|____client

Express Scaffolding

We need to scaffold our Express server since it will glue our client code to our trpc code. If you are following along, all we need to do for now is to add this code to server/src/main.ts

import express, { Application, NextFunction, Request, Response } from "express";

const app: Application = express();

app.get("/", (req: Request, res: Response, next: NextFunction) => {
  res.json({ message: "Hello World!" });
});

const PORT: number = Number(process.env.PORT) || 3000;

app.listen(PORT, () => {
  console.log(`✨ ✨ ✨ Server running on port ${PORT} ✨ ✨ ✨`);
});

Before we run our server to test it, we need to add this script inside server/package.json

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "npx ts-node-dev ./src/main.ts" // add this
  }

In addition we have our parent package.json in the root referencing the scripts inside the client folder in its scripts section

 "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "npm run dev --workspace=client & npm run dev --workspace=server"
  }

But we do not have this since we have not worked on the client yet, we will simply remove the client part and re-add it later. So if you are following along your package.json’s scripts in the root of the project should be modified as follows:

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "npm run dev --workspace=server"
  }

With that in place, let’s try to run our server. From the root directory let’s run npm run dev. You should be presented with similar message as the following:

npm run dev
> [email protected] dev
> npm run dev --workspace=server


> [email protected] dev
> npx ts-node-dev ./src/main.ts

[INFO] 11:44:34 ts-node-dev ver. 2.0.0 (using ts-node ver. 10.9.1, typescript ver. 5.0.4)
✨ ✨ ✨ Server running on port 3000 ✨ ✨ ✨

And if we hit our dummy Hello World endpoint we defined earlier we can see it is working as expected.

curl "localhost:3000/"
{"message":"Hello World!"}

Define Prisma Client

Previously we connected our Prisma schema with our PlanetScale database. However, we still need to setup the access layer for our data. Fortunately, we can leverage the @prisma/client dependency we added int the previous article in order to let the server have access to the ORM functionality Prisma exposes for us. So inside server/src/lib/db.server.ts let’s add the following code.

import { PrismaClient } from "@prisma/client";

let db: PrismaClient;

declare global {
  var __db: PrismaClient | undefined;
}

if (!global.__db) {
  global.__db = new PrismaClient();
}

db = global.__db;

export { db };

Now, why do you need to do this? Yes, we can simply use PrismaClient directly, however the previous piece of code is necessary to ensure that we have only one PrismaClient instance connected to out database, as we do not want to end up having multiple connections to our database from the same application. With this set up, we can now let our trpc layer have safe access to db layer and use PrismaClient to query our database in the next section.

Working With tRPC

Let’s now see how can we setup our trpc functions that we can execute later from the client with type safety.

Inside server/src/lib/trcp.ts we will expose the trpc instance after creating it.

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

export const trpc = initTRPC.create();

Defining Routers

Now our router directory is really important as it will house all of our type safe functions that will be exposed to our front-end to be able to query our database. The creatureRouter.ts as the name suggests will house all query logic on our Creature table that was created in the previous article. index.ts file is the router that nests other routers and eventually expose them to the client, in our case it will nest creatureRouter only, but as you may have noticed, we can have more than one router; for example, userRouter if we have a table of users, and all these different router will end up nested inside the main router we will create inside of our index.ts file. Things will get clearer as we move on.

Let’s first write the query logic inside of our creatureRouter.ts, if you are following along, paste this code inside of creatureRouter.ts

import { trpc } from "../lib/trpc";
import { db } from "../lib/db.server";

export const creatureRouter = trpc.router({
  getRandom: trpc.procedure.query(async () => {
    const id = Math.floor(Math.random() * 20);
    const creature = await db.creature.findUnique({
      where: {
        id: id,
      },
    });
    return creature;
  }),
});

In the previous code, we are creating creatureRouter that will have multiple methods that access our creature ORM layer provided by Prisma and return to the client the data. getRandom is the first method we are defining here as users from the React application will be presented with a random card each time they visit the application, and I will be seeding the database with 20 different creatures hence this line const id = Math.floor(Math.random() * 20);. This is application is like a game where you have to collect all the 20 creatures without hitting the same one twice in a run, as this will nullify your previous possession of the same creature. The rest of the logic in the previous snippet should clear enough since we are just accessing our PrismaClient to get one Creature based on the random id.

However, for the same of this tutorial we will refactor the data returned from getRandom method to only return the name and photo of the creature in a custom response.

import { trpc } from '../lib/trpc';
import { db } from '../lib/db.server';
import { z } from 'zod';

export const creatureRouter = trpc.router({
  getRandom: trpc.procedure.query(async () => {
    const id = Math.floor(Math.random() * 20);
    const creature = await db.creature.findUnique({
      where: {
        id: id,
      },
    });
    if (creature) {
      const { name, photo } = creature;
      return {
        creature: {
          name: name,
          photo: photo,
        },
      };
    }
    return creature;
  }),

Define getDetails method

We did this just to define another method that wil retrieve the entire Creature object and return it to the client based on an input param the client will send to our server. Basically, from the client and upon revealing the name and photo of the random creature the user collected, the user can then click on it to view all the information about this creature which will be done by defining a new trpc method called getDetails which will receive the id of the creature clicked on from the React application and send back the entire creature object. So our creatureRouter will be refactored to the following code:

import { trpc } from "../lib/trpc";
import { db } from "../lib/db.server";
import { z } from "zod";

export const creatureRouter = trpc.router({
  getRandom: trpc.procedure.query(async () => {
    const id = Math.floor(Math.random() * 20);
    const creature = await db.creature.findUnique({
      where: {
        id: id,
      },
    });
    if (creature) {
      const { name, photo } = creature;
      return {
        creature: {
          name: name,
          photo: photo,
        },
      };
    }
    return creature;
  }),

  getDetails: trpc.procedure
    .input(z.object({ id: z.number().int() }))
    .query(async ({ input }) => {
      const inputId = input.id;
      const creature = await db.creature.findUnique({
        where: {
          id: inputId,
        },
      });
      return creature;
    }),
});

Note how getDetails is setup in a different way to creatureRouter is, and that is because getDetails expects an input param from the client creatureRouter does not as it simply checks if a valid Creature is retrieved and returns a piece of its data. While getDetails takes the id from the client which is validated against the z.number().int(), hence the client will get compile time error if it is trying to send the wrong data type. Then we do our query as normal and return the entire Creature object this time.

Merging Routers

Now we can merge our creatureRouter into our index.ts router. Using the word merge may sound misleading as all we have is one router only, but as we have said before. index.ts will have the main router that will house multiple other routers we may create in the future. So, inside index.ts let’s simply add the following code:

import { trpc } from "../lib/trpc";
import { creatureRouter } from "./creatureRouter";

export const appRouter = trpc.router({
  creature: creatureRouter,
});

export type AppRouter = typeof appRouter; // exporting the type of this router to the client

We also need to export this in order to the client so create src/index.ts file and simply export your router from it like so

export * from "./router";

and change the main key in server/package.json to point to the newly created index.ts file like so:

...
 "main": "./src/index.ts",
 ...

Express Middleware

We have covered a lot in this tutorial already. But one last step is needed to complete the setup of trpc. Since express is serving our application we need to add the middleware that will expose our trpc router for the client. In order to that, we need to refactor our src/main.ts to use the trpc express adaptor like so:

import express, { Application, NextFunction, Request, Response } from "express";

import * as trpcX from "@trpc/server/adapters/express";

import { appRouter } from "./router";

import cors from "cors";

const app: Application = express();

app.use(cors());

app.use(
  "/mythica",
  trpcX.createExpressMiddleware({
    router: appRouter,
  })
);

const PORT: number = Number(process.env.PORT) || 3000;

app.listen(PORT, () => {
  console.log(`✨ ✨ ✨ Server running on port ${PORT} ✨ ✨ ✨`);
});

Note that we have also added a cors middleware to allow access to the server from our client application.

Conclusion

In this article we have covered how can we create routes based on trpc and connect them to our Prisma ORM and Express server. So, with that in place, we have finalized the essential parts to setup our trpc server and we can next start working on our React app and possibly modify the server as we go.

You can find the final code for this article here