Skip to main content

Backend Starter

Home

/

posts

/

Backend Starter

Backend Starter

This is a clean, production-ready backend starter using Express, TypeScript, and PostgreSQL. It covers the essentials: project setup, environment validation, database with Prisma, modern JWT authentication with session rotation, and secure defaults.

Tech stack

  • Runtime/package manager: Bun
  • Server: Express 5 + TypeScript
  • Database: PostgreSQL 16 (Docker)
  • ORM + migrations: Prisma
  • Validation: Zod
  • Auth: jose JWT + refresh-session rotation in DB
  • Security: Helmet, CORS allowlist, rate limiting, HTTP-only cookies
  • Logging: Pino
  • Testing: Vitest + Supertest

1) Bootstrap project

mkdir express-ts-pg-backend && cd express-ts-pg-backend
bun init -y

bun add express zod cors helmet cookie-parser express-rate-limit pino pino-http argon2 jose dotenv @prisma/client
bun add -d typescript tsx prisma @types/node @types/express @types/cookie-parser vitest supertest @types/supertest

bunx tsc --init --rootDir src --outDir dist --module nodenext --moduleResolution nodenext --target ES2022

package.json scripts:

{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc -p tsconfig.json",
    "start": "node dist/server.js",
    "test": "vitest run",
    "prisma:migrate": "prisma migrate dev",
    "prisma:generate": "prisma generate"
  }
}

Suggested structure:

src/
  app.ts
  server.ts
  config/
    env.ts
  lib/
    prisma.ts
    logger.ts
    tokens.ts
  features/
    auth/
      routes.ts
      schema.ts
  middlewares/
    error-handler.ts
    not-found.ts

2) Environment validation (non-negotiable)

Create .env.example:

NODE_ENV=development
PORT=4000
CORS_ORIGIN=http://localhost:3000

DATABASE_URL=postgresql://app_user:app_password@localhost:5432/app_db?schema=public

JWT_ACCESS_SECRET=replace-with-64-char-secret
JWT_REFRESH_SECRET=replace-with-64-char-secret
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL=30d
REFRESH_COOKIE_NAME=rt

src/config/env.ts:

import 'dotenv/config';
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().min(1).max(65535).default(4000),
  CORS_ORIGIN: z.string().min(1),
  DATABASE_URL: z.string().url(),
  JWT_ACCESS_SECRET: z.string().min(32),
  JWT_REFRESH_SECRET: z.string().min(32),
  ACCESS_TOKEN_TTL: z.string().default('15m'),
  REFRESH_TOKEN_TTL: z.string().default('30d'),
  REFRESH_COOKIE_NAME: z.string().default('rt'),
});

export const env = envSchema.parse(process.env);

If env is invalid, the app fails fast on boot.

3) PostgreSQL with Docker Compose

docker-compose.yml:

services:
  db:
    image: postgres:16-alpine
    container_name: express-ts-pg-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: app_db
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: app_password
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U app_user -d app_db']
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  postgres_data:

Run DB:

docker compose up -d

4) Prisma schema + migrations

Initialize Prisma:

bunx prisma init --datasource-provider postgresql

prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum UserRole {
  USER
  ADMIN
}

model User {
  id           String    @id @default(uuid())
  fullName     String
  email        String    @unique
  passwordHash String
  role         UserRole  @default(USER)
  sessions     Session[]
  createdAt    DateTime  @default(now())
  updatedAt    DateTime  @updatedAt
}

model Session {
  id        String    @id @default(cuid())
  userId    String
  jti       String    @unique
  userAgent String?
  ip        String?
  expiresAt DateTime
  revokedAt DateTime?
  createdAt DateTime  @default(now())
  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId, revokedAt])
  @@index([expiresAt])
}

Apply migration:

bunx prisma migrate dev --name init_auth

src/lib/prisma.ts:

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

export const prisma = new PrismaClient();

5) App foundation (Express 5)

src/lib/logger.ts:

import pino from 'pino';

export const logger = pino({
  level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
});

src/app.ts:

import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit';
import pinoHttp from 'pino-http';

import { env } from './config/env';
import { logger } from './lib/logger';
import { authRouter } from './features/auth/routes';
import { notFoundHandler } from './middlewares/not-found';
import { errorHandler } from './middlewares/error-handler';

const app = express();
const originList = env.CORS_ORIGIN.split(',').map((s) => s.trim());

app.use(
  pinoHttp({
    logger,
    redact: ['req.headers.authorization', 'req.headers.cookie'],
  }),
);
app.use(helmet());
app.use(
  cors({
    origin: (origin, cb) => {
      if (!origin || originList.includes(origin)) return cb(null, true);
      return cb(new Error('CORS blocked'));
    },
    credentials: true,
  }),
);
app.use(express.json({ limit: '1mb' }));
app.use(cookieParser());
app.use(
  rateLimit({
    windowMs: 60_000,
    max: 120,
    standardHeaders: true,
    legacyHeaders: false,
  }),
);

app.get('/health', (_req, res) => {
  res.status(200).json({ ok: true });
});

app.use('/v1/auth', authRouter);

app.use(notFoundHandler);
app.use(errorHandler);

export { app };

src/server.ts:

import { app } from './app';
import { env } from './config/env';
import { logger } from './lib/logger';

app.listen(env.PORT, () => {
  logger.info(`API listening on http://localhost:${env.PORT}`);
});

src/middlewares/error-handler.ts:

import type { NextFunction, Request, Response } from 'express';

export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
  const message = err instanceof Error ? err.message : 'Internal server error';
  res.status(500).json({ message });
}

src/middlewares/not-found.ts:

import type { Request, Response } from 'express';

export function notFoundHandler(_req: Request, res: Response) {
  res.status(404).json({ message: 'Route not found' });
}

6) Modern JWT helpers with jose

src/lib/tokens.ts:

import { JWTPayload, SignJWT, jwtVerify } from 'jose';
import { env } from '../config/env';

const accessSecret = new TextEncoder().encode(env.JWT_ACCESS_SECRET);
const refreshSecret = new TextEncoder().encode(env.JWT_REFRESH_SECRET);

export async function signAccessToken(payload: JWTPayload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime(env.ACCESS_TOKEN_TTL)
    .sign(accessSecret);
}

export async function signRefreshToken(payload: JWTPayload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime(env.REFRESH_TOKEN_TTL)
    .sign(refreshSecret);
}

export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, accessSecret);
  return payload;
}

export async function verifyRefreshToken(token: string) {
  const { payload } = await jwtVerify(token, refreshSecret);
  return payload;
}

7) Auth routes (signup, login, refresh, logout, me)

src/features/auth/schema.ts:

import { z } from 'zod';

export const signUpSchema = z.object({
  fullName: z.string().min(3).max(80),
  email: z.string().email(),
  password: z
    .string()
    .min(10)
    .regex(/[A-Z]/, 'Must contain an uppercase letter')
    .regex(/[a-z]/, 'Must contain a lowercase letter')
    .regex(/[0-9]/, 'Must contain a number')
    .regex(/[^A-Za-z0-9]/, 'Must contain a symbol'),
});

export const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
});

src/features/auth/routes.ts:

import { Router, type Request, type Response } from 'express';
import argon2 from 'argon2';
import { randomUUID } from 'node:crypto';

import { prisma } from '../../lib/prisma';
import { loginSchema, signUpSchema } from './schema';
import { env } from '../../config/env';
import {
  signAccessToken,
  signRefreshToken,
  verifyAccessToken,
  verifyRefreshToken,
} from '../../lib/tokens';

const authRouter = Router();

function setRefreshCookie(res: Response, token: string) {
  res.cookie(env.REFRESH_COOKIE_NAME, token, {
    httpOnly: true,
    secure: env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/v1/auth',
    maxAge: 30 * 24 * 60 * 60 * 1000,
  });
}

function clearRefreshCookie(res: Response) {
  res.clearCookie(env.REFRESH_COOKIE_NAME, {
    httpOnly: true,
    secure: env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/v1/auth',
  });
}

authRouter.post('/signup', async (req, res) => {
  const parsed = signUpSchema.safeParse(req.body);
  if (!parsed.success) return res.status(400).json(parsed.error.flatten());

  const { fullName, email, password } = parsed.data;
  const exists = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
  if (exists) return res.status(409).json({ message: 'Email already in use' });

  const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
  const user = await prisma.user.create({
    data: { fullName, email: email.toLowerCase(), passwordHash },
    select: { id: true, email: true, fullName: true, role: true },
  });

  const jti = randomUUID();
  const refreshToken = await signRefreshToken({ sub: user.id, jti });
  const accessToken = await signAccessToken({ sub: user.id, role: user.role });

  await prisma.session.create({
    data: {
      userId: user.id,
      jti,
      userAgent: req.headers['user-agent'] ?? null,
      ip: req.ip,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
    },
  });

  setRefreshCookie(res, refreshToken);
  return res.status(201).json({ accessToken, user });
});

authRouter.post('/login', async (req, res) => {
  const parsed = loginSchema.safeParse(req.body);
  if (!parsed.success) return res.status(400).json(parsed.error.flatten());

  const { email, password } = parsed.data;
  const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
  if (!user) return res.status(401).json({ message: 'Invalid credentials' });

  const ok = await argon2.verify(user.passwordHash, password);
  if (!ok) return res.status(401).json({ message: 'Invalid credentials' });

  const jti = randomUUID();
  const refreshToken = await signRefreshToken({ sub: user.id, jti });
  const accessToken = await signAccessToken({ sub: user.id, role: user.role });

  await prisma.session.create({
    data: {
      userId: user.id,
      jti,
      userAgent: req.headers['user-agent'] ?? null,
      ip: req.ip,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
    },
  });

  setRefreshCookie(res, refreshToken);
  return res.status(200).json({
    accessToken,
    user: { id: user.id, email: user.email, fullName: user.fullName, role: user.role },
  });
});

authRouter.post('/refresh', async (req, res) => {
  const token = req.cookies[env.REFRESH_COOKIE_NAME];
  if (!token) return res.status(401).json({ message: 'Missing refresh token' });

  const payload = await verifyRefreshToken(token);
  const jti = String(payload.jti ?? '');
  const userId = String(payload.sub ?? '');
  if (!jti || !userId) return res.status(401).json({ message: 'Invalid refresh token' });

  const session = await prisma.session.findFirst({
    where: {
      jti,
      userId,
      revokedAt: null,
      expiresAt: { gt: new Date() },
    },
  });

  if (!session) return res.status(401).json({ message: 'Session expired or revoked' });

  const nextJti = randomUUID();
  const [user] = await prisma.$transaction([
    prisma.user.findUniqueOrThrow({ where: { id: userId } }),
    prisma.session.update({ where: { jti }, data: { revokedAt: new Date() } }),
    prisma.session.create({
      data: {
        userId,
        jti: nextJti,
        userAgent: req.headers['user-agent'] ?? null,
        ip: req.ip,
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      },
    }),
  ]);

  const nextRefresh = await signRefreshToken({ sub: user.id, jti: nextJti });
  const nextAccess = await signAccessToken({ sub: user.id, role: user.role });

  setRefreshCookie(res, nextRefresh);
  return res.status(200).json({ accessToken: nextAccess });
});

authRouter.post('/logout', async (req, res) => {
  const token = req.cookies[env.REFRESH_COOKIE_NAME];

  if (token) {
    try {
      const payload = await verifyRefreshToken(token);
      const jti = String(payload.jti ?? '');
      if (jti) await prisma.session.updateMany({ where: { jti }, data: { revokedAt: new Date() } });
    } catch {
      // Invalid token: still clear cookie and return 204.
    }
  }

  clearRefreshCookie(res);
  return res.status(204).send();
});

authRouter.get('/me', async (req: Request, res: Response) => {
  const auth = req.headers.authorization;
  if (!auth?.startsWith('Bearer ')) return res.status(401).json({ message: 'Unauthorized' });

  const token = auth.slice('Bearer '.length);
  const payload = await verifyAccessToken(token);
  const userId = String(payload.sub ?? '');

  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: { id: true, email: true, fullName: true, role: true, createdAt: true },
  });

  if (!user) return res.status(404).json({ message: 'User not found' });
  return res.status(200).json({ user });
});

export { authRouter };

For very high-security apps, use opaque refresh tokens (random bytes + DB hash) instead of JWT refresh tokens.

8) Minimal API test

src/features/auth/auth.test.ts:

import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { app } from '../../app';

describe('health', () => {
  it('returns OK', async () => {
    const res = await request(app).get('/health');
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ ok: true });
  });
});

Run tests:

bun run test

9) Production hardening checklist

  • Use HTTPS everywhere.
  • Set secure: true cookies in production.
  • Rotate JWT secrets regularly.
  • Add per-route auth rate limits (/login, /refresh).
  • Add request ID + structured logs + central log shipping.
  • Add DB backups and migration strategy.
  • Add OpenAPI docs and contract tests.
  • Add CI: lint, typecheck, test, migration check.

10) Quick run

# 1) start db
docker compose up -d

# 2) run migrations
bunx prisma migrate dev --name init_auth

# 3) start api
bun run dev

Comments (0)

Enter your name, 2-50 charactersEnter your comment, up to 1000 characters

Loading comments...