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.
- 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
- Testable (can instantiate app without starting server)
- Reusable (could be mounted in different server environments)
- Maintainable (clear boundaries between setup and execution)
- 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)
- Different deployment strategies (serverless vs. traditional)
- Multiple protocol support (HTTP/2, WebSocket later)
- Clean abstraction between app logic and execution environment
Flow of Execution
-
Bootstrap Sequence
The server imports the preconfigured Express instance fromapp.ts
, ensuring all middleware and routes are initialized before binding to ports. This prevents race conditions in complex setups. -
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 Adel. Ahmad is a freelance writer and also a backend developer.
Related articles
-
Node.js, Bun.js, and Deno: How JavaScript Runtimes Have Changed
An short article on different javascript runtimes: Node, Bun, and Deno
Last updated: February 5th 2025
-
JavaScript’s event loop vs. PHP’s multi-process model
An article comparing JS's event-loop and PHP's multi-process model
Last updated: February 5th 2025
-
Process: A Tale of Code in Motion
Detailed description of the stages of a process
Last updated: February 6th 2025
-
Nodejs: Implementing a Basic Authentication Mechanism
An article on setting up basic authentication with NodeJS
Last updated: February 7th 2025
-
Turning Node.js Multi-Process: Scaling Applications with the Cluster Module
On scaling NodeJS processes with the cluster module...
Last updated: February 9th 2025
-
Building a Scalable Facebook-style Messaging Backend with NodeJS
Steps to build a facebook-style messaging backend with NodeJS
Last updated: February 10th 2025
-
What is PM2 and Why Your Node App Needs it
An article on PM2 - a Node process manager.
Last updated: February 20th 2025