Express.js Starter API with TypeScript
This article will teach you how to build a well-organized project using Express.js, TypeScript, ESlint, set up a pair of databases (SQL & NoSQL), make use of environment variables, and more.
Why Express?
Express.js has been around for over a decade. It’s easy to learn and minimalistic, but after years of working with frameworks like .NET Web API & Nest.js, going back to Express feels like living in the stone age.
Almost every repository you come across follows a unique architecture, with different coding patterns and a list of libraries, making it a nightmarish learning experience for newcomers.
It’s hard to find a good project online that fits your needs and at the same time:
- Uses strict typing
- Has database set up (SQL / NoSQL)
- Secure
- Scalable
- Well documented
- Ready for deployment
This project aims to achieve all that. From starter tools to a production-ready product, we’ll learn how to build a unified solution that can be easily extensible with new features. With each new chapter, we’ll improve upon the previous codebase.
The first part is already up and running on GitHub from where you can clone it and make use of it in your apps.
For everyone else who want to learn how we got to this point, stick around until the end. We’re going to walk through the process so that you know what am I doing and why.
New articles in the series
Project Setup
First, you need to have Node.js installed on your machine. For this demo, I’ll be using these versions:
Nodejs: v16.20.0
NPM: 8.19.4
// you can check this by running node -v & npm -v
Now, create a folder on your machine, call it Express-API or whatever, and open it in the terminal. Type:
npm init -y
to set up an NPM projectnpm i -g typescript eslint nodemon
only if you do not have these alreadytsc --init
to set up TypeScript configurationeslint --init
to set up linter. Works only for Node.js version 16 and above.
Upon installing it will ask you a couple of questions. If you do not know what to answer just copy my config .eslint.json
file on GitHub.
Now we’re good to install Express.js, an API framework for Node.js. I’m using npm, but you can use yarn too.
> npm i express
To enable TypeScript with Express we need to add strict typing to Node and Express:
> npm i -D @types/express @types/node ts-node typescript eslint nodemon
Notice that we’re installing a @types/express
package. This is needed for TypeScript as some packages (like Express) do not support TypeScript natively. This is also a dev dependency which is why I use the -D
flag (sometimes I also use the --save-dev
flag).
The latter two packages, ESlint, will help us write better code by setting rules, while Nodemon is used to auto-reload the server on save.
I added some custom TypeScript and ESLint rules in the config files that you will find on GitHub.
Add Autoreload
This is completely optional, but I like to add it up-front. The package Nodemon is used to automatically reload the server on save. However, it does not natively support TypeScript. To combat that we’ll create a nodemon.json
file in the root directory and paste the following config:
{
"watch": ["src"], // watch src directory
"ext": "ts", // all files ending with .ts (TypeScript)
"ignore": ["src/**/*.spec.ts"], // ignore test files (spec.ts)
"exec": "ts-node ./src/app.ts" // execute ts-node
}
Now let’s add a dev script in the package.json file that will run this Nodemon script.
...
"scripts": {
"dev": "nodemon"
},
To run this script simply type npm run dev
. This won’t get as far at the moment as we have not written the code yet.
Now that we’re all set let’s go over the folder structure.
Project Structure
The basic Express project structure usually looks like this (src/app.ts):
import express, { Request, Response } from 'express';
const app = express();
// database connections
// routes
app.get('/', (req: Request, res: Response) => {
res.send('Hello World!');
});
// some more stuff
const APP_PORT = 3000;
app.listen(APP_PORT, () => {
console.log(`Server started on port ${APP_PORT}`);
});
// end
I hate this setup as the main file (app.ts) is occupied with doing too many things at once. I designed this project in the mind to be organized and maintainable for further development. This is a raw overview of the folder structure we’ll follow:
└───src
├───databases
│ ├───mongodb
│ │ ├───model
│ │ └───schema
│ └───postgresql
│ ├───entity
│ └───model
├───controllers
│ ├───mongoose
│ └───typeorm
└───startup
We’ll be using multiple databases (for fans of SQL & MongoDB) with separate models and controllers for each. We’ll also use TypeScript as much as possible which will be more apparent in the later chapters.
Startup
Now let’s create an src directory and the app.ts file inside. We can keep it empty for now. Inside an src directory, we’ll create a new folder ‘startup’.
Inside the startup directory, we’ll create multiple files, each containing a set of functionalities related to a single feature. This allows us to improve each feature individually without compromising on code readability.
The app.ts file will import each file when bootstrapping the application.
# security.ts
Start by creating security.ts
in the startup folder and paste the following:
import { Express } from 'express';
const securitySetup = (app: Express) =>
app
.use(cors())
export default securitySetup;
TypeScript will rightfully complain about the Cors package. Because we haven’t installed it yet. Let’s do it right away.
> npm i cors
> npm i --save-dev @types/cors
And then import all required dependencies.
import cors from 'cors';
import { Express } from 'express';
const securitySetup = (app: Express) =>
app
.use(cors())
export default securitySetup;
The security.ts file will be used to store any security-related things, such as CORS, secure headers, rate-limiters, etc.
Cors will enable CORS which will allow clients (web apps) to send requests to this server. We’ll also add express.json()
middleware to allow Express to parse the HTTP request body as JSON.
import cors from 'cors';
import { Express } from 'express';
const securitySetup = (app: Express, express: any) =>
app
.use(cors())
.use(express.json())
export default securitySetup;
This wraps up the security file.
# router.ts
Now let’s set up a router.ts in the same directory using the same structure.
import { Express, Request, Response } from 'express';
const routerSetup = (app: Express) =>
app
.get('/', async (req: Request, res: Response) => {
res.send('Hello Express APIvantage!');
});
export default routerSetup;
Consider this file like a base controller file. This is where all routes will be set, as well as links to other controllers.
# init.ts
Finally, let’s add the init.ts file that we’ll use to start the server.
import { Express } from 'express';
const appSetup = (app: Express) => {
// set database connections
const APP_PORT = 3000;
app.listen(APP_PORT, () => {
console.log(`Server started on port ${APP_PORT}`);
});
};
export default appSetup;
Here we’ll later start each database before starting up the server.
Now let’s go back to the app.ts file in the src directory and wire up these files.
import express from 'express';
const app = express();
import appSetup from './startup/init';
import routerSetup from './startup/router';
import securitySetup from './startup/security';
appSetup(app);
securitySetup(app, express);
routerSetup(app);
Looks clean. We can test our progress so far by running the dev script we created earlier.
> npm run dev
> express-apivantage@1.0.0 dev
> nodemon
[nodemon] 2.0.22
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): src\**\*
[nodemon] watching extensions: ts
[nodemon] starting `ts-node ./src/app.ts`
Server started on port 3000
Now head over to http://localhost:3000
to test that it works.
Databases
Now let’s shift our attention to data persistence. For this demonstration, we’ll be using TypeORM combined with PostgreSQL and Mongoose with MongoDB.
Environment variables
Before going too far we’ll set up the .env
file in the root directory where we’ll keep environment variables (such as app port, database URIs, etc.).
I won’t go into detail on how to set up each database as that would take too much time. Instead, I’ll give you three choices to set up the databases:
- Install and configure each database locally
- Host free instances online (MongoDB, ElephantSQL)
- Download Docker images
I used option number two.
If you choose this option as well, I created a database-setup directory in the root directory where you can see a detailed description of how to do it yourself.
Once you obtain the connection URIs (one for each database), paste them into the .env
file.
APP_PORT=3000
MONGODB_URI=mongodb+srv://...
PGSQL_URI=postgres://...
Now we need to tell Node.js to read environment variables from this file. To do that we’ll install a dotenv package and import it into the app.ts file.
> npm i dotenv
// app.ts
import dotenv from 'dotenv';
dotenv.config();
The environment variables we just set will be present in the global process.env
Node.js object. Upon starting the app, process.env
will load all environment variables from the machine, including those in .env
file.
Setting up PostgreSQL with TypeORM
Node.js supports a large variety of SQL databases and a number of ORMS to handle them. For this project, I chose PostgreSQL and TypeORM because of fine TypeScript support.
> npm i typeorm pg // pg is short for postgreSQL
Folder structure
Both databases will follow a similar folder structure. Each database will have its directory and a set of related functionalities within.
postgresql
├───entity
| product.entity.ts
└───model
| product.model.ts
└───typeorm.ts
PostgreSQL Connection
This is where we set up database connection as well as links to entities we’ll use and other options as well.
import { DataSource, EntityTarget, ObjectLiteral, Repository } from 'typeorm';
let typeORMDB: DataSource;
export default async function typeORMConnect(): Promise<void> {
const dataSource = new DataSource({
type: 'postgres',
url: process.env.PGSQL_URI,
entities: [`${__dirname}/entity/*.entity.ts`], // points to entities
synchronize: true,
});
typeORMDB = await dataSource.initialize();
}
The synchronize: true
flag will automatically create and update database tables based on the entities. This is not recommended to do in production. Usually, we do these things using database migrations.
The next part is to initialize TypeORM. Since we cannot inject dependencies as we’d do in Nest.js, we’re going to expose useTypeORM
function to interact with the entities throughout the project.
// Executes TypeORM query for the provided entity model
export function useTypeORM(
entity: EntityTarget<ObjectLiteral>
): Repository<ObjectLiteral> {
if (!typeORMDB) {
throw new Error('TypeORM has not been initialized!');
}
return typeORMDB.getRepository(entity);
}
Create contract
Another thing we can do is create an interface IProduct for our entity. This can come in handy later when we cast properties from a JSON request body object into an interface.
export default interface IProduct {
id: number;
name: string;
description?: string;
datePublished: string;
price: number;
image?: string
}
Create entity
Each entity represents a SQL table. This is where we define columns, give them types, default values, etc.
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import IProduct from '../model/product.model';
@Entity()
export class ProductEntity implements IProduct {
@PrimaryGeneratedColumn()
id!: number;
@Column()
name!: string;
@Column({ default: 0 })
price!: number;
@Column({ nullable: true })
description!: string;
@Column({ default: new Date().toDateString() })
datePublished!: string;
@Column({ nullable: true })
image!: string;
}
Alright, that’s about it for TypeORM and SQL. Now let’s set up MongoDB and then we’ll write both databases with the app.
Setting up MongoDB with Mongoose
We start by installing Mongoose (ODM for MongoDB)
npm i mongoose
and its TypeScript counterpart:
npm i -D @types/mongoose
Folder Structure
Inside the databases directory, we’ll create a ‘mongodb’ directory. We’ll follow this structure:
mongodb
│───model
| user.model.ts
│───schema
| user.schema.ts
└───mongodb.ts
MongoDB Connection
Now let’s set up the database using Mongoose in the mongodb.ts file.
import { connect } from 'mongoose';
export default async function mongooseConnect(): Promise<void> {
const mongoDBURI = process.env.MONGODB_URI ?? 'mongodb://localhost:27017';
await connect(mongoDBURI);
}
Create contract
We’ll also create an interface for interactions with the Mongoose.
import { Document } from 'mongoose';
export interface IUser extends Document {
username: string;
email: string;
password: string;
}
Create schema
The Schema is like a table in the SQL world. This is where we define what fields our document will have. We’ll also make use of the previously created IUser interface so that schema follows the contract.
import { Schema, model } from 'mongoose';
import { IUser } from '../model/user.model';
const schema = new Schema<IUser>(
{
username: {
type: String,
required: true,
unique: true,
},
email: {
type: String,
required: true,
unique: true,
},
password: {
type: String,
required: true,
select: false,
},
},
{
timestamps: true,
}
);
export default model<IUser>('user', schema);
Each document will have a unique Id (Object Id) in the form of GUID that is automatically generated by MongoDB.
Connecting both databases to the App
Back into our init.ts file, we need to invoke both database connection functions and pass a connection URI to each via environment variables.
One thing to note is that we are going to push app startup (app.isten()
) after awaiting for both databases (using Promise.all()
) to be connected successfully. There is no point in launching the app if either database failed to connect.
import { Express } from 'express';
import mongooseConnect from '../databases/mongodb/mongodb';
import typeORMConnect from '../databases/postgresql/typeorm';
const appSetup = async (app: Express) => {
try {
await Promise.all([
typeORMConnect(),
mongooseConnect(),
]);
console.log('Databases connected successfully!');
const APP_PORT = Number(process.env.APP_PORT) || 3000;
app.listen(APP_PORT, () => {
console.log(`Server started on port ${APP_PORT}`);
});
} catch (error: unknown) {
console.log('Unable to start the app!');
console.error(error);
}
};
export default appSetup;
Now we also need to modify the app.ts to set up environment variables with dotenv package.
import express from 'express';
const app = express();
import dotenv from 'dotenv';
import appSetup from './startup/init';
import routerSetup from './startup/router';
import securitySetup from './startup/security';
dotenv.config();
void appSetup(app); // I put void because of ESLint
securitySetup(app, express);
routerSetup(app);
Just for a sanity check, let’s run the dev script to check if any errors will be thrown.
> npm run dev
> express-apivantage@1.0.0 dev
> nodemon
[nodemon] 2.0.22
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): src\**\*
[nodemon] watching extensions: ts
[nodemon] starting `ts-node ./src/app.ts`
Databases connected successfully!
Server started on port 3000
Awesome. Now let’s set up controllers.
Controllers
Here we’ll create a controller file for each of our databases. We’ll create a CRUD app supporting all major HTTP requests.
TypeORM controller
This is where we’re going to make use of TypeORM entities to manage the database.
File path: src/controllers/typeorm/product.controller.ts
import { Router, Request, Response } from 'express';
import { useTypeORM } from '../../databases/postgresql/typeorm';
import { ProductEntity } from '../../databases/postgresql/entity/product.entity';
const controller = Router();
controller
.post('/', async (req: Request, res: Response) => {
const product = new ProductEntity();
product.name = req.body.name;
product.image = req.body.image;
product.price = req.body.price;
product.description = req.body.description;
const newProduct = await useTypeORM(ProductEntity).save(product);
res.status(201).send(newProduct);
})
.get('/', async (req: Request, res: Response) => {
const products = await useTypeORM(ProductEntity).find();
res.send(products);
})
.get('/:id', async (req: Request, res: Response) => {
const { id } = req.params;
if (!id) {
return res.status(400).send({ message: 'Required parameter "id" is missing!' });
}
const existingProduct = await useTypeORM(ProductEntity).findOneBy({ id });
if (!existingProduct) {
return res.status(404).send({ message: `Product with id: ${id} was not found.` });
}
res.send(existingProduct);
})
.patch('/:id', async (req: Request, res: Response) => {
const { id } = req.params;
if (!id) {
return res.status(400).send({ message: 'Required parameter "id" is missing!' });
}
const existingProduct = await useTypeORM(ProductEntity).findOneBy({ id });
if (!existingProduct) {
return res.status(404).send({ message: `Product with id: ${id} was not found.` });
}
const changes: Partial<ProductEntity> = req.body;
const productChanges = { ...existingProduct, ...changes };
const updatedProduct = await useTypeORM(ProductEntity).save(productChanges);
res.send(updatedProduct);
})
.delete('/:id', async (req: Request, res: Response) => {
const { id } = req.params;
if (!id) {
return res.status(400).send({ message: 'Required parameter "id" is missing!' });
}
const existingProduct = await useTypeORM(ProductEntity).findOneBy({ id });
if (!existingProduct) {
return res.status(404).send({ message: `Product with id: ${id} was not found.` });
}
await useTypeORM(ProductEntity).remove(existingProduct);
res.send({ message: 'Product removed!' });
})
export default controller;
Mongoose controller
And here, we’re going to make use of Mongoose schemas to manage the database.
File path: src/controllers/mongoose/user.controller.ts
import { Router, Request, Response } from 'express';
import { IUser } from '../../databases/mongodb/model/user.model';
import UserModel from '../../databases/mongodb/schema/user.schema';
const controller = Router();
controller
.post('/', async (req, res) => {
const newUser = new UserModel();
newUser.username = req.body.username;
newUser.email = req.body.email;
newUser.password = req.body.password;
await newUser.save();
res.status(201).send(newUser);
})
.get('/', async (req: Request, res: Response) => {
const users = await UserModel.find({});
res.send(users);
})
.get('/:id', async (req: Request, res: Response) => {
const { id } = req.params;
if (!id) {
return res.status(400).send({ message: 'Required parameter "id" is missing!' });
}
const existingUser = await UserModel.findById(id);
if (!existingUser) {
return res.status(404).send({ message: `User with id: ${id} was not found.` });
}
res.send(existingUser);
})
.patch('/:id', async (req, res) => {
const { id } = req.params;
if (!id) {
return res.status(400).send({ message: 'Required parameter "id" is missing!' });
}
const existingUser = await UserModel.findById(id);
if (!existingUser) {
return res
.status(404)
.send({ message: `User with id: ${id} was not found.` });
}
const changes: Partial<IUser> = req.body;
const updatedUser = await UserModel.findOneAndUpdate(
{ _id: id },
{ $set: { ...changes } },
{ new: true }
);
res.send(updatedUser);
})
.delete('/:id', async (req, res) => {
const { id } = req.params;
if (!id) {
return res.status(400).send({ message: 'Required parameter "id" is missing!' });
}
const existingUser = await UserModel.findById(id);
if (!existingUser) {
return res.status(404).send({ message: `User with id: ${id} was not found.` });
}
await UserModel.findOneAndRemove({ _id: id });
res.send({ message: 'User removed!' });
});
export default controller;
Wiring up controllers with the App
We’ll place both controllers into a router.ts file. Each controller will be activated on the predefined route, prefixed with ‘/api’.
import { Express, Request, Response } from 'express';
import mongooseUsersRouter from '../controllers/mongoose/user.controller';
import typeormProductsRouter from '../controllers/typeorm/product.controller';
const routerSetup = (app: Express) =>
app
.get('/', async (req: Request, res: Response) => {
res.send('Hello Express APIvantage!');
})
.use('/api/mongoose/users', mongooseUsersRouter)
.use('/api/typeorm/products', typeormProductsRouter);
export default routerSetup;
Now we can interact with our databases using API testing tools like Postman. I created a ‘postman’ directory in the root folder where you’ll find API endpoints and dummy data you can use to play with your database.
Final Touches
There are a few things to do before ringing down the curtain.
# 1 Git Ignore
We need to add a .gitignore file and leave out folders that can be generated by our scripts, such as node_modules, and .dist, as well as a .env file.
node_modules
dist
.env
# 2 Build & Lint Scripts
We’ll add a few scripts to the package.json file. The build script is used to convert TypeScript (.ts) files to JavaScript files (.js). It will also throw an error if anything breaks.
The lint scripts are useful when we want to detect badly written code and tell ESLint to fix it for us.
Feel free to update .eslint.json
file if you find my rules too strict or if you want to add your own.
"build": "tsc",
"dev": "nodemon",
"postinstall": "npm run build", // runs after npm i
"lint": "eslint . --ext .ts",
"lint-fix": "eslint . --ext .ts --fix"
# 3 Start Script
To start the app in production mode we’ll make use of npm start
command. With the start script, we need to point Node.js to the compiled JavaScript app.js in the dist directory.
We’ll also add prestart
script to a package.json file to always build TypeScript before starting the app. The full list of scripts now looks like this:
"scripts": {
"build": "tsc",
"postinstall": "npm run build",
"dev": "nodemon",
"prestart": "npm run build",
"start": "ts-node ./dist/app.js",
"lint": "eslint . --ext .ts",
"lint-fix": "eslint . --ext .ts --fix"
},
One last detail we need to add is to expand the entities array in the TypeORM.ts DataSource configuration to look for entities that end with both .ts and .js extensions. This way we’ll be able to read original entities (.ts) in dev mode and transpiled ones in production mode (.js)
const dataSource = new DataSource({
type: 'postgres',
url: process.env.PGSQL_URI,
entities: [
`${__dirname}/entity/*.entity.js`,
`${__dirname}/entity/*.entity.ts`
],
synchronize: true,
});
The __dirname
is a built-in global Node.js object that is used in file paths and points to the current working directory.
Wrapping up
We’ve made it to the end. We have built the starter Express app with TypeScript and a pair of databases like we set out to do, but this app is far from being a finished product.
We’re missing a lot of things that we’d need to add in order to use this app in production, such as:
- API Validations
- Error handling
- Swagger
- Logging
- Security
- Scaling, etc.
GitHub repository
I already have some things in works to improve upon the project in the near future. To stay up to date, I created a dashboard where you’ll be able to track changes and see what I’m currently working on.
There is a lot more coming in the future. Stay tuned.
Also, if you have any questions feel free to post a comment or reach out to me on Twitter.
And if you want to see more exciting articles, be sure to hit a follow button and support me with a Cup of Coffee.
I’ll see you in the next one 👋