Part 6: Database integration
Two approaches: raw pg and Prisma ORM
This part covers both. Use raw pg when you want full SQL control and minimal abstraction. Use Prisma when you want type-safe queries, auto-generated client, and migrations managed in code.
Start with raw pg to understand what the ORM is hiding. Then switch to Prisma for larger projects.
PostgreSQL setup
You need a running Postgres instance. Options:
# Docker (quickest)docker run --name pg-dev -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=myapp -p 5432:5432 -d postgres:15
# Homebrew on macOSbrew install postgresql@15brew services start postgresql@15createdb myappnode-postgres (pg)
Install:
npm install pgnpm install --save-dev @types/pgConnection pool
Use a pool, not individual connections. A pool keeps a set of connections open and recycles them across requests:
const { Pool } = require('pg');
const pool = new Pool({ host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT, 10) || 5432, database: process.env.DB_NAME || 'myapp', user: process.env.DB_USER || 'postgres', password: process.env.DB_PASSWORD || 'secret', max: 20, // max open connections idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000,});
pool.on('error', (err) => { console.error('Unexpected pg pool error', err);});
module.exports = pool;Or use a DATABASE_URL connection string (what Heroku and Railway provide):
const pool = new Pool({ connectionString: process.env.DATABASE_URL });Parameterized queries
Always use parameterized queries ($1, $2, …). Never interpolate user input into SQL strings.
const pool = require('../db/pool');
// Safe: user input goes through pg's parameterizationasync function getUserById(id) { const result = await pool.query('SELECT id, email, name FROM users WHERE id = $1', [id]); return result.rows[0] || null;}
// Unsafe: never do this// const result = await pool.query(`SELECT * FROM users WHERE id = ${id}`);result.rows is an array of plain objects. result.rows[0] is the first row or undefined.
CRUD with raw pg
const pool = require('./pool');
async function createUser({ email, name, passwordHash }) { const result = await pool.query( 'INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, email, name, created_at', [email, name, passwordHash] ); return result.rows[0];}
async function getUserByEmail(email) { const result = await pool.query( 'SELECT id, email, name, password_hash FROM users WHERE email = $1', [email] ); return result.rows[0] || null;}
async function listUsers({ limit = 20, offset = 0 } = {}) { const result = await pool.query( 'SELECT id, email, name, created_at FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2', [limit, offset] ); return result.rows;}
async function updateUser(id, { name }) { const result = await pool.query( 'UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2 RETURNING id, email, name', [name, id] ); return result.rows[0] || null;}
async function deleteUser(id) { const result = await pool.query('DELETE FROM users WHERE id = $1 RETURNING id', [id]); return result.rowCount > 0;}
module.exports = { createUser, getUserByEmail, listUsers, updateUser, deleteUser };Transactions
When multiple queries must succeed or fail together, use a transaction:
async function transferCredits(fromUserId, toUserId, amount) { const client = await pool.connect(); try { await client.query('BEGIN');
await client.query( 'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2', [amount, fromUserId] );
await client.query( 'UPDATE accounts SET balance = balance + $1 WHERE user_id = $2', [amount, toUserId] );
await client.query('COMMIT'); } catch (err) { await client.query('ROLLBACK'); throw err; // re-throw so the route handler can respond with 500 } finally { client.release(); // always release back to the pool }}The pattern: connect() to get a dedicated client, BEGIN, do work, COMMIT on success, ROLLBACK on error, release() in finally.
Prisma ORM
Prisma generates a type-safe client from your schema. It handles migrations, relationships, and complex queries without raw SQL.
Install Prisma
npm install prisma --save-devnpm install @prisma/clientnpx prisma initprisma init creates prisma/schema.prisma and a .env with DATABASE_URL.
Schema
prisma/schema.prisma
datasource db { provider = "postgresql" url = env("DATABASE_URL")}
generator client { provider = "prisma-client-js"}
model User { id Int @id @default(autoincrement()) email String @unique name String? passwordHash String @map("password_hash") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") posts Post[]
@@map("users")}
model Post { id Int @id @default(autoincrement()) title String body String published Boolean @default(false) authorId Int @map("author_id") author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) @map("created_at")
@@map("posts")}Migrations
npx prisma migrate dev --name initThis creates a SQL migration file in prisma/migrations/, applies it, and regenerates the Prisma client.
Prisma client
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],});
module.exports = prisma;Instantiate once, reuse everywhere. Creating multiple PrismaClient instances exhausts the connection pool.
CRUD with Prisma
const prisma = require('../db/prisma');
// Createconst user = await prisma.user.create({ data: { email: 'alice@example.com', name: 'Alice', passwordHash: hashedPassword, }, select: { id: true, email: true, name: true, createdAt: true }, // omit passwordHash});
// Find oneconst user = await prisma.user.findUnique({ where: { email: 'alice@example.com' },});
// Find many with paginationconst users = await prisma.user.findMany({ skip: offset, take: limit, orderBy: { createdAt: 'desc' }, select: { id: true, email: true, name: true, createdAt: true },});
// Updateconst updated = await prisma.user.update({ where: { id: userId }, data: { name: 'Alice Smith' },});
// Deleteawait prisma.user.delete({ where: { id: userId } });
// With related dataconst userWithPosts = await prisma.user.findUnique({ where: { id: userId }, include: { posts: { where: { published: true }, orderBy: { createdAt: 'desc' } } },});Prisma transactions
const [debit, credit] = await prisma.$transaction([ prisma.account.update({ where: { userId: fromUserId }, data: { balance: { decrement: amount } }, }), prisma.account.update({ where: { userId: toUserId }, data: { balance: { increment: amount } }, }),]);For interactive transactions (when you need to read then write):
await prisma.$transaction(async (tx) => { const account = await tx.account.findUnique({ where: { userId: fromUserId } }); if (account.balance < amount) throw new Error('Insufficient funds');
await tx.account.update({ where: { userId: fromUserId }, data: { balance: { decrement: amount } }, });
await tx.account.update({ where: { userId: toUserId }, data: { balance: { increment: amount } }, });});Wiring into Express routes
const express = require('express');const prisma = require('../db/prisma');const requireAuth = require('../middleware/requireAuth');
const router = express.Router();
router.get('/', requireAuth, async (req, res, next) => { try { const page = Math.max(1, parseInt(req.query.page, 10) || 1); const limit = Math.min(100, parseInt(req.query.limit, 10) || 20);
const [users, total] = await prisma.$transaction([ prisma.user.findMany({ skip: (page - 1) * limit, take: limit, select: { id: true, email: true, name: true, createdAt: true }, orderBy: { createdAt: 'desc' }, }), prisma.user.count(), ]);
res.json({ data: users, meta: { page, limit, total } }); } catch (err) { next(err); }});
router.get('/:id', requireAuth, async (req, res, next) => { try { const user = await prisma.user.findUnique({ where: { id: parseInt(req.params.id, 10) }, select: { id: true, email: true, name: true, createdAt: true }, });
if (!user) return res.status(404).json({ error: 'User not found' }); res.json(user); } catch (err) { next(err); }});
module.exports = router;Gotchas
- Connection pool exhaustion. If every query opens a new
pg.Clientwithout releasing it, you run out of connections. Always usePooland callclient.release()infinally. - SQL injection. Parameterized queries (2) are the defense. String interpolation into SQL is always wrong.
- Prisma client singleton. Instantiate
PrismaClientonce. In Next.js or hot-reloading environments, use a module-level singleton pattern to avoid creating a new client on every hot reload. - Prisma
selectto exclude passwords. Withoutselect, Prisma returns all fields includingpasswordHash. Always exclude sensitive fields at the query level. BigIntin Prisma. By default, Prisma serializesBigIntas a string in JSON. If your IDs or counts areBigInt, you need a custom JSON serializer or useIntwhere possible.
What’s next
Part 7 covers input validation with Zod and centralized error handling, so database errors and validation failures return consistent JSON error shapes.