Build a REST API with Express and MongoDB

Build a production-grade REST API with JWT auth, Zod validation, and layered architecture.

Introduction

30 Second Summary

Every app you use daily talks to a backend API that handles your data securely. The code that powers these APIs follows specific architecture patterns that hiring managers look for in technical interviews.

In this project, you will build a production-grade REST API with user authentication, CRUD operations, and security middleware using Express, TypeScript, and MongoDB.

What You'll Build

A Todo API with user authentication that demonstrates layered architecture, JWT security, input validation with Zod, and centralized error handling.

By the end of this project, you'll have:

  • A layered API architecture with routes, controllers, services, and models that interviewers recognize as professional code.
  • Secure JWT authentication with password hashing, token generation, and protected routes.
  • A security middleware stack with input validation, rate limiting, environment configuration, and consistent error responses.
  • Secret Mission: Add pagination, filtering, and sorting to your todo list endpoint.

Are there any prerequisites?

You should have basic JavaScript or TypeScript knowledge and Node.js installed on your machine. You will also need VS Code or Cursor, a free MongoDB Atlas account, and a Git/GitHub account.

Initialize the Project and Configure TypeScript

Every production API starts with a solid foundation. Before writing any business logic, you need a properly configured project with the right packages, strict type safety, and a folder structure that hiring managers recognize instantly.

In this step, you'll scaffold a Node.js project from scratch, install all the packages your API needs, configure TypeScript for strict mode, and create a professional-grade directory layout.

In this step, get ready to:
  • Initialize the project and install all production and dev dependencies.
  • Configure TypeScript with strict production settings.
  • Create the professional folder structure and verify the dev server runs.
Initialize the project and install dependencies

Your API will use Express as its web framework, Mongoose to talk to MongoDB, and several security and validation packages to make it production-ready.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir production-todo-api
cd production-todo-api
  • Confirm the folder exists by running:
ls

You should see an empty directory (no output), confirming you're inside the new folder.

  • Initialize a new Node.js project by running:
npm init -y

What does this command do?

The -y flag tells npm to accept all the default values and generate a package.json immediately. This file tracks your project's name, version, scripts, and installed packages.

  • Install all production dependencies by running:
npm install express mongoose zod jsonwebtoken bcryptjs helmet express-rate-limit cors dotenv

What are these packages?

  • express is the web framework that handles HTTP requests and routing.
  • mongoose connects your app to MongoDB and defines data models.
  • zod validates incoming request data so bad inputs never reach your logic.
  • jsonwebtoken and bcryptjs handle authentication (tokens and password hashing).
  • helmet, cors, and express-rate-limit add security headers, cross-origin controls, and request throttling.
  • dotenv loads environment variables from a .env file so you never hardcode secrets.
  • Install the dev dependencies (TypeScript tooling and type definitions) by running:
npm install -D typescript @types/node @types/express @types/jsonwebtoken @types/cors tsx

Why tsx instead of ts-node?

The tsx runner has a much faster cold start than ts-node and requires zero configuration. It also has built-in watch mode, so your server restarts automatically when you save a file.

Install command failing?

  • Make sure your terminal is inside the production-todo-api folder. Run pwd to check your current location.
  • Confirm Node.js is installed by running node --version in your terminal. You need a version number in response.

Help me fix my npm install errors

Now you need to update package.json with the correct scripts, description, and entry point.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code (or Cursor) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the production-todo-api folder, then click Open.
  • Open package.json from the Explorer sidebar on the left.

The file already has your dependencies listed from the installs. You need to update three fields: description, main, and scripts.

  • Find this line:
"description": "",
  • Replace it with:
"description": "Production-grade REST API with Express, TypeScript, and MongoDB",
  • Find this line:
"main": "index.js",
  • Replace it with:
"main": "dist/server.js",
  • Find the scripts section:
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1"
},
  • Replace the entire scripts block with these three scripts:
"scripts": {
  "dev": "tsx watch src/server.ts",
  "build": "tsc",
  "start": "node dist/server.js"
},

What do these scripts do?

  • dev runs your TypeScript source directly with hot-reload using tsx.
  • build compiles TypeScript into JavaScript for production.
  • start runs the compiled JavaScript in production.
  • Also remove the keywords, author, and license fields that npm generated, so your file matches the target structure.
  • Save package.json.

Seeing red squiggles in package.json?

Make sure you don't have trailing commas after the last item in any JSON object or array. JSON does not allow trailing commas, and VS Code will flag them as errors.

Help me fix JSON syntax errors in my package.json

Configure TypeScript for production

TypeScript's strict mode catches bugs at compile time that would otherwise slip into production. A properly configured tsconfig.json is what separates a hobby project from interview-ready code.

  • Create a new file called tsconfig.json in the root of your production-todo-api folder by right-clicking in the Explorer sidebar and selecting New File.
  • Paste the following configuration into tsconfig.json:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "moduleResolution": "node"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

What do these settings do?

  • target: "ES2022" compiles to modern JavaScript that Node.js supports natively.
  • module: "CommonJS" uses the module system that works seamlessly with Express and most Node.js libraries.
  • strict: true enables all strict type-checking options, catching null errors, implicit any types, and more at compile time.
  • rootDir and outDir separate your source code from compiled output, keeping the project clean.
  • Save tsconfig.json.
Create the folder structure and verify the dev server

Production APIs use a layered folder structure where each directory has a single responsibility. This makes the codebase navigable for any developer who joins the project.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Create all the required directories by running this command:

macOS

mkdir -p src/config src/controllers src/middleware src/models src/routes src/services src/schemas src/types src/utils

Windows

New-Item -ItemType Directory -Path "src/config", "src/controllers", "src/middleware", "src/models", "src/routes", "src/services", "src/schemas", "src/types", "src/utils"
  • Verify the directories were created by running:
ls src

You should see all nine folders listed: config, controllers, middleware, models, routes, schemas, services, types, and utils.

What is each folder for?

  • config holds environment validation and database connection setup.
  • controllers are thin functions that receive requests and call services.
  • services contain the actual business logic (the heavy lifting).
  • models define how your data looks in MongoDB.
  • schemas hold Zod validation rules for incoming requests.
  • middleware contains reusable request processors (auth, error handling, validation).

Now create a minimal src/server.ts file to verify that tsx can run your TypeScript code.

  • In the Explorer sidebar, right-click the src folder and select New File.
  • Name the file server.ts.
  • Add this temporary placeholder to verify the toolchain works:
// Temporary placeholder to verify tsx is working
console.log("Server running on port 5000");

Why a placeholder file?

You'll replace this file with the real server code in the next step. For now, it confirms that tsx can find and execute your TypeScript without errors.

  • Save src/server.ts.
  • Start the dev server by running this command in the integrated terminal:
npm run dev

You should see Server running on port 5000 printed in the terminal. This confirms tsx is correctly running your TypeScript file.

  • Press Ctrl+C in the terminal to stop the dev server for now.

Not seeing the output?

  • Make sure src/server.ts exists (not just server.ts in the root). The file must be inside the src folder.
  • Check that the dev script in package.json says exactly tsx watch src/server.ts.
  • Confirm your terminal is inside the production-todo-api folder (VS Code's integrated terminal opens here by default when the folder is open).

Help me debug why npm run dev isn't working

✔️ Awesome, I've got everything!

Great. Double check you've saved both package.json and tsconfig.json.

ⓧ I'd like to double check the full code

Here is what your package.json should look like (your dependency versions may vary slightly):

{
  "name": "production-todo-api",
  "version": "1.0.0",
  "description": "Production-grade REST API with Express, TypeScript, and MongoDB",
  "main": "dist/server.js",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "bcryptjs": "^3.0.3",
    "cors": "^2.8.5",
    "dotenv": "^16.4.7",
    "express": "^5.2.1",
    "express-rate-limit": "^8.5.2",
    "helmet": "^8.2.0",
    "jsonwebtoken": "^9.0.3",
    "mongoose": "^8.13.2",
    "zod": "^4.4.3"
  },
  "devDependencies": {
    "@types/cors": "^2.8.17",
    "@types/express": "^5.0.1",
    "@types/jsonwebtoken": "^9.0.9",
    "@types/node": "^22.15.0",
    "tsx": "^4.19.4",
    "typescript": "^5.8.3"
  }
}

And here is what your tsconfig.json should look like:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "moduleResolution": "node"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Your project skeleton is ready. You have a properly configured TypeScript project with all the packages you need and a clean folder structure. Next up, you'll wire up environment validation and connect to your MongoDB database.

Configure Environment Variables and Database Connection

Your project structure is ready, but a production API needs more than just folders. It needs a secure way to manage configuration and a reliable database connection.

In this step, you'll build the configuration layer that professional APIs depend on. You'll validate environment variables at startup so misconfigurations crash immediately with a helpful error instead of silently breaking at runtime.

In this step, get ready to:
  • Validate environment variables with Zod so missing config fails fast.
  • Connect to MongoDB Atlas with Mongoose and proper error handling.
  • Wire up Express with security middleware and a health check endpoint.
Create the environment validation module

Production APIs have many configuration values: database URLs, secret keys, port numbers. If any of these are missing or malformed, the app should refuse to start rather than behaving unpredictably.

  • Create a new file called src/config/env.ts in your production-todo-api project by running this command in your terminal:
touch src/config/env.ts
  • Open src/config/env.ts in your editor and add the following code:
import { z } from "zod";
import dotenv from "dotenv";

dotenv.config();

// Define the shape and constraints for all required environment variables
const envSchema = z.object({
  PORT: z.string().default("5000"),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  MONGODB_URI: z.string().min(1, "MONGODB_URI is required"),
  JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
  JWT_EXPIRES_IN: z.string().default("7d"),
});

// Validate process.env against the schema
const parsed = envSchema.safeParse(process.env);

// If validation fails, log the errors and stop the server immediately
if (!parsed.success) {
  console.error("Invalid environment variables:");
  console.error(parsed.error.format());
  process.exit(1);
}

export const env = parsed.data;

What does this code do?

This module runs the moment it's imported. It loads your .env file, then validates every variable against a Zod schema.

  • safeParse checks all variables without throwing. If anything fails, the module logs a formatted error and exits with code 1.
  • default() values mean PORT, NODE_ENV, and JWT_EXPIRES_IN are optional in your .env file. MONGODB_URI and JWT_SECRET are mandatory.
  • The exported env object is fully typed. Every other file that imports it gets autocomplete and type safety for free.
  • Save src/config/env.ts.

You won't see a change yet because we haven't created the .env file or wired up the server. The next chunk handles that.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import { z } from "zod";
import dotenv from "dotenv";

dotenv.config();

const envSchema = z.object({
  PORT: z.string().default("5000"),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  MONGODB_URI: z.string().min(1, "MONGODB_URI is required"),
  JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
  JWT_EXPIRES_IN: z.string().default("7d"),
});

const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error("Invalid environment variables:");
  console.error(parsed.error.format());
  process.exit(1);
}

export const env = parsed.data;
Set up the database connection and environment files

Your env module validates config, but you need an actual .env file to validate against. You also need a database connection module that uses those validated values.

  • Create two environment files in the root of your production-todo-api folder by running:
touch .env.example .env
  • Open .env.example and add this reference template:
PORT=5000
NODE_ENV=development
MONGODB_URI=mongodb+srv://your-username:your-password@cluster.mongodb.net/todo-api?retryWrites=true&w=majority
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=7d

Why have both .env and .env.example?

The .env file contains your real secrets and is never committed to Git. The .env.example file shows teammates what variables are needed without revealing actual values.

  • Now open your .env file and add your real configuration values:
PORT=5000
NODE_ENV=development
MONGODB_URI=mongodb+srv://your-username:your-password@cluster.mongodb.net/todo-api?retryWrites=true&w=majority
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=7d
  • Replace the MONGODB_URI value with your actual MongoDB Atlas connection string.
  • Replace the JWT_SECRET value with any random string that is at least 32 characters long.

How do I get my MongoDB Atlas connection string?

  • Sign in to your MongoDB Atlas dashboard.
  • Click the Connect button on your cluster.
  • Choose Drivers as the connection method.
  • Copy the connection string provided. It starts with mongodb+srv://.
  • Replace <password> with your database user's password and add todo-api as the database name after the cluster address.

Make sure your IP address is in the Atlas IP Access List. You can add 0.0.0.0/0 for development to allow all connections.

Now create the database connection module. This file uses the validated MONGODB_URI to connect Mongoose to your Atlas cluster.

  • Create src/config/database.ts by running:
touch src/config/database.ts
  • Open src/config/database.ts and add:
import mongoose from "mongoose";
import { env } from "./env";

export const connectDatabase = async (): Promise<void> => {
  try {
    // Attempt to connect using the validated URI from env
    await mongoose.connect(env.MONGODB_URI);
    console.log("Connected to MongoDB");
  } catch (error) {
    console.error("MongoDB connection error:", error);
    process.exit(1);
  }

  // Listen for runtime connection errors after initial connect
  mongoose.connection.on("error", (error) => {
    console.error("MongoDB error:", error);
  });

  // Log when the connection drops unexpectedly
  mongoose.connection.on("disconnected", () => {
    console.log("MongoDB disconnected");
  });
};

What does this code do?

  • connectDatabase() is an async function that wraps mongoose.connect() in a try/catch. If the initial connection fails, the server exits immediately.
  • The error and disconnected event listeners handle problems that occur AFTER the initial connection succeeds.
  • Exiting on connection failure is a production best practice. It lets your process manager (PM2, Docker, etc.) restart cleanly instead of running in a broken state.
  • Save src/config/database.ts and .env.

You won't see a change yet because no entry point imports these modules. The next chunk wires everything together.

Not sure if your connection string is right?

The connection string format is mongodb+srv://username:password@cluster-name.xxxxx.mongodb.net/todo-api. Make sure you replaced the placeholder password with your actual database user password (not your Atlas login password).

Help me troubleshoot my MongoDB Atlas connection string.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before continuing.

ⓧ I'd like to double check the full code

import mongoose from "mongoose";
import { env } from "./env";

export const connectDatabase = async (): Promise<void> => {
  try {
    await mongoose.connect(env.MONGODB_URI);
    console.log("Connected to MongoDB");
  } catch (error) {
    console.error("MongoDB connection error:", error);
    process.exit(1);
  }

  mongoose.connection.on("error", (error) => {
    console.error("MongoDB error:", error);
  });

  mongoose.connection.on("disconnected", () => {
    console.log("MongoDB disconnected");
  });
};
Wire up the Express application and server entry point

You have config and database modules, but nothing uses them yet. You need two more files: app.ts (the Express app with middleware) and server.ts (the entry point that starts everything).

The app.ts file imports route and middleware modules that you'll build in the next steps. To let the server start now, you'll create minimal placeholder files for those imports first.

  • Create placeholder files for the routes and error handler by running:
touch src/routes/auth.routes.ts src/routes/todo.routes.ts src/middleware/errorHandler.ts
  • Open src/routes/auth.routes.ts and add this temporary placeholder:
import { Router } from "express";
const router = Router();
export default router;
  • Open src/routes/todo.routes.ts and add the same placeholder:
import { Router } from "express";
const router = Router();
export default router;
  • Open src/middleware/errorHandler.ts and add:
import { Request, Response, NextFunction } from "express";

export const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  res.status(500).json({ success: false, message: err.message });
};

Why create placeholders?

The main app.ts file imports routes and middleware that you'll build in Steps 3 and 4. These temporary stubs let the server start now so you can verify your configuration works. You'll replace them with full implementations later.

Now create the main Express application file. This is where all middleware, routes, and error handling come together.

  • Create src/app.ts by running:
touch src/app.ts
  • Open src/app.ts and add the following:
import express from "express";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import authRoutes from "./routes/auth.routes";
import todoRoutes from "./routes/todo.routes";
import { errorHandler } from "./middleware/errorHandler";

const app = express();

// Security headers and CORS
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: "10kb" }));

// Rate limiting: max 100 requests per 15-minute window
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { success: false, message: "Too many requests, please try again later" },
});
app.use(limiter);

// Health check endpoint for monitoring
app.get("/api/health", (req, res) => {
  res.status(200).json({ success: true, status: "ok", timestamp: new Date().toISOString() });
});

// Route mounts
app.use("/api/auth", authRoutes);
app.use("/api/todos", todoRoutes);

// Centralized error handler (must be last middleware)
app.use(errorHandler);

export default app;

What does this code do?

  • Helmet sets security HTTP headers (X-Content-Type-Options, Strict-Transport-Security, etc.) automatically.
  • CORS allows cross-origin requests so frontend apps on different domains can call your API.
  • express.json({ limit: "10kb" }) parses JSON request bodies and rejects payloads over 10kb to prevent abuse.
  • The rate limiter caps each IP at 100 requests per 15 minutes. This protects against brute-force attacks and accidental infinite loops.
  • The /api/health endpoint returns a simple OK response. Load balancers and monitoring tools use this to check if your server is alive.
  • Save src/app.ts.

Finally, create the server entry point that ties env validation, database connection, and the Express app together.

  • Create src/server.ts by running:
touch src/server.ts
  • Open src/server.ts and add:
import { env } from "./config/env";
import { connectDatabase } from "./config/database";
import app from "./app";

const startServer = async () => {
  // Connect to MongoDB before accepting requests
  await connectDatabase();

  app.listen(Number(env.PORT), () => {
    console.log(`Server running on port ${env.PORT} in ${env.NODE_ENV} mode`);
  });
};

startServer().catch((error) => {
  console.error("Failed to start server:", error);
  process.exit(1);
});

What does this code do?

The startServer function runs three things in sequence: validate env (happens automatically when env is imported), connect to the database, then start listening for HTTP requests.

If any step fails, the .catch() at the bottom logs the error and exits. This means a missing env var, a bad database URL, or a port conflict all produce a clear error instead of hanging silently.

  • Save src/server.ts.
  • Start your development server by running:
npm run dev
  • You should see two messages in your terminal: Connected to MongoDB followed by Server running on port 5000 in development mode.

Server not starting?

  • If you see "Invalid environment variables" followed by an error about MONGODB_URI or JWT_SECRET, check that your .env file exists in the project root (not inside src/) and that JWT_SECRET is at least 32 characters.
  • If you see a MongoDB connection error, verify your Atlas connection string has the correct password and that your IP address is in the Atlas access list.
  • If you see a "Cannot find module" error, check that you created all three placeholder files (src/routes/auth.routes.ts, src/routes/todo.routes.ts, src/middleware/errorHandler.ts).

Help me debug why my Express TypeScript server won't start.

Now test the fail-fast behavior. Stop the server with Ctrl+C, then try removing the MONGODB_URI line from your .env file and running npm run dev again. You should see a clear validation error instead of a cryptic crash.

Add the MONGODB_URI line back to .env and restart with npm run dev to confirm everything works again.

✔️ Awesome, I've got everything!

Great. Make sure you've restored .env and the server is running before continuing.

ⓧ I'd like to double check the full code

import express from "express";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import authRoutes from "./routes/auth.routes";
import todoRoutes from "./routes/todo.routes";
import { errorHandler } from "./middleware/errorHandler";

const app = express();

app.use(helmet());
app.use(cors());
app.use(express.json({ limit: "10kb" }));

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { success: false, message: "Too many requests, please try again later" },
});
app.use(limiter);

app.get("/api/health", (req, res) => {
  res.status(200).json({ success: true, status: "ok", timestamp: new Date().toISOString() });
});

app.use("/api/auth", authRoutes);
app.use("/api/todos", todoRoutes);

app.use(errorHandler);

export default app;
import { env } from "./config/env";
import { connectDatabase } from "./config/database";
import app from "./app";

const startServer = async () => {
  await connectDatabase();

  app.listen(Number(env.PORT), () => {
    console.log(`Server running on port ${env.PORT} in ${env.NODE_ENV} mode`);
  });
};

startServer().catch((error) => {
  console.error("Failed to start server:", error);
  process.exit(1);
});

Your configuration layer is solid. The server validates its own config, connects to the database, and applies security middleware before accepting any requests. Next up, you'll build user authentication with JWT tokens and password hashing.

Build User Authentication

Your server is running and connected to MongoDB Atlas. Now it is time to build the feature that every production API needs: user authentication.

Authentication protects your API by ensuring only verified users can access resources. You will build a complete auth system with password hashing, JWT token generation, and middleware that guards protected routes.

In this step, get ready to:
  • Create the User model, validation schemas, and auth service with password hashing and JWT tokens.
  • Build the auth controller, routes, and middleware layer.
  • Wire up centralized error handling and test the full auth flow.
Create the User model, validation schemas, and auth service

The data layer is the foundation of your auth system. You will define how users are stored, how input is validated, and how passwords are securely handled.

  • Create the file src/types/index.ts by pasting this code:
import { Request } from "express";

export interface AuthRequest extends Request {
  userId?: string;
}

This extends the Express Request type with an optional userId field that the auth middleware will fill in later.

  • Create the file src/utils/appError.ts with the custom error classes that your service layer will throw:
export class AppError extends Error {
  public readonly statusCode: number;
  public readonly isOperational: boolean;

  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    Object.setPrototypeOf(this, AppError.prototype);
  }
}

export class ValidationError extends AppError {
  constructor(message: string) {
    super(message, 400);
  }
}

export class UnauthorizedError extends AppError {
  constructor(message: string = "Unauthorized") {
    super(message, 401);
  }
}

export class NotFoundError extends AppError {
  constructor(message: string = "Resource not found") {
    super(message, 404);
  }
}

What does this code do?

These custom error classes let you throw specific errors anywhere in your code and have them handled consistently. Each class carries a statusCode so the error handler knows exactly what HTTP response to send.

The isOperational flag distinguishes expected errors (bad input, missing auth) from unexpected crashes. This is a production pattern that prevents leaking internal details to clients.

  • Create the file src/models/user.model.ts with the Mongoose schema for users:
import mongoose, { Document, Schema } from "mongoose";

export interface IUser extends Document {
  email: string;
  password: string;
  name: string;
  createdAt: Date;
  updatedAt: Date;
}

const userSchema = new Schema<IUser>(
  {
    email: {
      type: String,
      required: true,
      unique: true,
      lowercase: true,
      trim: true,
    },
    password: {
      type: String,
      required: true,
      minlength: 6,
    },
    name: {
      type: String,
      required: true,
      trim: true,
    },
  },
  { timestamps: true }
);

export const User = mongoose.model<IUser>("User", userSchema);

What does this code do?

The IUser interface defines the TypeScript shape for user documents. The Mongoose schema enforces constraints at the database level: emails are forced lowercase, passwords require at least 6 characters, and timestamps: true auto-adds createdAt and updatedAt fields.

The unique: true constraint on email prevents duplicate registrations at the database level.

  • Save src/models/user.model.ts.
  • Create the file src/schemas/auth.schema.ts with Zod validation schemas for registration and login:
import { z } from "zod";

export const registerSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string().min(6, "Password must be at least 6 characters"),
  name: z.string().min(2, "Name must be at least 2 characters"),
});

export const loginSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string().min(1, "Password is required"),
});

export type RegisterInput = z.infer<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>;

What does this code do?

Zod schemas validate incoming request bodies before they reach your service layer. If a field is missing or malformed, Zod throws a detailed error immediately. The z.infer utility extracts TypeScript types from the schemas so your service functions get full type safety for free.

  • Save src/schemas/auth.schema.ts.
  • Create the file src/services/auth.service.ts with the auth service that handles registration and login logic:
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { User } from "../models/user.model";
import { env } from "../config/env";
import { AppError, UnauthorizedError } from "../utils/appError";
import { RegisterInput, LoginInput } from "../schemas/auth.schema";

export class AuthService {
  async register(input: RegisterInput): Promise<{ token: string; user: { id: string; email: string; name: string } }> {
    // Check if a user with this email already exists
    const existingUser = await User.findOne({ email: input.email });
    if (existingUser) {
      throw new AppError("Email already registered", 409);
    }

    // Hash the password with 12 salt rounds
    const hashedPassword = await bcrypt.hash(input.password, 12);

    // Create the user in the database
    const user = await User.create({
      email: input.email,
      password: hashedPassword,
      name: input.name,
    });

    // Generate a JWT token for the new user
    const token = this.generateToken(user.id);

    return {
      token,
      user: { id: user.id, email: user.email, name: user.name },
    };
  }

  async login(input: LoginInput): Promise<{ token: string; user: { id: string; email: string; name: string } }> {
    // Find the user by email
    const user = await User.findOne({ email: input.email });
    if (!user) {
      throw new UnauthorizedError("Invalid email or password");
    }

    // Compare the provided password with the stored hash
    const isPasswordValid = await bcrypt.compare(input.password, user.password);
    if (!isPasswordValid) {
      throw new UnauthorizedError("Invalid email or password");
    }

    // Generate a JWT token for the authenticated user
    const token = this.generateToken(user.id);

    return {
      token,
      user: { id: user.id, email: user.email, name: user.name },
    };
  }

  private generateToken(userId: string): string {
    return jwt.sign({ userId }, env.JWT_SECRET, {
      expiresIn: env.JWT_EXPIRES_IN,
    });
  }
}

What does this code do?

The AuthService class contains all business logic for authentication. The register method hashes the password with bcryptjs using 12 salt rounds before storing it. This means even if the database is compromised, passwords remain protected.

The login method uses the same generic error message for both wrong email and wrong password. This prevents attackers from discovering which emails are registered. The private generateToken method signs a JWT containing the user's ID.

  • Save src/services/auth.service.ts.

Seeing red squiggly lines in your editor?

Your editor may show import errors for files that do not exist yet (like the middleware and routes). This is normal. The errors will resolve as you create the remaining files in the next substeps.

help me fix the TypeScript import errors in my auth service

✔️ Awesome, I've got everything!

Great. Make sure you have saved all five files before moving on.

ⓧ I'd like to double check the full code

Here is each file you should have at this point:

import { Request } from "express";

export interface AuthRequest extends Request {
  userId?: string;
}
export class AppError extends Error {
  public readonly statusCode: number;
  public readonly isOperational: boolean;

  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    Object.setPrototypeOf(this, AppError.prototype);
  }
}

export class ValidationError extends AppError {
  constructor(message: string) {
    super(message, 400);
  }
}

export class UnauthorizedError extends AppError {
  constructor(message: string = "Unauthorized") {
    super(message, 401);
  }
}

export class NotFoundError extends AppError {
  constructor(message: string = "Resource not found") {
    super(message, 404);
  }
}
import mongoose, { Document, Schema } from "mongoose";

export interface IUser extends Document {
  email: string;
  password: string;
  name: string;
  createdAt: Date;
  updatedAt: Date;
}

const userSchema = new Schema<IUser>(
  {
    email: {
      type: String,
      required: true,
      unique: true,
      lowercase: true,
      trim: true,
    },
    password: {
      type: String,
      required: true,
      minlength: 6,
    },
    name: {
      type: String,
      required: true,
      trim: true,
    },
  },
  { timestamps: true }
);

export const User = mongoose.model<IUser>("User", userSchema);
import { z } from "zod";

export const registerSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string().min(6, "Password must be at least 6 characters"),
  name: z.string().min(2, "Name must be at least 2 characters"),
});

export const loginSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string().min(1, "Password is required"),
});

export type RegisterInput = z.infer<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>;
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { User } from "../models/user.model";
import { env } from "../config/env";
import { AppError, UnauthorizedError } from "../utils/appError";
import { RegisterInput, LoginInput } from "../schemas/auth.schema";

export class AuthService {
  async register(input: RegisterInput): Promise<{ token: string; user: { id: string; email: string; name: string } }> {
    const existingUser = await User.findOne({ email: input.email });
    if (existingUser) {
      throw new AppError("Email already registered", 409);
    }

    const hashedPassword = await bcrypt.hash(input.password, 12);

    const user = await User.create({
      email: input.email,
      password: hashedPassword,
      name: input.name,
    });

    const token = this.generateToken(user.id);

    return {
      token,
      user: { id: user.id, email: user.email, name: user.name },
    };
  }

  async login(input: LoginInput): Promise<{ token: string; user: { id: string; email: string; name: string } }> {
    const user = await User.findOne({ email: input.email });
    if (!user) {
      throw new UnauthorizedError("Invalid email or password");
    }

    const isPasswordValid = await bcrypt.compare(input.password, user.password);
    if (!isPasswordValid) {
      throw new UnauthorizedError("Invalid email or password");
    }

    const token = this.generateToken(user.id);

    return {
      token,
      user: { id: user.id, email: user.email, name: user.name },
    };
  }

  private generateToken(userId: string): string {
    return jwt.sign({ userId }, env.JWT_SECRET, {
      expiresIn: env.JWT_EXPIRES_IN,
    });
  }
}
Create the auth controller, routes, and middleware

With the service layer complete, you need a thin controller to handle HTTP concerns and middleware to validate inputs and protect routes.

  • Create the file src/middleware/validate.ts with reusable validation middleware factories:
import { Request, Response, NextFunction } from "express";
import { ZodSchema } from "zod";

// Validates the request body against a Zod schema
export const validate = (schema: ZodSchema) => {
  return (req: Request, res: Response, next: NextFunction): void => {
    schema.parse(req.body);
    next();
  };
};

// Validates query parameters against a Zod schema
export const validateQuery = (schema: ZodSchema) => {
  return (req: Request, res: Response, next: NextFunction): void => {
    req.query = schema.parse(req.query);
    next();
  };
};

What does this code do?

These are middleware factories. You pass a Zod schema, and they return Express middleware that validates the request. If validation fails, Zod throws a ZodError which your error handler will catch and return as a clean 400 response. The validateQuery version does the same for URL query parameters.

  • Save src/middleware/validate.ts.
  • Create the file src/middleware/auth.ts with the authentication middleware that protects routes:
import { Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { env } from "../config/env";
import { UnauthorizedError } from "../utils/appError";
import { AuthRequest } from "../types";

export const authenticate = (
  req: AuthRequest,
  res: Response,
  next: NextFunction
): void => {
  // Extract the Authorization header
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    throw new UnauthorizedError("No token provided");
  }

  // Pull the token from "Bearer <token>"
  const token = authHeader.split(" ")[1];

  try {
    // Verify the token and extract the userId
    const decoded = jwt.verify(token, env.JWT_SECRET) as { userId: string };
    req.userId = decoded.userId;
    next();
  } catch (error) {
    throw new UnauthorizedError("Invalid or expired token");
  }
};

What does this code do?

The authenticate middleware checks for a Bearer token in the Authorization header. It verifies the token signature using your JWT_SECRET and extracts the userId from the payload. If the token is missing, malformed, or expired, it throws an UnauthorizedError (401).

This middleware attaches userId to the request object so downstream controllers always know which user is making the request.

  • Save src/middleware/auth.ts.
  • Create the file src/controllers/auth.controller.ts with the thin controller layer:
import { Request, Response } from "express";
import { AuthService } from "../services/auth.service";

const authService = new AuthService();

export class AuthController {
  // Handles POST /api/auth/register
  async register(req: Request, res: Response): Promise<void> {
    const result = await authService.register(req.body);
    res.status(201).json({ success: true, data: result });
  }

  // Handles POST /api/auth/login
  async login(req: Request, res: Response): Promise<void> {
    const result = await authService.login(req.body);
    res.status(200).json({ success: true, data: result });
  }
}

Why is the controller so thin?

Controllers should only handle HTTP concerns: reading the request, calling the service, and sending the response. All business logic lives in the service layer. This separation makes your code testable and keeps each layer focused on one job.

  • Save src/controllers/auth.controller.ts.
  • Create the file src/routes/auth.routes.ts to wire up the auth endpoints:
import { Router } from "express";
import { AuthController } from "../controllers/auth.controller";
import { validate } from "../middleware/validate";
import { registerSchema, loginSchema } from "../schemas/auth.schema";

const router = Router();
const authController = new AuthController();

// POST /api/auth/register - validate body then register
router.post("/register", validate(registerSchema), (req, res, next) => {
  authController.register(req, res).catch(next);
});

// POST /api/auth/login - validate body then login
router.post("/login", validate(loginSchema), (req, res, next) => {
  authController.login(req, res).catch(next);
});

export default router;

What does .catch(next) do?

Express 5 supports async route handlers, but wrapping with .catch(next) is the explicit pattern. If the controller's async method throws an error, .catch(next) passes it to the error handling middleware instead of crashing the server.

  • Save src/routes/auth.routes.ts.

✔️ Awesome, I've got everything!

Great. Save all four files before continuing.

ⓧ I'd like to double check the full code

Here are the four files from this substep:

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

export const validate = (schema: ZodSchema) => {
  return (req: Request, res: Response, next: NextFunction): void => {
    schema.parse(req.body);
    next();
  };
};

export const validateQuery = (schema: ZodSchema) => {
  return (req: Request, res: Response, next: NextFunction): void => {
    req.query = schema.parse(req.query);
    next();
  };
};
import { Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { env } from "../config/env";
import { UnauthorizedError } from "../utils/appError";
import { AuthRequest } from "../types";

export const authenticate = (
  req: AuthRequest,
  res: Response,
  next: NextFunction
): void => {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    throw new UnauthorizedError("No token provided");
  }

  const token = authHeader.split(" ")[1];

  try {
    const decoded = jwt.verify(token, env.JWT_SECRET) as { userId: string };
    req.userId = decoded.userId;
    next();
  } catch (error) {
    throw new UnauthorizedError("Invalid or expired token");
  }
};
import { Request, Response } from "express";
import { AuthService } from "../services/auth.service";

const authService = new AuthService();

export class AuthController {
  async register(req: Request, res: Response): Promise<void> {
    const result = await authService.register(req.body);
    res.status(201).json({ success: true, data: result });
  }

  async login(req: Request, res: Response): Promise<void> {
    const result = await authService.login(req.body);
    res.status(200).json({ success: true, data: result });
  }
}
import { Router } from "express";
import { AuthController } from "../controllers/auth.controller";
import { validate } from "../middleware/validate";
import { registerSchema, loginSchema } from "../schemas/auth.schema";

const router = Router();
const authController = new AuthController();

router.post("/register", validate(registerSchema), (req, res, next) => {
  authController.register(req, res).catch(next);
});

router.post("/login", validate(loginSchema), (req, res, next) => {
  authController.login(req, res).catch(next);
});

export default router;
Create centralized error handling and test the auth flow

The last piece is the error handler that catches every thrown error and returns a consistent JSON response. After that, you will test the complete registration and login flow.

  • Create the file src/middleware/errorHandler.ts with the centralized error handling middleware:
import { Request, Response, NextFunction } from "express";
import { AppError } from "../utils/appError";
import { ZodError } from "zod";
import { env } from "../config/env";

export const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  // Handle custom application errors
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      success: false,
      message: err.message,
    });
    return;
  }

  // Handle Zod validation errors
  if (err instanceof ZodError) {
    res.status(400).json({
      success: false,
      message: "Validation failed",
      errors: err.errors.map((e) => ({
        field: e.path.join("."),
        message: e.message,
      })),
    });
    return;
  }

  // Handle unexpected errors
  console.error("Unhandled error:", err);

  res.status(500).json({
    success: false,
    message:
      env.NODE_ENV === "production"
        ? "Internal server error"
        : err.message,
  });
};

What does this code do?

This middleware sits at the end of the Express pipeline and catches every error that bubbles up. It checks the error type and responds accordingly: AppError instances get their specific status code, ZodError instances get a 400 with field-level details, and unknown errors get a generic 500.

In production mode, the handler hides internal error messages from clients. In development, it exposes the full message to help you debug.

  • Save src/middleware/errorHandler.ts.

Your terminal running npm run dev should restart automatically via tsx watch. Check for any compile errors before testing.

Server not restarting or showing errors?

If you see import errors, double check that every file is saved and that the folder paths match exactly. The src/app.ts file already imports from ./routes/auth.routes, ./routes/todo.routes, and ./middleware/errorHandler. The todo routes file does not exist yet, so you may see an error. That is expected and will resolve in the next step.

If you want to test auth now without the todo routes error, you can temporarily comment out the todoRoutes import and usage line in src/app.ts. Remember to uncomment it in the next step.

help me fix my Express server startup errors after adding auth files

Now test the auth endpoints. Open a new terminal and register a user by running this command:

curl -X POST http://localhost:5000/api/auth/register -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"password123","name":"Test User"}'

You should see a JSON response with success: true and a token field containing your JWT.

  • Test login by running:
curl -X POST http://localhost:5000/api/auth/login -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"password123"}'

You should see a similar response with a fresh token.

  • Test that an invalid token is rejected by running:
curl -X GET http://localhost:5000/api/todos -H "Authorization: Bearer invalidtoken123"

You should see a 401 response with the message indicating an invalid or expired token.

Why test with curl?

Using curl from the terminal is the fastest way to test API endpoints without installing extra tools. Each command sends a real HTTP request to your running server and displays the JSON response directly in your terminal.

Getting connection refused?

Make sure your server is still running in the other terminal window. If it crashed due to the missing todo.routes file, temporarily comment out the two todoRoutes lines in src/app.ts (the import and the app.use line) and save. The server will restart.

my curl request to localhost:5000 is getting connection refused

Your authentication layer is fully wired. Next up, you will build the Todo CRUD operations that sit behind this auth middleware.

Build Todo CRUD with Protected Routes

Your authentication layer is solid. Users can register and log in, and you have a JWT middleware ready to protect routes.

Now it is time to build the core feature: a full CRUD (Create, Read, Update, Delete) system for todos. Every todo belongs to a specific user, and users can only access their own data.

In this step, get ready to:
  • Create the Todo model, Zod validation schemas, and the service layer with ownership checks.
  • Build the todo controller and wire up protected CRUD routes.
  • Test the complete flow from registration through creating, reading, updating, and deleting todos.
Create the Todo model, schemas, and service

The layered architecture pattern means you build from the bottom up: model first, then validation schemas, then the service that contains your business logic.

  • Create a new file src/models/todo.model.ts and add the following code:
import mongoose, { Document, Schema } from "mongoose";

// Define the shape of a Todo document
export interface ITodo extends Document {
  title: string;
  description: string;
  completed: boolean;
  userId: mongoose.Types.ObjectId;
  createdAt: Date;
  updatedAt: Date;
}

// Define the Mongoose schema with validation rules
const todoSchema = new Schema<ITodo>(
  {
    title: {
      type: String,
      required: true,
      trim: true,
    },
    description: {
      type: String,
      default: "",
    },
    completed: {
      type: Boolean,
      default: false,
    },
    userId: {
      type: Schema.Types.ObjectId,
      ref: "User",
      required: true,
    },
  },
  { timestamps: true }
);

export const Todo = mongoose.model<ITodo>("Todo", todoSchema);

What does this code do?

The ITodo interface defines the TypeScript shape of a todo document, ensuring type safety across your app.

The userId field is an ObjectId reference to the User collection. This is how each todo is tied to a specific user. The timestamps: true option automatically adds createdAt and updatedAt fields.

  • Save src/models/todo.model.ts.
  • Create a new file src/schemas/todo.schema.ts and add the Zod validation schemas:
import { z } from "zod";

// Schema for creating a new todo
export const createTodoSchema = z.object({
  title: z.string().min(1, "Title is required").max(200, "Title too long"),
  description: z.string().max(1000, "Description too long").optional(),
});

// Schema for updating an existing todo (all fields optional)
export const updateTodoSchema = z.object({
  title: z.string().min(1, "Title is required").max(200, "Title too long").optional(),
  description: z.string().max(1000, "Description too long").optional(),
  completed: z.boolean().optional(),
});

// Infer TypeScript types from schemas
export type CreateTodoInput = z.infer<typeof createTodoSchema>;
export type UpdateTodoInput = z.infer<typeof updateTodoSchema>;

What does this code do?

The createTodoSchema requires a title (1-200 characters) and allows an optional description. The updateTodoSchema makes every field optional so you can update just one field at a time (a PATCH pattern).

The z.infer utility generates TypeScript types directly from the Zod schemas. This gives you a single source of truth for both runtime validation and compile-time types.

  • Save src/schemas/todo.schema.ts.
  • Create a new file src/services/todo.service.ts and add the service layer with all CRUD methods:
import { Todo } from "../models/todo.model";
import { NotFoundError, UnauthorizedError } from "../utils/appError";
import { CreateTodoInput, UpdateTodoInput } from "../schemas/todo.schema";

export class TodoService {
  // Create a new todo linked to the authenticated user
  async create(input: CreateTodoInput, userId: string) {
    const todo = await Todo.create({ ...input, userId });
    return todo;
  }

  // Get all todos for a specific user, newest first
  async getAll(userId: string) {
    const todos = await Todo.find({ userId }).sort({ createdAt: -1 });
    return todos;
  }

  // Get a single todo by ID with ownership verification
  async getById(todoId: string, userId: string) {
    const todo = await Todo.findById(todoId);
    if (!todo) {
      throw new NotFoundError("Todo not found");
    }
    if (todo.userId.toString() !== userId) {
      throw new UnauthorizedError("Not authorized to access this todo");
    }
    return todo;
  }

  // Update a todo after verifying ownership
  async update(todoId: string, input: UpdateTodoInput, userId: string) {
    const todo = await this.getById(todoId, userId);
    Object.assign(todo, input);
    await todo.save();
    return todo;
  }

  // Delete a todo after verifying ownership
  async delete(todoId: string, userId: string) {
    await this.getById(todoId, userId);
    await Todo.findByIdAndDelete(todoId);
  }
}

Why ownership checks matter

The getById method does two things: it checks if the todo exists (throws NotFoundError if not) and verifies the requesting user owns it (throws UnauthorizedError if not). Both update and delete reuse getById to enforce this check automatically.

Without this pattern, any authenticated user could read or modify any other user's todos just by guessing an ID. This is a common security vulnerability called Insecure Direct Object Reference (IDOR).

  • Save src/services/todo.service.ts.

Seeing import errors in your editor?

If your editor shows red squiggles on the import paths, that is expected until all files are saved. TypeScript resolves imports across the project, so a missing file temporarily breaks the chain. Once you finish creating all the files in this step, the errors disappear.

help me fix TypeScript import errors in my Express project

Create the todo controller and routes

The controller is a thin layer that receives HTTP requests, calls the service, and sends back responses. The routes file maps URL paths to controller methods and applies middleware.

  • Create a new file src/controllers/todo.controller.ts and add the following:
import { Response } from "express";
import { TodoService } from "../services/todo.service";
import { AuthRequest } from "../types";

const todoService = new TodoService();

export class TodoController {
  // Handle POST /api/todos
  async create(req: AuthRequest, res: Response): Promise<void> {
    const todo = await todoService.create(req.body, req.userId!);
    res.status(201).json({ success: true, data: todo });
  }

  // Handle GET /api/todos
  async getAll(req: AuthRequest, res: Response): Promise<void> {
    const todos = await todoService.getAll(req.userId!);
    res.status(200).json({ success: true, data: todos });
  }

  // Handle GET /api/todos/:id
  async getById(req: AuthRequest, res: Response): Promise<void> {
    const todo = await todoService.getById(req.params.id, req.userId!);
    res.status(200).json({ success: true, data: todo });
  }

  // Handle PATCH /api/todos/:id
  async update(req: AuthRequest, res: Response): Promise<void> {
    const todo = await todoService.update(req.params.id, req.body, req.userId!);
    res.status(200).json({ success: true, data: todo });
  }

  // Handle DELETE /api/todos/:id
  async delete(req: AuthRequest, res: Response): Promise<void> {
    await todoService.delete(req.params.id, req.userId!);
    res.status(204).send();
  }
}

What does this code do?

Each controller method uses AuthRequest instead of the standard Request type. This gives access to req.userId which the auth middleware attaches after verifying the JWT token.

The non-null assertion (req.userId!) is safe here because the auth middleware runs before these handlers. If the token is missing or invalid, the middleware throws before the controller ever executes.

  • Save src/controllers/todo.controller.ts.
  • Create a new file src/routes/todo.routes.ts and add the route definitions:
import { Router } from "express";
import { TodoController } from "../controllers/todo.controller";
import { authenticate } from "../middleware/auth";
import { validate } from "../middleware/validate";
import { createTodoSchema, updateTodoSchema } from "../schemas/todo.schema";
import { AuthRequest } from "../types";

const router = Router();
const todoController = new TodoController();

// Apply auth middleware to ALL todo routes
router.use(authenticate);

// POST /api/todos - create a new todo
router.post("/", validate(createTodoSchema), (req: AuthRequest, res, next) => {
  todoController.create(req, res).catch(next);
});

// GET /api/todos - list all todos for the authenticated user
router.get("/", (req: AuthRequest, res, next) => {
  todoController.getAll(req, res).catch(next);
});

// GET /api/todos/:id - get a single todo by ID
router.get("/:id", (req: AuthRequest, res, next) => {
  todoController.getById(req, res).catch(next);
});

// PATCH /api/todos/:id - update a todo
router.patch("/:id", validate(updateTodoSchema), (req: AuthRequest, res, next) => {
  todoController.update(req, res).catch(next);
});

// DELETE /api/todos/:id - delete a todo
router.delete("/:id", (req: AuthRequest, res, next) => {
  todoController.delete(req, res).catch(next);
});

export default router;

What does this code do?

The router.use(authenticate) line applies the JWT authentication middleware to every route in this file. Any request to /api/todos without a valid token gets rejected before reaching the controller.

The .catch(next) pattern forwards any errors from the async controller methods to your centralized error handler. This prevents unhandled promise rejections from crashing the server.

  • Save src/routes/todo.routes.ts.
  • Check your terminal. The tsx watch process should detect the new files and restart the server automatically. You should see "Server running on port 5000" and "Connected to MongoDB" in the output.

Server not restarting?

Make sure all five new files are saved: todo.model.ts, todo.schema.ts, todo.service.ts, todo.controller.ts, and todo.routes.ts. The server cannot start if any imported file is missing.

help me debug why my Express server won't restart after adding new files

✔️ Awesome, I've got everything!

Great. Double check that all five files are saved and the server is running without errors.

ⓧ I'd like to double check the full code

Here is the complete code for each new file:

import mongoose, { Document, Schema } from "mongoose";

export interface ITodo extends Document {
  title: string;
  description: string;
  completed: boolean;
  userId: mongoose.Types.ObjectId;
  createdAt: Date;
  updatedAt: Date;
}

const todoSchema = new Schema<ITodo>(
  {
    title: {
      type: String,
      required: true,
      trim: true,
    },
    description: {
      type: String,
      default: "",
    },
    completed: {
      type: Boolean,
      default: false,
    },
    userId: {
      type: Schema.Types.ObjectId,
      ref: "User",
      required: true,
    },
  },
  { timestamps: true }
);

export const Todo = mongoose.model<ITodo>("Todo", todoSchema);
import { z } from "zod";

export const createTodoSchema = z.object({
  title: z.string().min(1, "Title is required").max(200, "Title too long"),
  description: z.string().max(1000, "Description too long").optional(),
});

export const updateTodoSchema = z.object({
  title: z.string().min(1, "Title is required").max(200, "Title too long").optional(),
  description: z.string().max(1000, "Description too long").optional(),
  completed: z.boolean().optional(),
});

export type CreateTodoInput = z.infer<typeof createTodoSchema>;
export type UpdateTodoInput = z.infer<typeof updateTodoSchema>;
import { Todo } from "../models/todo.model";
import { NotFoundError, UnauthorizedError } from "../utils/appError";
import { CreateTodoInput, UpdateTodoInput } from "../schemas/todo.schema";

export class TodoService {
  async create(input: CreateTodoInput, userId: string) {
    const todo = await Todo.create({ ...input, userId });
    return todo;
  }

  async getAll(userId: string) {
    const todos = await Todo.find({ userId }).sort({ createdAt: -1 });
    return todos;
  }

  async getById(todoId: string, userId: string) {
    const todo = await Todo.findById(todoId);
    if (!todo) {
      throw new NotFoundError("Todo not found");
    }
    if (todo.userId.toString() !== userId) {
      throw new UnauthorizedError("Not authorized to access this todo");
    }
    return todo;
  }

  async update(todoId: string, input: UpdateTodoInput, userId: string) {
    const todo = await this.getById(todoId, userId);
    Object.assign(todo, input);
    await todo.save();
    return todo;
  }

  async delete(todoId: string, userId: string) {
    await this.getById(todoId, userId);
    await Todo.findByIdAndDelete(todoId);
  }
}
import { Response } from "express";
import { TodoService } from "../services/todo.service";
import { AuthRequest } from "../types";

const todoService = new TodoService();

export class TodoController {
  async create(req: AuthRequest, res: Response): Promise<void> {
    const todo = await todoService.create(req.body, req.userId!);
    res.status(201).json({ success: true, data: todo });
  }

  async getAll(req: AuthRequest, res: Response): Promise<void> {
    const todos = await todoService.getAll(req.userId!);
    res.status(200).json({ success: true, data: todos });
  }

  async getById(req: AuthRequest, res: Response): Promise<void> {
    const todo = await todoService.getById(req.params.id, req.userId!);
    res.status(200).json({ success: true, data: todo });
  }

  async update(req: AuthRequest, res: Response): Promise<void> {
    const todo = await todoService.update(req.params.id, req.body, req.userId!);
    res.status(200).json({ success: true, data: todo });
  }

  async delete(req: AuthRequest, res: Response): Promise<void> {
    await todoService.delete(req.params.id, req.userId!);
    res.status(204).send();
  }
}
import { Router } from "express";
import { TodoController } from "../controllers/todo.controller";
import { authenticate } from "../middleware/auth";
import { validate } from "../middleware/validate";
import { createTodoSchema, updateTodoSchema } from "../schemas/todo.schema";
import { AuthRequest } from "../types";

const router = Router();
const todoController = new TodoController();

router.use(authenticate);

router.post("/", validate(createTodoSchema), (req: AuthRequest, res, next) => {
  todoController.create(req, res).catch(next);
});

router.get("/", (req: AuthRequest, res, next) => {
  todoController.getAll(req, res).catch(next);
});

router.get("/:id", (req: AuthRequest, res, next) => {
  todoController.getById(req, res).catch(next);
});

router.patch("/:id", validate(updateTodoSchema), (req: AuthRequest, res, next) => {
  todoController.update(req, res).catch(next);
});

router.delete("/:id", (req: AuthRequest, res, next) => {
  todoController.delete(req, res).catch(next);
});

export default router;
Test the complete CRUD flow

Your src/app.ts file already has the todo routes registered and a health check endpoint. With the new files in place, your API is fully operational.

  • Verify the health check endpoint by running this command in a new terminal:
curl http://localhost:5000/api/health

You should see a JSON response with "status":"ok" in the output.

  • Register a new user by running:
curl -X POST http://localhost:5000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123","name":"Test User"}'

You should see a JSON response containing a token field with a long JWT string. Copy this token value for the next commands.

  • Create a todo using your token by running this command (replace YOUR_TOKEN_HERE with the JWT you just received):
curl -X POST http://localhost:5000/api/todos \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN_HERE" \
  -d '{"title":"Learn Express","description":"Build a production API"}'

You should see a response with "success":true and a data object containing your new todo with an _id field.

  • Retrieve all your todos by running:
curl http://localhost:5000/api/todos \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"

You should see an array of todos inside the data field, sorted newest first.

  • Test that authentication is enforced by sending a request without a token:
curl http://localhost:5000/api/todos

You should see a 401 response with "message":"No token provided". This confirms unauthenticated users cannot access todos.

What about update and delete?

To update a todo, send a PATCH request to /api/todos/:id with the fields you want to change. To delete, send a DELETE request to the same URL. Both require the Bearer token and only work on todos you own.

The ownership check ensures that even if someone guesses another user's todo ID, the API returns 401 instead of exposing the data.

Getting a 401 even with a token?

Make sure there is a space between Bearer and the token value in the Authorization header. The format must be exactly Bearer eyJhb... with one space separating them.

Also check that you are using the token from the most recent registration or login response. Tokens from previous server restarts may be invalid if your JWT_SECRET changed.

help me debug why my API returns 401 even with a Bearer token

Your API now has complete CRUD operations with authentication and authorization. Next up, you will push this project even further with pagination, filtering, and sorting in the Secret Mission.

Secret mission

Add Pagination, Filtering, and Sorting

Your API currently dumps every single todo in one response. That works fine with 5 items, but what about 5,000? Real production APIs let clients request exactly the slice of data they need. Add pagination, filtering by completion status, and sorting to your GET /api/todos endpoint.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally with a MongoDB Atlas Free Tier cluster, so there are no ongoing costs.

Resources you used:

  • Local production-todo-api project folder on your Desktop (source code, node_modules, .env file).
  • MongoDB Atlas todo-api database (stores user and todo documents on your Free Tier cluster).
  • Running dev server process (tsx watch in your terminal).

Keep everything running

No action needed. Choose this if you are still actively building or testing your API.

  • Your dev server continues running in the terminal. Press Ctrl+C when you want to stop it.
  • Your MongoDB Atlas Free Tier cluster stays active at no cost. Atlas automatically pauses it after 30 days of inactivity.
  • Your project folder remains on your Desktop.

Pause - I'll come back to this later

Shut down the running process to free up memory. Your files and database stay intact for when you return.

  • In your terminal, press Ctrl+C to stop the dev server.
  • Your MongoDB Atlas Free Tier cluster will auto-pause after 30 days of inactivity. No manual action needed.
  • Your production-todo-api folder stays on your Desktop, ready for next time.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible.

  • In your terminal, press Ctrl+C to stop the dev server if it is still running.
  • Delete the local project folder by running the command for your operating system.

macOS

cd ~/Desktop
rm -rf production-todo-api

Windows

cd ~\Desktop
rmdir /s /q production-todo-api
  • Drop the todo-api database from MongoDB Atlas. Log in to Atlas, click your cluster name, then click the Browse Collections button.
  • Hover over the todo-api database in the left panel and click the trash can icon.
  • Type todo-api to confirm, then click Drop.

What about the Atlas cluster itself?

Your M0 Free Tier cluster costs nothing and auto-pauses after inactivity. Dropping the database removes your project data while keeping the cluster available for future projects.

If you want to terminate the cluster entirely, go to the Clusters page in Atlas, click the ... menu next to your cluster, select Terminate, enter the cluster name, and click Terminate to confirm.

Nice Work!

Nice Work!

Nice work! You just built a production-grade REST API with Express 5, TypeScript, and MongoDB that demonstrates the exact architecture patterns hiring managers look for in technical interviews.

You've learned how to:

  • Build a layered architecture REST API with Express 5 and TypeScript using the routes -> controllers -> services -> models pattern.
  • Implement secure JWT authentication with password hashing, token generation, and protected routes.
  • Add centralized error handling, input validation with Zod schemas, and a security middleware stack (Helmet, CORS, rate limiting).
  • Secret Mission: Added pagination, filtering, and sorting to transform a basic endpoint into a production-ready data API.

Ready to quiz yourself?