Published from Publish Studio
In this tutorial, let's learn how to connect a Postgres database to a tRPC express backend using Drizzle ORM. I have also created a simple frontend for our finance tracker application. You can copy frontend code from the repo here.
This is part 2, read part 1 here: Let's Build a Full-Stack App with tRPC and Next.js 14
Backend
If you don't have Postgres installed locally, please do or you can also use a hosted database.
Once you have Postgres ready, add DATABASE_URL
to your .env
:
DATABASE_URL=postgres://postgres:password@localhost:5432/myDB
Setting up db with drizzle
To set up drizzle, start off by installing these packages:
yarn add drizzle-orm pg dotenv
yarn add -D drizzle-kit tsx @types/pg
Now, all you have to do is connect drizzle to the DB. To do that, create src/utils/db.ts
file and configure drizzle:
import { drizzle } from "drizzle-orm/node-postgres";
import pg from "pg";
const { Pool } = pg;
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === "production",
});
export const db = drizzle(pool);
That's it! Our db setup is ready. We can now create tables and interact with our db using drizzle ORM.
Create the first module
Regarding the project structure, there are mainly two types:
- Modules: Divide features into different modules and keep all related files together. Popular frameworks like NestJs and Angular use this structure.
.
└── feature/
├── feature.controller.ts
├── feature.routes.ts
├── feature.schema.ts
└── feature.service.ts
- Separate folders based on the purpose:
.
├── controllers/
│ ├── feature1.controller.ts
│ └── feature2.controller.ts
├── services/
│ ├── feature1.service.ts
│ └── feature2.service.ts
└── models/
├── feature1.model.ts
└── feature2.model.ts
I personally prefer modules because it just makes sense (plz stop using 2nd one).
Now, let's create our first module called transaction
. This is our core feature. Start by creating src/modules/transaction/transaction.schema.ts
file. This is where we define transaction schema using drizzle.
The great thing about using drizzle to write schemas is it lets us use typescript. So you don't have to learn a new syntax and ensure type safety for your schemas.
To record a transaction (txn), the most basic things we need are:
- txn amount
- txn type - credit or debit
- description - a simple note to refer to later
- tag - a category like shopping/travel/food, and so on.
First, let's create enums for txn type and tag:
import {
pgEnum,
} from "drizzle-orm/pg-core";
export const txnTypeEnum = pgEnum("txnType", ["Incoming", "Outgoing"]);
export const tagEnum = pgEnum("tag", [
"Food",
"Travel",
"Shopping",
"Investment",
"Salary",
"Bill",
"Others",
]);
Then, let's create the schema:
import {
integer,
pgTable,
serial,
text,
timestamp,
} from "drizzle-orm/pg-core";
export const transactions = pgTable("transactions", {
id: serial("id").primaryKey(),
amount: integer("amount").notNull(),
txnType: txnTypeEnum("txn_type").notNull(),
summary: text("summary"),
tag: tagEnum("tag").default("Others"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date()),
});
As you can see, we simply wrote typescript code and created a table!
Run migrations
One final step before we can start interacting with our db is to apply changes to our database so that all the tables will be created. To do that we have to run migrations. Drizzle has this amazing tool called drizzle-kit
which handles migrations for us, so all we have to do is run a command.
Before doing that we have to create a file called drizzle.config.ts
in the project root, which includes all the information about the database and schemas.
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/**/*.schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
ssl: process.env.NODE_ENV === "production",
},
});
With that ready, run the below command:
yarn dlx drizzle-kit push
That's it! Now we can start interacting with db and write our business logic.
Business logic
Let's add logic to add new transactions.
If you already don't know:
- Service - where we interact with DB and write most of the business logic
- Controller - handle request/response
Create transaction/transaction.service.ts
and write logic to add new transactions to db:
import { TRPCError } from "@trpc/server";
import { db } from "../../utils/db";
import { transactions } from "./transaction.schema";
export default class TransactionService {
async createTransaction(data: typeof transactions.$inferInsert) {
try {
return await db.insert(transactions).values(data).returning();
} catch (error) {
console.log(error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create transaction",
});
}
}
}
Another benefit of using drizzle ORM is it provides type definitions for different CRUD methods like $inferInsert
, $inferSelect
so there is no need to define the types again. Here, by using typeof transactions.$inferInsert
we don't have to provide values for fields like primary key, and fields with default values like createdAt
, and updatedAt
, so typescript won't throw an error.
Drizzle also has extensions like drizzle-zod which can be used to generate zod schemas. Another headache was prevented by drizzle 🫡. So open transaction.schema.ts
and create zod schema for insert operation:
import { createInsertSchema } from "drizzle-zod";
export const insertUserSchema = createInsertSchema(transactions).omit({
id: true,
createdAt: true,
updatedAt: true,
});
Let's use this in the controller, create transaction/transaction.controller.ts
:
export default class TransactionController extends TransactionService {
async createTransactionHandler(data: typeof transactions.$inferInsert) {
return await super.createTransaction(data);
}
}
Now, all that remains is to expose this controller through an endpoint. For that, create transaction/transaction.routes.ts
. Since we are using tRPC, to create an endpoint, we have to define a procedure:
import { publicProcedure, router } from "../../trpc";
import TransactionController from "./transaction.controller";
import { insertUserSchema } from "./transaction.schema";
const transactionRouter = router({
create: publicProcedure
.input(insertUserSchema)
.mutation(({ input }) =>
new TransactionController().createTransactionHandler(input)
),
});
export default transactionRouter;
If you remember from part 1, we created a reusable router
that can be used to group procedures and publicProcedure
which creates an endpoint.
Finally, open src/routes.ts
and use the above transactionRouter
:
import transactionRouter from "./modules/transaction/transaction.routes";
import { router } from "./trpc";
const appRouter = router({
transaction: transactionRouter,
});
export default appRouter;
That's it! The backend is ready. This is the final backend structure:
.
├── README.md
├── drizzle
│ ├── 0000_true_junta.sql
│ └── meta
│ ├── 0000_snapshot.json
│ └── _journal.json
├── drizzle.config.ts
├── package.json
├── src/
│ ├── index.ts
│ ├── modules/
│ │ └── transaction/
│ │ ├── transaction.controller.ts
│ │ ├── transaction.routes.ts
│ │ ├── transaction.schema.ts
│ │ └── transaction.service.ts
│ ├── routes.ts
│ ├── trpc.ts
│ └── utils/
│ ├── db.ts
│ └── migrate.ts
├── tsconfig.json
└── yarn.lock
Challenge for you
Before proceeding to frontend integration, as a challenge, create an endpoint for getting all transactions.
Frontend
It's time to integrate the created endpoints in our frontend. Since this is not a frontend tutorial, I'll let you just copy the code from the repo.
All I've changed is:
- Set up shadcn/ui
- Change
src/components/modules/dashboard/index.tsx
Also, as you observe, I'm using a modules-like structure here too. If you also like this structure, you can learn more from my previous projects Publish Studio and My One Post
In part 1, we queried data using built-in tRPC react-query.
...
const { data } = trpc.test.useQuery();
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
{data}
</main>
);
...
So, if you already know react-query, there's isn't much to learn except with tRPC we don't have to create queryFn
or mutationFn
because we directly call backend methods.
This is how mutations are used:
...
const { mutateAsync: createTxn, isLoading: isCreating } =
trpc.transaction.create.useMutation({
onSuccess: async () => {
form.reset();
await utils.transaction.getAll.invalidate();
},
});
const addTransaction = async (data: z.infer<typeof formSchema>) => {
try {
await createTxn(data);
} catch (error) {
console.error(error);
}
};
...
See In Action
I hope you like this tutorial. Feel free to extend the functionality. In the next article, I'll share how to add authentication.
Project source code can be found here.
Follow me for more 🚀. Socials
Top comments (0)