Build a REST API with TypeScript — Full Project Tutorial
In this tutorial, you will learn about Build a REST API with TypeScript. We cover key concepts, practical examples, and best practices to help you master this topic.
Build a production-ready REST API using TypeScript, Express, and Prisma — this hands-on project walks through every step from project setup to deployment, applying patterns from previous lessons in a real application.
What You'll Learn
- Full project structure and setup
- Prisma schema and migrations
- Typed Express routes with Zod validation
- Authentication with JWT
- Comprehensive test suite
- Production deployment
Why It Matters
Building a complete project from scratch — not just snippets — is how TypeScript skills become second nature. This project combines Express APIs, typed database access, error handling, and testing into a single cohesive application that you can extend for real projects.
Real-World Use
Every DodaTech service — from license verification to user management — follows the same architecture you'll build here. The patterns are production-proven across millions of daily requests.
Learning Path
flowchart LR A[Performance] --> B[Project: REST API] B --> C[Project: Dashboard] B --> D[You Are Here] C --> E[Project: CLI Tool] D --> F[Migration from JS]
Project Overview
We'll build a Task Management API with:
- User registration and authentication (JWT)
- CRUD operations for tasks
- Typed request/response validation
- Pagination and filtering
- Error handling middleware
- Integration tests
Project Structure
task-api/
prisma/
schema.prisma
src/
middleware/
auth.ts
error.ts
validate.ts
routes/
auth.ts
tasks.ts
services/
auth-service.ts
task-service.ts
types/
index.ts
utils/
jwt.ts
app.ts
server.ts
tests/
auth.test.ts
tasks.test.ts
package.json
tsconfig.json
Step 1: Project Setup
mkdir task-api && cd task-api
npm init -y
npm install express prisma @prisma/client zod jsonwebtoken bcryptjs cors dotenv
npm install --save-dev typescript @types/express @types/jsonwebtoken @types/bcryptjs @types/cors tsx vitest supertest @types/supertest
npx tsc --init
npx prisma init
Step 2: Prisma Schema
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
password String
name String
tasks Task[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Task {
id String @id @default(uuid())
title String
description String?
completed Boolean @default(false)
priority Int @default(0)
dueDate DateTime?
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
npx prisma migrate dev --name init
npx prisma generate
Step 3: Types and Validation
// src/types/index.ts
export interface JwtPayload {
userId: string;
email: string;
}
export interface AuthRequest {
email: string;
password: string;
name?: string;
}
export interface CreateTaskInput {
title: string;
description?: string;
priority?: number;
dueDate?: string;
}
export interface UpdateTaskInput {
title?: string;
description?: string;
completed?: boolean;
priority?: number;
dueDate?: string;
}
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
export const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(2).optional(),
});
export const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
export const createTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
priority: z.number().int().min(0).max(5).optional(),
dueDate: z.string().datetime().optional(),
});
export function validate(schema: z.ZodSchema) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: 'Validation failed',
details: result.error.flatten(),
});
return;
}
req.body = result.data;
next();
};
}
Step 4: Authentication Service
// src/utils/jwt.ts
import jwt from 'jsonwebtoken';
import { JwtPayload } from '../types';
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
export function signToken(payload: JwtPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' });
}
export function verifyToken(token: string): JwtPayload {
return jwt.verify(token, JWT_SECRET) as JwtPayload;
}
// src/services/auth-service.ts
import bcrypt from 'bcryptjs';
import { prisma } from '../lib/db';
import { signToken } from '../utils/jwt';
import { AuthRequest } from '../types';
export async function register(input: AuthRequest) {
const existing = await prisma.user.findUnique({
where: { email: input.email },
});
if (existing) {
throw new Error('Email already registered');
}
const hashedPassword = await bcrypt.hash(input.password, 10);
const user = await prisma.user.create({
data: {
email: input.email,
password: hashedPassword,
name: input.name || input.email.split('@')[0],
},
});
const token = signToken({ userId: user.id, email: user.email });
return {
token,
user: { id: user.id, email: user.email, name: user.name },
};
}
export async function login(input: { email: string; password: string }) {
const user = await prisma.user.findUnique({
where: { email: input.email },
});
if (!user) {
throw new Error('Invalid credentials');
}
const valid = await bcrypt.compare(input.password, user.password);
if (!valid) {
throw new Error('Invalid credentials');
}
const token = signToken({ userId: user.id, email: user.email });
return {
token,
user: { id: user.id, email: user.email, name: user.name },
};
}
Step 5: Auth Middleware
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { verifyToken } from '../utils/jwt';
import { JwtPayload } from '../types';
declare global {
namespace Express {
interface Request {
user?: JwtPayload;
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'No token provided' });
return;
}
try {
const token = authHeader.slice(7);
req.user = verifyToken(token);
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}
Step 6: Routes
// src/routes/auth.ts
import { Router, Request, Response } from 'express';
import { register, login } from '../services/auth-service';
import { validate, registerSchema, loginSchema } from '../middleware/validate';
const router = Router();
router.post('/register', validate(registerSchema), async (req: Request, res: Response) => {
try {
const result = await register(req.body);
res.status(201).json(result);
} catch (error) {
res.status(400).json({
error: error instanceof Error ? error.message : 'Registration failed',
});
}
});
router.post('/login', validate(loginSchema), async (req: Request, res: Response) => {
try {
const result = await login(req.body);
res.json(result);
} catch (error) {
res.status(401).json({
error: error instanceof Error ? error.message : 'Login failed',
});
}
});
export default router;
Step 7: Tests
// tests/auth.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import app from '../src/app';
describe('Auth Routes', () => {
it('registers a new user', async () => {
const response = await request(app)
.post('/api/auth/register')
.send({
email: 'test@example.com',
password: 'password123',
name: 'Test User',
});
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('token');
expect(response.body.user.email).toBe('test@example.com');
});
it('rejects duplicate email registration', async () => {
await request(app)
.post('/api/auth/register')
.send({ email: 'dup@example.com', password: 'password123' });
const response = await request(app)
.post('/api/auth/register')
.send({ email: 'dup@example.com', password: 'password123' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Email already registered');
});
});
Common Mistakes
1. Not using environment variables for secrets
Hardcoding JWT_SECRET in source is a security risk. Always use .env files and process.env.
2. Forgetting CORS configuration
Browser clients hitting the API from different origins will be blocked. Configure cors() middleware.
3. Not hashing passwords
Storing plaintext passwords is unacceptable. Use bcryptjs with salt rounds of at least 10.
4. No request rate limiting
Public APIs need rate limiting to prevent abuse. Use express-rate-limit package.
5. Insufficient error handling in async routes
Every async route handler needs try-catch or the asyncHandler wrapper. Unhandled rejections crash the server.
6. Not validating pagination inputs
Accepting ?page=-1&limit=10000 without validation can crash the database. Always validate and clamp pagination.
7. No input sanitization
Even with Zod, consider escaping output or trimming strings. Zod's .trim() is useful for user input.
Practice Questions
Why use Zod for validation instead of TypeScript types alone? TypeScript types are erased at runtime. Zod validates data at runtime while inferring the type — a single source of truth.
How does JWT authentication work in this project? User logs in → server creates JWT with userId and email → client stores token → sends as Bearer header → middleware verifies → route gets
req.user.What's the purpose of the
authenticatemiddleware? It extracts the JWT from the Authorization header, verifies it, and attaches the decoded user info toreq.userfor downstream route handlers.How do you test authenticated routes? First register/login to get a token, then pass it as
Authorization: Bearer <token>in subsequent request headers.Why use SQLite for development and PostgreSQL for production? SQLite requires zero setup and is perfect for local development. PostgreSQL handles concurrent connections better for production.
Challenge
Add these features to the Task API: task categories (many-to-many with Category model), task assignment (assign task to other users), search endpoint with full-text search, and paginated task listing with filters.
FAQ
Project Summary
Congratulations — you've built a complete, production-ready REST API with TypeScript! This project demonstrates:
- Type-safe database access with Prisma
- Request validation with Zod
- JWT authentication
- Typed error handling
- Integration tests
- Clean project structure
The full source code (~500 lines) is production-grade and ready to extend with real features.
What's Next
You've built a complete API with TypeScript. Now build a React dashboard for this API with {{< ref "56-project-react-dashboard" >}}, or create a CLI tool with {{< ref "57-project-cli-tool" >}}.
For strategies to convert existing JavaScript projects, see {{< ref "58-migration-from-js" >}}.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro