Node.js boilerplate Typescript, Express, Prisma

Last updated: February 6th 2025

Introduction

In the ever-evolving landscape of Node.js development, building scalable and secure applications demands more than just writing code—it requires a robust foundation. This guide walks you through creating a modern Express.js API with TypeScript, leveraging cutting-edge tools and architectural patterns to optimize performance, enhance security, and streamline maintenance.

I strongly recommend using pnpm for Node.js projects due to its speed, efficient disk space management, and robust dependency resolution. If you prefer another package manager like npm or yarn, the instructions below can be adapted accordingly.

Project Directory Structure

Here’s a detailed organization of a typical project:

your-project/
├── prisma/                  # Prisma schema and migrations
├── src/
│   ├── controllers/         # Request-handling logic (e.g., user auth)
│   │   └── userController.ts
│   ├── routes/              # API route definitions
│   │   └── userRoutes.ts
│   ├── services/            # Reusable business logic (Optional, Prisma takes care of it's job)
│   ├── utils/               # Utilities and configurations
│   │   ├── middleware/      # Custom middleware (e.g., auth checks)
│   │   ├── database.ts      # Database connection setup
│   │   └── logger.ts        # Logging configuration
│   └── public/              # Static assets (CSS, client-side JS/TS, images)
│       ├── css/
│       ├── ts/              # Client-side TypeScript (compiled to JS)
│       └── images/
├── tests/                   # Integration/unit tests
├── app.ts                   # Express app configuration
├── server.ts                # HTTP server initialization
├── package.json             # Project dependencies and scripts
└── .env                     # Environment variables (e.g., database URL)

Setting Up Your Project with pnpm

1. Install pnpm

Globally install pnpm if not already installed:

$ sudo npm install -g pnpm

2. Initialize the Project

Create a package.json:

$ pnpm init

Now on to installing dependencies.

Runtime Dependencies

$ pnpm add prisma bcrypt cookie cookie-parser cors express helmet jsonwebtoken yup logger express-rate-limit morgan
  • prisma: Modern database toolkit and ORM for TypeScript/Node.js. Enables type-safe database access, schema migrations, and declarative data modeling. Simplifies interactions with SQL databases (PostgreSQL, MySQL) and NoSQL databases (MongoDB).

  • bcrypt: Password security library for hashing and salting sensitive user credentials. Protects against brute-force attacks and rainbow table exploits by securely handling password storage.

  • cookie: Low-level utility for parsing and serializing HTTP cookies. Provides foundational cookie handling for server-side operations.

  • cookie-parser: Express middleware that parses cookies attached to incoming HTTP requests, making them accessible via req.cookies. Works with the cookie library internally.

  • cors: Middleware for enabling Cross-Origin Resource Sharing (CORS). Safely manages cross-domain requests between frontend and backend services.

  • express: Minimalist web framework for building REST APIs and server-side applications. Handles routing, middleware pipelines, and HTTP request/response cycles.

  • helmet: Security middleware that sets critical HTTP headers (e.g., X-Content-Type-Options, Content-Security-Policy) to protect against common web vulnerabilities like XSS and clickjacking.

  • jsonwebtoken: Implements JSON Web Tokens (JWT) for stateless authentication. Generates, signs, and verifies tokens to securely transmit user claims between client and server.

  • yup: Schema validation library for runtime data validation. Ensures request payloads, forms, and configuration data adhere to predefined rules before processing.

  • logger (generic): Typically a logging utility (e.g., winston, morgan) for recording server events, errors, and HTTP request details. Supports debugging and monitoring.

  • express-rate-limit: Brute-force/DDoS protection middleware. Enforces request throttling to limit repeated calls from a single IP address within a specified timeframe.

  • morgan: HTTP request logger middleware for node.js

Development Dependencies

$ pnpm add -D @types/cookie-parser @types/cors @types/express @types/node dotenv nodemon prettier ts-node typescript 
  • prisma: ORM for database management.
  • nodemon: Auto-restart server during development.
  • ts-node: Run TypeScript files directly.
  • dotenv: Dotenv is a zero-dependency module that loads environment variables from a .env file into process.env.

3. Configure TypeScript

a. Add TypeScript Compilation Script

Update package.json:

"scripts": {
  "tsc": "tsc",
  // ...other scripts
}

b. Generate tsconfig.json

Create a TypeScript configuration file:

$ pnpm tsc --init

Adjust tsconfig.json as needed (e.g., "outDir": "./dist").

Database Setup with Prisma

VSCode Extension: Install the Prisma VSCode Extension for syntax highlighting and auto-completion.

1. Initialize Prisma

$ pnpx  prisma init

This creates a prisma/schema.prisma file and adds a DATABASE_URL to .env.

2. Configure SQLite

Update .env to use SQLite:

DATABASE_URL="file:./prisma/dev.db"

3. Define the Database Schema

Edit prisma/schema.prisma:

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

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

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
}

4. Generate Migrations and Client

pnpx prisma migrate dev --name init

This creates a migration file and syncs the database schema.

Input Validation with Yup

Yup provides a declarative way to validate request bodies. Create a reusable validator:

1. Create src/utils/validate-yup.ts

$ mkdir -p src/utils && touch src/utils/validate-yup.ts
import * as yup from "yup";

type ValidationResult<T> =
  | { valid: true; data: T }
  | { valid: false; errors: string[] };

export function validateSchema<T>(
  schema: yup.Schema<T>,
  obj: unknown,
): ValidationResult<T> {
  try {
    // Validate synchronously. The option { abortEarly: false } collects all errors.
    const validatedData: T = schema.validateSync(obj, { abortEarly: false });
    return { valid: true, data: validatedData };
  } catch (err) {
    if (err instanceof yup.ValidationError) {
      // Return all error messages collected during validation.
      return { valid: false, errors: err.errors };
    }
    // If it's not a validation error, rethrow it.
    throw err;
  }
}

This validation helper provides a type-safe way to validate data structures using Yup schemas. It wraps Yup’s validation logic in a standardized response format (ValidationResult<T>) that returns either:

  • A success object

    { valid: true; data: T }

    Containing properly typed, validated data

  • An error object

    { valid: false; errors: string[] }

    Containing all validation error messages

2. Define a Validation Schema

Example for user registration:

// src/schemas/register.schema.ts
import * as yup from 'yup';

export const registerSchema = yup.object({
  email: yup.string().email().required(),
  password: yup.string().min(10).required(),
  passwordConfirmation: yup
    .string()
    .oneOf([yup.ref('password')], 'Passwords must match'),
});

3. Use Validation in a Controller

// src/controllers/userController.ts
import { Request, Response } from 'express';
import { registerSchema } from '../schemas/user';
import { validateSchema } from '../utils/validate-yup';

export const registerUser = async (req: Request, res: Response) => {
  const result = validateSchema(registerSchema, req.body);
  
  if (!result.error) {
    return res.status(400).json({ errors: result.errors });
  }

  // Type-safe access to validated data
  const { email, password } = result.data;
  
  // ...save user to database
};

Environment variables

To never push the api key to GetHub ever again, just don’t forget to add .env to .gitignore

Dotenv is a popular Node.js package that enables developers to load environment variables from a .env file directly into the process environment. This simple yet powerful utility simplifies the management of configuration settings, making it easier to maintain distinct configurations for development, testing, and production stages. In a development setting, dotenv offers numerous advantages by securely managing sensitive information such as API keys, database credentials, and other secret data that should not be embedded in source code.

By separating configuration from code, dotenv reduces the risk of accidental exposure of critical information when sharing code across teams or deploying projects publicly. It also allows developers to modify configuration values without altering the codebase, thus streamlining debugging and testing processes.

import "dotenv/config"

and now all the values in .env are avilable as process.env["foo"]

The server

Separation of Concerns Architecture

The core principle behind splitting app.ts and server.ts is architectural modularity. This pattern separates the application logic (Express configuration) from the runtime environment (server instantiation), creating distinct layers of responsibility.

  1. Express Application Configuration (app.ts)
    This file acts as the central hub for:
    • Middleware pipeline setup (CORS, body parsers, security headers)
    • Route definitions and controller bindings
    • Error handling frameworks
    By isolating these concerns, the application becomes:
    • Testable (can instantiate app without starting server)
    • Reusable (could be mounted in different server environments)
    • Maintainable (clear boundaries between setup and execution)
  2. Server Initialization (server.ts)
    This file handles the runtime context:
    • HTTP/HTTPS server creation
    • Port binding and network interface management
    • Environment variable validation
    • Graceful shutdown procedures
    • Process exception handling (uncaught errors, rejections)
    This separation allows:
    • Different deployment strategies (serverless vs. traditional)
    • Multiple protocol support (HTTP/2, WebSocket later)
    • Clean abstraction between app logic and execution environment

Flow of Execution

  1. Bootstrap Sequence
    The server imports the preconfigured Express instance from app.ts, ensuring all middleware and routes are initialized before binding to ports. This prevents race conditions in complex setups.

  2. Dependency Lifecycle
    Database connections, third-party services, and other resources can be initialized either:

    • At app configuration level (if global to all routes)
    • During server startup (if tied to process lifecycle)
// src/app.ts

import express, { NextFunction, Request, Response } from "express";

import cookieParser from "cookie-parser";
import cors from "cors";
// Security middleware
import helmet from "helmet";
import loggerExpress from "logger";
import rateLimit from "express-rate-limit";

const logger = loggerExpress.createLogger();
const app = express();

// Security headers
app.use(helmet());

// Rate limiting
app.use(
  rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100, // limit each IP to 100 requests per window
  }),
);

// CORS configuration
app.use(
  cors({
    origin: process.env.CLIENT_ORIGIN || "http://localhost:3000",
    methods: ["GET", "POST", "PUT", "DELETE"],
  }),
);

// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());

// Static assets
app.use(express.static("public"));
// API Routes

// Health check endpoint
app.get('/health', (req: Request, res: Response) => {
  res.status(200).json({
    status: 'ok',
    message: 'Server is running',
    environment: process.env.NODE_ENV,
    timestamp: new Date().toISOString()
  });
});

// 404
// Routes
app.get("/", (req, res) => {
  res.send("Secure Express Server");
});

app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  logger.error(err.stack);
  res.status(500).json({ error: "Internal Server Error" });
});

export default app
// src/server.ts
import app from "./app";
import { config } from "dotenv";
import http from "http";

config();

const PORT: number = parseInt(process.env.PORT || "3000", 10);
const NODE_ENV: string = process.env.NODE_ENV || "development";

const server = new http.Server(app);

// Handle uncaught exceptions and unhandled rejections
process.on("uncaughtException", (error: Error) => {
  console.error("Uncaught Exception:", error);
  process.exit(1);
});

process.on(
  "unhandledRejection",
  (reason: unknown, promise: Promise<unknown>) => {
    console.error("Unhandled Rejection at:", promise, "reason:", reason);
    process.exit(1);
  },
);

// Graceful shutdown
const shutdown = () => {
  console.log("Shutting down server...");
  server.close(() => {
    console.log("Server closed");
    process.exit(0);
  });
};

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

// Start server
server.listen(PORT, () => {
  console.log(`
    Server running in ${NODE_ENV} mode
    Listening on port ${PORT}
    Ready to handle requests
  `);
});

Building the project

Add the command build, and dev, and start scripts to package.json now it look like this

  "scripts": {
    "dev": "nodemon src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "migrate:dev": "pnpx prisma migrate dev",
    "format": "prettier --write \"src/**/*.{ts,js,json}\" --ignore-unknown",
    "tsc": "tsc"
  },
webdock$ pnpm build

> nodejs-express-app-with-auth@1.0.0 build /webdock/nodejs-express-app
> tsc --project tsconfig.json

webdock$ pnpm start

> nodejs-express-app-with-auth@1.0.0 start /webdock/nodejs-express-app
> node ./dist/server.js
  Server running in development mode
  Listening on port 5000
  Ready to handle requests
GET / 304 15.217 ms - -

Code Formatting with Prettier

1. Installation
Prettier is the standard code formatter for JavaScript/TypeScript. Install it as a development dependency:

$ pnpm add prettier --D

2. Configuration
Create a minimal configuration file to explicitly use Prettier’s defaults:

$ touch .prettierrc
// .prettierrc
{
  // Empty config uses Prettier's default rules
  // Add custom rules here if needed (https://prettier.io/docs/en/options.html)
}

Note: You could omit the config file entirely to use defaults, but creating one makes it easier to add custom rules later.

4. Run Formatting
Execute the formatter:

$ pnpm format

Example Output:

 pnpm format

> nodejs-express-app@1.0.0 format /project
> prettier --write "src/**/*.{ts,js,json}" --ignore-unknown

src/schema/register.schema.ts 107ms
src/server.ts 27ms
src/utils/validate.yup.ts 31ms

5. Recommended Extensions
- Install the Prettier VS Code extension - Enable format-on-save in VS Code settings: json { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" }

Conclusion: Structure as Your Foundation

While project organization ultimately comes down to personal preference, following established patterns creates a maintainable foundation for growth. The structure outlined here—with its clear separation of routes, controllers, utilities, and database logic—prioritizes modularity, testability, and collaboration. By adopting conventions like layered architecture and environment-aware configuration, you ensure your codebase scales gracefully as requirements evolve.

Tools like Prisma, Yup, and Express middleware streamline development, but their effectiveness depends on how they’re integrated into your project’s skeleton. A well-organized directory structure acts as living documentation, making it easier for teams to onboard, debug, and extend functionality without reinventing patterns.

Whether you follow this blueprint exactly or adapt it to your needs, consistency is key. Structuring projects thoughtfully from the start reduces cognitive overhead, minimizes tech debt, and prepares your application for future challenges. While creativity in coding is encouraged, a disciplined foundation ensures that creativity remains productive rather than chaotic. In the end, a deliberate structure isn’t about restriction—it’s about building a scaffold that lets your ideas thrive sustainably.

This article was written by Ahmad AdelAhmad is a freelance writer and also a backend developer.

chat box icon
Close
combined chatbox icon

Welcome to our Chatbox

Reach out to our Support Team or chat with our AI Assistant for quick and accurate answers.
webdockThe Webdock AI Assistant is good for...
webdockChatting with Support is good for...