Skip to content

Build a REST API with TypeScript — Full Project Tutorial

DodaTech Updated 2026-06-28 8 min read

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

  1. 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.

  2. 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.

  3. What's the purpose of the authenticate middleware? It extracts the JWT from the Authorization header, verifies it, and attaches the decoded user info to req.user for downstream route handlers.

  4. How do you test authenticated routes? First register/login to get a token, then pass it as Authorization: Bearer <token> in subsequent request headers.

  5. 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

Should I use SQLite for production?

No. SQLite doesn't handle concurrent writes well. Use PostgreSQL or MySQL in production. Prisma makes swapping databases easy — just change the provider.

How do I deploy this API?

Build TypeScript to JavaScript (npx tsc), set environment variables, run migrations (npx prisma migrate deploy), and start the server. Deploy to Railway, Fly.io, or a VPS with PM2.

Is JWT secure for authentication?

JWT is secure when implemented correctly: use HTTPS, set short expiration (7 days max), store tokens securely (httpOnly cookies for web apps).

How do I handle file uploads?

Use multer with TypeScript (@types/multer). Store files in cloud storage (S3, Cloudflare R2) and save the URL in your database.

What about API documentation?

Use Swagger/OpenAPI with swagger-jsdoc and swagger-ui-express. Zod schemas can generate OpenAPI specs with zod-to-json-schema.

How do I add WebSocket support?

Use socket.io with TypeScript. Create a typed event system with interfaces for client and server events.

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