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:
- Part One
- Part Two <— you are here
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.