Node.js, Express and Prisma: API and database recipes
Sixteen server-side snippets: HTTP servers, env defaults, bcrypt hashing, constant-time comparison, Stripe webhook idempotency, Express middleware for CORS, JWT auth, rate limiting and errors, plus Prisma schema, migrations, CRUD and pagination.
A whole request path in sixteen pieces: the server accepts a connection, middleware decides whether to continue, a handler talks to the database, and something has to happen when any of that fails.
Middleware order is the API's actual behaviour
Express runs middleware in registration order, which makes the order part of the contract rather than a detail. Rate limiting before authentication means unauthenticated floods are cheap to reject; after it means every attacker request costs you a token verification. CORS before everything means preflight requests get an answer without touching your auth logic.
The error handler is the exception that catches people out: Express identifies it by its four-argument signature and only reaches it if it is registered last. Register it early and it never runs, which is why a "centralised error handler" sometimes appears to do nothing at all.
Webhooks need idempotency, not just verification
Stripe retries a webhook until it gets a 2xx, and a network timeout on your side after the work is done still counts as a failure — so the same event arrives twice. Verifying the signature proves the event is genuine; it does not prevent processing it twice. Recording the event ID and refusing duplicates is what makes retries safe, and it is the difference between a reliable integration and one that occasionally grants two subscriptions for one payment. The Stripe documentation on webhook best practices is explicit about this.
Secrets compare differently from passwords
bcrypt.compare handles password verification. Everything else — API keys, webhook secrets, session tokens — needs crypto.timingSafeEqual, because === on strings returns as soon as two bytes differ and leaks the length of the matching prefix through response time. The two cases look similar and are not interchangeable.
Prisma: the migration workflow is the risky part
Schema and CRUD are the easy half. The part worth reading carefully is migrations: prisma migrate dev is a development command that can reset your database, and running it against production is the classic accident. Production uses prisma migrate deploy, which only applies pending migrations and never resets.
Pagination has a similar split. Offset pagination is simple and gets slower as the offset grows, because the database still walks the skipped rows. Cursor pagination stays fast at any depth but cannot jump to an arbitrary page. Both snippets are here because the right answer depends on whether your UI has page numbers.
Create a basic HTTP server
The bare Node.js way to serve requests — no Express needed for something this simple.
How it works: createServer takes a callback that runs on every incoming request; you inspect req.url/req.method yourself to decide what to do, and must always call res.end() to finish the response.
import { createServer } from 'node:http'
const server = createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'ok' }))
return
}
res.writeHead(404)
res.end('Not found')
})
server.listen(3000, () => console.log('Listening on http://localhost:3000'))
Hash and verify a password with bcrypt
The standard way to store passwords safely in Node.js — never store or compare plain text.
How it works: bcrypt generates a unique random salt per call (baked into the returned hash string itself), so hashing the same password twice produces two different hashes — compare() re-extracts that salt from the stored hash to check a candidate password, so you never need to store the salt separately.
import bcrypt from 'bcrypt'
async function hashPassword(plainPassword) {
const saltRounds = 12
return bcrypt.hash(plainPassword, saltRounds)
}
async function verifyPassword(plainPassword, storedHash) {
return bcrypt.compare(plainPassword, storedHash)
}
// Example usage
const hash = await hashPassword('correct horse battery staple')
const isValid = await verifyPassword('correct horse battery staple', hash)
Read environment variables with defaults
A tiny helper that reads a Node.js environment variable and falls back to a default when it's missing.
How it works: it checks process.env for the key and returns the fallback whenever the variable is undefined or an empty string.
function env(key, fallback) {
const value = process.env[key]
return value === undefined || value === '' ? fallback : value
}
// Example usage
const port = Number(env('PORT', 3000))
const dbUrl = env('DATABASE_URL', 'sqlite://local.db')
Read a file asynchronously
The modern Promise-based way to read a file's contents in Node.js — no callbacks, and no blocking the event loop like fs.readFileSync would.
How it works: fs/promises (built into Node since v14) lets you await file operations directly instead of nesting callbacks or wrapping fs.readFile in a manual Promise — and unlike readFileSync, it doesn't block other requests from being handled while the disk read is in flight.
import { readFile } from 'node:fs/promises'
async function loadConfig(path) {
const contents = await readFile(path, 'utf-8')
return JSON.parse(contents)
}
// Example usage
const config = await loadConfig('./config.json')
Constant-time string comparison for secrets
Compares two secrets (API tokens, webhook signatures) without leaking timing information that could help an attacker guess them character by character.
How it works: a plain === comparison returns as soon as it hits the first mismatched character, so response time subtly correlates with how many leading characters were correct — over many requests an attacker can exploit that to guess a secret one character at a time. timingSafeEqual always takes the same time regardless of where the strings differ.
import { timingSafeEqual } from 'node:crypto'
function timingSafeStringEquals(a, b) {
const bufferA = Buffer.from(a, 'utf8')
const bufferB = Buffer.from(b, 'utf8')
// Length check short-circuits before the constant-time compare — safe,
// since the expected length is fixed per endpoint and doesn't leak secret content
if (bufferA.length !== bufferB.length) return false
return timingSafeEqual(bufferA, bufferB)
}
// Example usage — verifying a webhook signature
if (!timingSafeStringEquals(receivedSignature, expectedSignature)) {
throw new Error('Invalid signature')
}
Stripe webhook handler with idempotency
Process Stripe webhooks safely — verify signatures, deduplicate events, handle checkout.session.completed and subscription updates.
How it works: Stripe sends each event with a signature in the header. The handler verifies it using the endpoint secret, then checks a deduplication set before processing — the same event ID is never processed twice, even if Stripe retries.
import Stripe from 'stripe'
import express from 'express'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature']
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(req.body, sig!, endpointSecret)
} catch {
return res.status(400).send('Invalid signature')
}
// Deduplicate — event.id is unique
if (await hasBeenProcessed(event.id)) {
return res.json({ received: true }) // already done
}
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object
await activateSubscription(session.client_reference_id, session.subscription)
break
case 'invoice.payment_succeeded':
await extendSubscription(event.data.object.subscription)
break
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await syncSubscriptionStatus(event.data.object)
break
}
await markProcessed(event.id)
res.json({ received: true })
})
async function hasBeenProcessed(id: string): Promise<boolean> {
// Use Redis SET or DB table
return false
}
async function markProcessed(id: string): Promise<void> {
// Store event ID with TTL
}
JWT access and refresh token rotation in Express
A complete JWT auth middleware with short-lived access tokens and httpOnly refresh token rotation — no refresh token reuse vulnerability.
How it works: the access token lives 15 minutes and is sent in the response body. The refresh token is stored as an httpOnly cookie and rotated on each use — the old one is invalidated immediately, preventing reuse if stolen.
import jwt from 'jsonwebtoken'
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!
function signTokens(userId: string) {
const accessToken = jwt.sign({ sub: userId }, ACCESS_SECRET, { expiresIn: '15m' })
const refreshToken = jwt.sign({ sub: userId, jti: crypto.randomUUID() }, REFRESH_SECRET, { expiresIn: '7d' })
return { accessToken, refreshToken }
}
function authMiddleware(req, res, next) {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) return res.sendStatus(401)
try {
req.user = jwt.verify(header.slice(7), ACCESS_SECRET)
next()
} catch {
res.sendStatus(401)
}
}
// Refresh endpoint with rotation
app.post('/auth/refresh', (req, res) => {
const token = req.cookies.refreshToken
if (!token) return res.sendStatus(401)
try {
const payload = jwt.verify(token, REFRESH_SECRET)
// Invalidate old refresh token in DB
invalidateToken(payload.jti)
// Issue new pair
const tokens = signTokens(payload.sub)
setRefreshCookie(res, tokens.refreshToken)
res.json({ accessToken: tokens.accessToken })
} catch {
res.clearCookie('refreshToken')
res.sendStatus(401)
}
})
function setRefreshCookie(res, token) {
res.cookie('refreshToken', token, {
httpOnly: true, secure: true, sameSite: 'strict',
path: '/auth/refresh', maxAge: 7 * 24 * 60 * 60 * 1000,
})
}
Enable CORS in Express
The standard way to allow cross-origin requests from your frontend — either quick-and-open, or locked down to specific origins.
How it works: without CORS headers, a browser blocks any fetch/XHR request made from a different origin than the API (the classic "No 'Access-Control-Allow-Origin' header" error) — the cors middleware adds those headers automatically based on your config, including handling the OPTIONS preflight request.
import cors from 'cors'
// Quick and open — any origin can call this API
app.use(cors())
// Locked down to specific origins, with credentials (cookies) allowed
app.use(cors({
origin: ['https://example.com', 'https://app.example.com'],
credentials: true,
}))
Centralized error-handling middleware
Catches errors from any route/middleware in one place instead of repeating try/catch and error-formatting logic everywhere.
How it works: Express doesn't automatically catch rejected Promises inside async route handlers — an unhandled rejection just hangs the request. Wrapping handlers in asyncHandler forwards any thrown/rejected error to next(), which routes it to the 4-argument error-handling middleware (Express identifies error handlers by that exact signature).
// Wrap async route handlers so thrown errors reach Express's error handling
function asyncHandler(fn) {
return (req, res, next) => fn(req, res, next).catch(next)
}
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id)
if (!user) {
const error = new Error('User not found')
error.status = 404
throw error
}
res.json(user)
}))
// Must be registered LAST, after all other app.use()/routes
app.use((err, req, res, next) => {
console.error(err)
res.status(err.status || 500).json({ error: err.message || 'Internal server error' })
})
JWT Auth Middleware (Express)
Express middleware for verifying Authorization header JWT token, parsing payload data, and extending the Request object in TypeScript safely.
A complete middleware setup to protect routes in a Node.js / Express backend using jsonwebtoken. Includes TypeScript type definition merging to safely access req.user without compiler errors.
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
// 1. Extend global Express Request interface
declare global {
namespace Express {
interface Request {
user?: {
id: string
email: string
role?: string
}
}
}
}
const JWT_SECRET = process.env.JWT_SECRET || 'your-default-secret-key'
// 2. Auth check middleware
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Access token is missing or malformed'
})
}
const token = authHeader.split(' ')[1]
try {
const decoded = jwt.verify(token, JWT_SECRET) as {
id: string
email: string
role?: string
}
// Store user data in request object
req.user = {
id: decoded.id,
email: decoded.email,
role: decoded.role
}
next()
} catch (err) {
return res.status(403).json({
error: 'Forbidden',
message: 'Invalid or expired access token'
})
}
}
Rate limit requests
Caps how many requests a single IP can make in a time window — the standard first line of defense against abuse and brute-force attempts.
How it works: this in-memory limiter is enough for a single server instance; behind a load balancer with multiple instances you'd swap the default store for a shared one (e.g. rate-limit-redis) so all instances count against the same limit.
import rateLimit from 'express-rate-limit'
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per IP per window
standardHeaders: true, // adds RateLimit-* response headers
message: { error: 'Too many requests, please try again later.' },
})
app.use('/api/', limiter)
// Stricter limit for a sensitive endpoint
app.post('/api/login', rateLimit({ windowMs: 15 * 60 * 1000, max: 5 }), loginHandler)
Sanitize an uploaded filename
Strip a file extension and normalize the remaining name to a safe, URL-friendly slug before saving it to disk.
How it works:
replace(/\.[^/.]+$/, '')removes the extension.replace(/[^a-zA-Z0-9]+/g, '-')swaps every non-alphanumeric run for a single hyphen.replace(/^-+|-+$/g, '')trims any leading/trailing hyphens left behind.
function sanitizeFilename(filename) {
const nameWithoutExt = filename.replace(/\.[^/.]+$/, '')
const sanitized = nameWithoutExt
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return sanitized
}
// Example usage in an Express upload handler
app.post('/upload', upload.single('file'), (req, res) => {
const safeName = sanitizeFilename(req.file.originalname)
res.json({ savedAs: `${safeName}${path.extname(req.file.originalname)}` })
})
Prisma CRUD — create, read, update, delete
The four basic data operations with Prisma Client — create a user, find by email, update profile, and delete an account — all fully typed.
How it works: Prisma Client provides dedicated methods for each operation. Every query is type-safe — the return type is inferred from the schema, so accessing a non-existent field is a compile-time error.
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
// Create
const user = await prisma.user.create({
data: {
email: '[email protected]',
name: 'Alice',
role: 'USER',
},
})
// Read by unique field
const found = await prisma.user.findUnique({
where: { email: '[email protected]' },
})
// Read with filter and relations
const admins = await prisma.user.findMany({
where: { role: 'ADMIN' },
include: { posts: true },
orderBy: { createdAt: 'desc' },
})
// Update
const updated = await prisma.user.update({
where: { id: user.id },
data: { name: 'Alice Smith' },
})
// Upsert — update or create
const upserted = await prisma.user.upsert({
where: { email: '[email protected]' },
update: { name: 'Bob' },
create: { email: '[email protected]', name: 'Bob' },
})
// Delete
await prisma.user.delete({
where: { id: user.id },
})
// Count
const total = await prisma.user.count()
const active = await prisma.user.count({
where: { role: 'USER' },
})
Prisma migrations — create, apply, and roll back
Manage database schema changes with Prisma Migrate — create migrations from schema changes, apply them to production, and handle drift.
How it works: after changing your schema.prisma, run prisma migrate dev to create a new migration file. The CLI generates the SQL, applies it to your local database, and creates a timestamped migration file that you commit to git. Team members and production run prisma migrate deploy to apply pending migrations.
# Create and apply a migration after schema changes
npx prisma migrate dev --name add-user-role
# Apply pending migrations (CI/CD, production)
npx prisma migrate deploy
# Reset database — drops all data and re-applies all migrations
npx prisma migrate reset
# Check migration status
npx prisma migrate status
# Create migration without applying (review SQL first)
npx prisma migrate dev --create-only
# Apply to production
npx prisma migrate deploy
# If migrations get out of sync:
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma
# In production deployment script
npx prisma migrate deploy
npx prisma generate
npx next start
// Example: adding a new field
model User {
id String @id @default(cuid())
email String @unique
name String?
phone String? // ← newly added
role Role @default(USER) // ← newly added
}
Prisma pagination — cursor and offset
Efficiently paginate large datasets with Prisma — offset pagination for simple lists, cursor-based pagination for infinite scroll and real-time feeds.
How it works: offset pagination uses skip and take — simple but slow on large offsets because the database still scans skipped rows. Cursor pagination uses a unique value (id) to start after a specific record — O(1) per page and stable when new items are inserted.
// Offset pagination — simple but slows down on deep pages
const page1 = await prisma.post.findMany({
skip: 0,
take: 20,
orderBy: { createdAt: 'desc' },
})
const page2 = await prisma.post.findMany({
skip: 20,
take: 20,
orderBy: { createdAt: 'desc' },
})
// Cursor pagination — fast and stable
const firstPage = await prisma.post.findMany({
take: 20,
orderBy: { id: 'asc' },
})
const lastId = firstPage[firstPage.length - 1].id
const secondPage = await prisma.post.findMany({
take: 20,
cursor: { id: lastId },
skip: 1, // skip the cursor itself
orderBy: { id: 'asc' },
})
// Cursor with where clause
const filtered = await prisma.post.findMany({
take: 20,
where: { published: true },
cursor: { id: lastId },
skip: 1,
orderBy: { id: 'asc' },
})
// Helper for cursor pagination
async function paginatePosts(cursor?: string, limit = 20) {
return prisma.post.findMany({
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { createdAt: 'desc' },
})
}
Prisma schema — User, Post, and relations
A practical Prisma schema with User, Post, Session, and Subscription models using relations, enums, and indexes.
How it works: the schema defines four related models. User has many Post and Session records; Subscription links a user to a Stripe subscription. Enums keep status values constrained, and @@index speeds up common queries.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
USER
ADMIN
}
enum SubscriptionStatus {
ACTIVE
CANCELED
PAST_DUE
EXPIRED
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(USER)
posts Post[]
sessions Session[]
subscription Subscription?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([published, createdAt])
}
model Session {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
}
model Subscription {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
stripeId String @unique
status SubscriptionStatus
currentPeriodEnd DateTime?
createdAt DateTime @default(now())
@@index([stripeId])
@@index([status])
}
// Query user with their posts and subscription
const user = await prisma.user.findUnique({
where: { email: '[email protected]' },
include: { posts: true, subscription: true }
})
More snippet collections
Contact Information
Submit a request or write to me directly on Telegram. I'll reply within 24 hours.