Building a fitness app backend with Node.js, TypeScript, Postgres, and TypeORM (Part 1)


Introduction
Quick story, guys. I was scrolling on X when I saw a link to apply for a gig. The form asked me to submit a project from GitHub. I went to my GitHub, and something shocked me: I had no recent project.
So I started looking for a project to work on and stumbled on this one from roadmap.sh. I was interested because I might use it for personal use. The project is a fitness app where users can create an account, see a list of exercises, create a workout plan, and create a list of workout exercises of their choice. So let’s build it together.
We will build the project with Express.js for REST APIs, Postgres, and TypeORM for the database. I am documenting the project because I will be exploring some things for the first time and want to learn and share the knowledge.
In the first part, we will cover user authentication and authorization. We will explore topics like setting up the database, hashing passwords with bcrypt, generating tokens with jsonwebtoken, and more. So join me on the ride.
Prerequisites
A basic understanding of the backend development flow is essential. Familiarity with technologies like Node.js, Express.js, and TypeScript is important
A beginner's knowledge of Postgres and TypeORM would be appreciated
Project setup
Technologies:
Folder setup:
Initiate npm:
npm init -y
.Create
tsconfig.json
file:tsc —init
.Copy this configuration into the
tsconfig.json
file.{ "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "target": "ES2020", "module": "ESNext", "esModuleInterop": true, "moduleResolution": "node", "strict": true, "skipLibCheck": true, "allowImportingTsExtensions": true, "noEmit": true, "experimentalDecorators": true, "strictPropertyInitialization": false, "emitDecoratorMetadata": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] }
In the package.json file, include these scripts:
"scripts": { "dev": "nodemon --exec npx tsx src/server.ts", "build": "npx tsc", "start": "node dist/server.js", }
Create the file structure to match this format.
Project file structure
Packages
Install these packages as dependencies.
npm install bcrypt dotenv express jsonwebtoken zod reflect-metadata
Install these packages as development dependencies.
npm install @types/bcrypt @types/dotenv @types/express @types/jsonwebtoken @types/node nodemon ts-node tsx typescript --save dev
Database connection
Postgres is an open-source relational database for storing and manipulating data. TypeORM is an object-relational mapper that simplifies database interactions. When both are combined, developers can utilize the object-oriented approach and eliminate the complexities of Postgres.
Before our application can interact with the Postgres database, we must step up a connection with TypeORM by creating an data-source
instance. The data-source
instance defines the necessary parameters for the database connection.
// /src/data-source.ts
import { DataSource } from "typeorm";
import * as dotenv from "dotenv";
import { User } from "./entities/user.ts";
dotenv.config();
export const AppDataSource = new DataSource({
type: "postgres",
url: process.env.DB_URL,
entities: [ User],
synchronize: false,
logging: true,
});
The code above defines the parameters in the DataSource
instance. Let’s break down these parameters.
type
:This specifies the database type we are trying to connect to, which is Postgres for our project.url
: This specifies the database URL we have defined in our.env
file. The URL follows this format:postgresql://postgres:
postgres@localhost:5432
/workout_db?sslmode=disable
entities
: This specifies the entity class TypeORM should use to create the user profile.synchronize
: This specifies that TypeORM should not define the database table based on the entities defined. This will rather be done with migration to track changes.logging
: This logs all database entries.
Implementing signup
This section will discuss how to implement the user signup functionality. The image below shows the design flow.
First, let’s create the user entity.
// /src/entities/user.ts
import {
PrimaryGeneratedColumn,
Column,
Entity,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ nullable: false, type: "varchar" })
firstname!: string;
@Column({ nullable: false, type: "varchar" })
lastname!: string;
@Column({ nullable: false, type: "varchar" })
email!: string;
@Column({ nullable: false, type: "varchar" })
password!: string;
@Column({ type: "boolean", default: true })
isActive = true;
@Column({ type: "varchar", nullable: true, array: true })
refreshToken?: string[];
@Column({ nullable: false, type: "varchar" })
phone!: string;
@Column({ nullable: false, type: "varchar" })
country!: string;
@CreateDateColumn()
createdAt?: Date;
@UpdateDateColumn()
updatedAt?: Date;
}
The code above imports all the necessary decorators from TypeORM. Each decorator defines how each field should appear in the database. For instance, @PrimaryGeneratedColumn("uuid") id!: string;
decorates the id
field as a primary column whose value is automatically generated with a UUID
.
Next, we will create a data validation schema with a schema validation library called Zod.
// src/dtos/auth.dto.ts
import { z } from "zod";
export const CreateUserSchema = z
.object({
email: z
.string()
.email({ message: "Please enter a valid email address" })
.trim(),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters long" }),
confirmPassword: z.string().min(8, {
message: "Confirm password must be at least 8 characters long",
}),
firstname: z.string().min(1, { message: "First name is required" }),
lastname: z.string().min(1, { message: "Last name is required" }),
phone: z.string(),
country: z.string().optional(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
export type UserCreationDto = Omit<CreateUserDto, "confirmPassword">;
The code above defines the user validation schema that enables the application to validate incoming user data. We have also inferred the schema and used the inferred type as a Data Transfer Object (DTO).
Next, we create the business logic. This is going to be the authentication service.
// src/services/authService.ts
import { User } from "../entities/user";
import { UserCreationDto, UserResponseDto } from "../dtos/auth.dto";
import { AppDataSource } from "../data-source";
import { encrypt } from "../utils/hash";
import { AppError } from "../utils/appError";
import { jwtTokens } from "../utils/jwt";
export class authService {
static async registerUser(
payload: UserCreationDto
): Promise<UserResponseDto | void> {
try {
const userRepository = AppDataSource.getRepository(User);
const isUser = await userRepository.findOneBy({ email: payload.email });
if (isUser) {
throw new AppError("User already exists", 409, true, "conflict");
}
const hashedPassword = await encrypt.encryptPassword(payload.password);
const userData = {
firstname: payload.firstname,
lastname: payload.lastname,
email: payload.email,
phone: payload.phone,
password: hashedPassword,
country: payload.country,
createdAt: new Date(),
};
const userCreated = await userRepository.save(userData);
const { password, ...userProfile } = userCreated;
return userProfile;
} catch (error) {
if (!(error instanceof AppError)) {
console.error("Unexpected error during user registration:", error);
throw new AppError("Internal server error", 500, false, "error");
}
throw error
}
}
}
The code above is a static asynchronous method that handles the user signup service. TypeORM AppDataSource
grants the application access to the User
table in the database. The code checks if the user exists already and returns an error message if it does. The hashedPassword
variable holds the encrypted user password.
The userData
object holds the user information and is used to create and save a user to the database. The password is removed from the response data by destructuring as a security measure. The AppError
class returns an error response if it is not defined in the class.
Let’s explore some methods like AppError
and encryptPassword
. Also, we will look at the DTOs we used as types.
The AppError class provides detailed error details for different situations we might encounter in our application.
// /src/utils
export class AppError extends Error {
public readonly statusCode: number;
public readonly isOperational: boolean | undefined;
public readonly status: string | undefined;
constructor(
message: string,
statusCode: number,
isOperational: boolean = true,
status: string
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
this.statusCode = statusCode;
this.isOperational = isOperational;
this.status = status;
Error.captureStackTrace(this, this.constructor);
}
}
The code above defines some properties like statusCode
, status
, and isOperational
. isOperational
Indicates if the error is an expected error or an operational error. In the constructor, the properties are set to their values. The prototype is set so that the AppError
class can behave as a subclass of the JavaScript Error class. Error.captureStackTrace
ensures we see the error stack trace in the development environment.
Another method we used in the registerUser
method is the encryptPassword
from the encrypt
class.
// src/utils/hash.ts
export class encrypt {
static async encryptPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
return hashedPassword;
}
}
The code above is a function that uses bcrypt to hash the password and returns the hashed password as a string.
The next step is to create the application controller, which will handle the request and response and connect with the service.
// src/controllers/authController.ts
export class authController {
static async signup(req: Request, res: Response, next: NextFunction) {
try {
const validateData = CreateUserSchema.parse(req.body);
if (validateData.password !== validateData.confirmPassword) {
throw new AppError(
"Password and confirm password do not match",
406,
true,
"failed"
);
}
const { confirmPassword, ...userData } = validateData;
const newUser = await authService.registerUser(userData);
const response = new ResponseHandler(
newUser,
"User created successfully",
201,
null,
"success"
);
response.send(res);
} catch (error) {
next(error);
}
}
}
The signup
method gets the request data from the request body and uses the CreateUserSchema
to validate it. There is a check to confirm that the password
and the confirmPassword
inputs matches each other. The data is passed into ResponseHandler
which then defines how the response is rendered.
Now let's look at the ResponseHandler
function and how it works.
// src/utils/response.ts
export class ResponseHandler {
private data: any;
private message: string;
private statusCode: number;
private meta: any | null;
private status: string;
constructor(
data: any,
message: string,
statusCode: number,
meta: any = null,
status: string
) {
this.data = data;
this.message = message;
this.statusCode = statusCode;
this.meta = meta;
this.status = status;
}
public send(res: Response): void {
const responseBody: any = {
statusCode: this.statusCode,
message: this.message,
data: this.data,
status: this.status,
};
if (this.meta) {
responseBody.meta = this.meta;
}
res.status(this.statusCode).json(responseBody);
}
}
The class has key components like the data
, that holds the user info, message
, status
, statusCode
, and meta
holds pagination details. The constructor initializes these properties, assigning the meta
default value to null. Lastly, it creates the send
method that handles the standardized response structure.
Let’s create the route for the application controller.
// src/routes/authRoute.ts
import express from "express";
import { authController } from "../controllers/authController";
const authRouter = express.Router();
authRouter.post("/signup", authController.signup);
export default authRouter;
This code is an Express router that handles routing to /signup
as a post
request. It connects to the controller created earlier.
Let’s set up the express application.
// src/app.ts
import express, { ErrorRequestHandler } from "express";
import authRouter from "../src/routes/authRoute";
import exerciseRouter from "./routes/exerciseRoute";
import { errorHandler } from "./middlewares/errorMiddleware";
const app = express();
app.use(express.json());
app.get("/", (req, res) => {
res.status(200).json({
status: "success",
message: "Welcome Fitness Tracker",
});
});
app.use("/api/v1", authRouter);
app.use(errorHandler as ErrorRequestHandler);
export default app;
This code creates an Express application instance, integrates the authRouter
created earlier, and lastly, registers a custom error-handling middleware.
Let’s look at the error middleware in detail.
// src/middlewares/errorMiddleware.ts
import { Request, Response, NextFunction } from "express";
import { AppError } from "../utils/appError";
import * as dotenv from "dotenv";
dotenv.config();
import { Request, Response, NextFunction } from "express";
import { AppError } from "../utils/appError";
import * as dotenv from "dotenv";
dotenv.config();
export const errorHandler = (
err: AppError | Error,
req: Request,
res: Response,
next: NextFunction
): void | Response => {
const statusCode = err instanceof AppError ? err.statusCode : 500;
const status = statusCode >= 400 && statusCode < 500 ? "fail" : "error";
if (process.env.NODE_ENV === "development") {
console.error(err);
return res.status(statusCode).json({
status: status,
message: err.message,
stack: err.stack,
error: err,
});
}
if (process.env.NODE_ENV === "production") {
return res.status(statusCode).json({
status: status,
message:
err instanceof AppError && err.isOperational
? err.message
: "Something went very wrong!",
});
}
next(err);
};
The errorHandler
function takes either the AppError
or the standard JavaScript Error
class as the err
object. The error is further classified using the error statusCode
. The error is handled differently depending on the environment the application is running.
To the last piece that connects the entire application and keeps everything running: the server.
// src/server.ts
import "reflect-metadata";
import * as dotenv from "dotenv";
dotenv.config();
import app from "./app";
import { AppDataSource } from "../src/data-source";
AppDataSource.initialize()
.then(() => {
console.log("Connected to DB");
app.listen(process.env.PORT, () =>
console.log("Server running on port 3000")
);
})
.catch((error) => console.error("DB connection error:", error));
This code connects the Express app and the database. The AppDataScource
creates an instance that listens to the Express server and connects to the database.
Let’s test this functionality. You can use Postman or ThunderClient, or any API testing tool you know. I will be using Postman for this demonstration.
The above images show the response when the user account is created.
The above image shows the response when the user used an existing email to register a new account.
Implementing login
This section will discuss how to implement the user login functionality. The image below shows the design flow.
We will start by creating a method called loginUser
in the authService
class.
// src/services/authService.ts
import { User } from "../entities/user";
import { AppDataSource } from "../data-source";
import { encrypt } from "../utils/hash";
import { AppError } from "../utils/appError";
import {
UserLoginDto,
loginResponseDto,
} from "../dtos/auth.dto";
import { jwtTokens } from "../utils/jwt";
export class authService {
static async loginUser(payload: UserLoginDto): Promise<loginResponseDto> {
try {
const userRepository = AppDataSource.getRepository(User);
const isUser = await userRepository.findOneBy({ email: payload.email });
if (!isUser) {
throw new AppError("Invalid credentials", 404, true, "Not found");
}
let isPasswordVerified;
if (payload.password) {
isPasswordVerified = await encrypt.comparePassword(
payload.password,
isUser.password
);
}
if (!isPasswordVerified) {
throw new AppError("Invalid credentials", 401, true, "Unauthorized");
}
let accessToken: string;
let refreshToken: string;
try {
accessToken = await jwtTokens.generateAccessToken({
id: isUser.id,
email: isUser.email,
});
refreshToken = await jwtTokens.generateRefreshToken({
id: isUser.id,
email: isUser.email,
});
} catch (error) {
throw new AppError(
"Failed to generate token",
500,
false,
"token_error"
);
}
encrypt.hashRefreshToken(refreshToken, isUser);
return { accessToken: accessToken, refreshToken: refreshToken };
} catch (error) {
if (!(error instanceof AppError)) {
console.error("Error trying to login");
throw new AppError("Internal server error", 500, false, "error");
}
throw error;
}
}
}
The login service handles the logic for logging into the application. The user's email is checked to see if it has been used to create an account. If it has been used, an error is returned.
Next, the user's password is compared with the password saved in the database by calling the comparePassword
method. The access and refresh tokens are then generated for user authorization. The hashRefreshToken
method hashes and saves the refresh token to the database.
Let’s create the methods we called in the loginUser
function.
// src/utils/hash.ts
import * as bcrypt from "bcrypt";
import * as dotenv from "dotenv";
import * as crypto from "crypto";
import { User } from "../entities/user";
import { AppDataSource } from "../data-source";
dotenv.config();
export class encrypt {
static async comparePassword(
password: string,
hashedPassword: string
): Promise<boolean> {
const isMatch = await bcrypt.compare(password, hashedPassword);
return isMatch;
}
static async hashRefreshToken(
refreshToken: string,
user: User
): Promise<string> {
const authSecret = process.env.AUTH_REFRESH_TOKEN_SECRET;
const userRepository = AppDataSource.getRepository(User);
if (!authSecret) {
throw new Error("AUTH_REFRESH_TOKEN_SECRET is not set");
}
const rTknHash = crypto
.createHmac("sha256", authSecret)
.update(refreshToken)
.digest("hex");
user.refreshToken = user.refreshToken
? [...user.refreshToken, rTknHash]
: [rTknHash];
await userRepository.save(user);
return refreshToken;
}
}
The comparePassword
compares the user's password and the password saved in the database for the user's email. It executes the bcrypt compare method, which takes both the password and the hashed password and compares both.
The hashRefreshToken
takes the user
object and refreshToken
, hashes the refreshToken
and saves it into the database.
Next, we create the methods that generate the access and refresh tokens. Also, we create one that verifies the token.
// src/utils/jwt.ts
import jwt, { SignOptions } from "jsonwebtoken";
import { StringValue } from "ms";
import * as dotenv from "dotenv";
import { UserResponseDto } from "../entities/user";
import { AppError } from "./appError";
dotenv.config();
export class jwtTokens {
static async generateAccessToken({
id,
email,
}: {
id: string;
email: string;
}): Promise<string> {
const secret = process.env.TOKEN_SECRET;
const expiresIn = process.env.ACCESS_TOKEN_EXPIRES_IN;
if (!secret) throw new Error("TOKEN_SECRET is not defined");
const options: SignOptions = {
expiresIn: expiresIn as StringValue,
};
try {
const accessToken = jwt.sign({ id, email }, secret, options);
return accessToken;
} catch (error) {
throw error;
}
}
static async generateRefreshToken({
id,
email,
}: {
id: string;
email: string;
}): Promise<string> {
const expiresIn = process.env.REFRESH_TOKEN_EXPIRES_IN;
const secret = process.env.AUTH_REFRESH_TOKEN_SECRET;
if (!secret) throw new Error("TOKEN_SECRET is not defined");
const options: SignOptions = {
expiresIn: expiresIn as StringValue,
};
const refreshToken = jwt.sign({ id, email }, secret, options);
return refreshToken;
}
static async verifyToken(
token: string
): Promise<{ id: string; email: string } | null> {
try {
const decoded = jwt.verify(token, process.env.TOKEN_SECRET as string) as {
id: string;
email: string;
};
return decoded;
} catch (error: any) {
if (error.name === "JsonWebTokenError") {
throw new AppError(
"Token verfication failed",
403,
true,
"Invalid token"
);
}
if (error.name === "TokenExpiredError") {
throw new AppError(
"Token Expired",
403,
true,
"Jwt expired"
);
}
console.error("Token verification failed:", error);
return null;
}
}
}
The generateAccessToken
method creates a token that will authorize a user to access some protected routes. The jwt.sign()
method accepts the user id and email and uses both to sign in the access token.
The second method, generateRefreshToken
, implements the refresh token. It is a security best practice method that enables longer sessions while minimizing the risk of token theft. This approach allows access tokens to expire frequently while giving users a seamless experience through automatic token renewal.
Finally, the verifyToken
method handles token validation. The method classifies errors based on the error name and returns the appropriate response.
Next, we define a function that takes the token from the header and passes it to verifyToken
method for the user’s authorization.
// src/middlewares/authMiddleware.ts
import { Response, Request, NextFunction } from "express";
import { jwtTokens } from "../utils/jwt";
import { AppError } from "../utils/appError";
import "../types/custom-request";
export const authMiddleware = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void | any> => {
const token: string | undefined = req.headers["authorization"]?.split(" ")[1];
if (!token) {
throw new AppError("Token missing", 404, true, "Not found");
}
try {
const decoded = await jwtTokens.verifyToken(token);
if (!decoded) {
throw new AppError("Token Error", 403, true, "Forbidden");
}
req.user = decoded;
next();
} catch (error) {
next(error);
}
};
This code decodes the token and saves the user details in the request object.
Also, we imported some DTOs that we used to define the return type of the request and the response.
// src/dtos/auth.dto.ts
export interface UserLoginDto {
password?: string;
email?: string;
}
export interface loginResponseDto {
accessToken: string;
refreshToken: string;
}
Let’s create the LoginUserSchema
that will validate the user input
// src/dtos/auth.dto.ts
export const LoginUserSchema = z.object({
email: z
.string()
.email({ message: "Please enter a valid email address" })
.trim(),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters long" }),
});
This code validates the user's email and password. It also returns a message validation.
Next, we create the controller that will handle our responses.
// src/controllers/authController.ts
import { Response, Request, NextFunction } from "express";
import { authService } from "../services/authService";
import { LoginUserSchema } from "../dtos/auth.dto";
import { AppError } from "../utils/appError";
import { ResponseHandler } from "../utils/response";
export class authController {
static async login(req: Request, res: Response, next: NextFunction) {
try {
const validateData = LoginUserSchema.parse(req.body);
if (!validateData) {
throw new AppError("Validation failed", 422, true, "validation_error");
}
const tokens = await authService.loginUser(validateData);
const response = new ResponseHandler(
tokens,
"Login successful",
200,
null,
"success"
);
response.send(res);
} catch (error) {
next(error);
}
}
}
The controller uses the LoginUserSchema
to validate the user input and return an error if there is a validation error. Lastly, it uses the ResponseHandler
to send the tokens to the client after connecting to the service.
Lastly, we define the route.
// src/routes/authRoute.ts
import express from "express";
import { authController } from "../controllers/authController";
const authRouter = express.Router();
authRouter.post("/login", authController.login);
export default authRouter;
Let’s test this functionality. I will be using Postman for this demonstration.
The image above shows the success response when a user logs into the application.
The image above shows the error response when a user tries to log in with invalid credentials.
Conclusion
In this article, we have built a robust user authentication and authorization system. We explored fundamental key aspects of backend security.
We learned how to connect to a database, secure and validate user input, hash sensitive information, generate tokens, and handle errors properly.
We have seen how these technologies connect to create a seamless authentication and authorization flow. We have seen how TypeScript ensures type safety, how we handled routing and middleware with Express, how TypeORM interacts with the database, and so much more.
Our implementation shows how we have secured every layer of the application, from hashing sensitive information before storing to effective error handling. We were able to generate a short-lived access token to enhance security.
Future enhancements
Implementing Multi-Factor Authentication
Creating endpoints for token refresh
Rate limiting
Logging
Next steps
In the upcoming series, we will implement the following:
Seed the database with exercise data
Implement database migration
Fetch all available exercises
Subscribe to my newsletter
Read articles from Munir Abdullahi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Munir Abdullahi
Munir Abdullahi
I am a backend developer who is also interested in technical writing and open-source development. I am very open to collaboration, mentorship, and internship roles.