Back to blog
Building a Production-Grade Video Platform Backend with TypeScript
How I architected and built a YouTube-like backend API with JWT authentication, file uploads, social features, and auto-generated documentation.
26 min read·Talha Bilal
Share:

Introduction
Modern video platforms require sophisticated backend systems that handle authentication, media management, social interactions, and real-time analytics. While building CRUD APIs is straightforward, creating a production-ready system that scales, maintains data integrity, and provides excellent developer experience demands careful architectural decisions.
This case study documents how I built Bilal Tube, a comprehensive backend API for a YouTube-like video-sharing platform. The project demonstrates production-grade TypeScript development with strict type safety, validation-first request handling, automated API documentation, and a dual-token JWT authentication strategy.
The Challenge
The goal was to build a backend system that goes beyond basic CRUD operations and demonstrates professional engineering practices suitable for real-world applications. The system needed to handle:
- Secure user authentication with session management
- Large file uploads to cloud storage with cleanup strategies
- Complex social features (comments on videos and tweets, likes, subscriptions, playlists)
- Multi-entity relationships with optimized database queries
- Type-safe request validation that stays synchronized with API documentation
- Production deployment with Docker containerization
The technical requirements emphasized maintainability, developer experience, and architectural clarity over quick prototyping.
Technology Decisions
TypeScript with Strict Mode
I chose TypeScript with the strictest compiler settings to catch errors at compile time rather than runtime:
typescript
1// tsconfig.json2{3 "compilerOptions": {4 "strict": true,5 "noUncheckedIndexedAccess": true,6 "exactOptionalPropertyTypes": true,7 "target": "esnext",8 "module": "nodenext"9 }10}This configuration forces explicit handling of potential
undefined values and eliminates entire classes of runtime errors. The noUncheckedIndexedAccess setting, in particular, prevents common bugs when accessing array or object properties by index.Express 5 with ES Modules
Express 5 was selected for its maturity and middleware ecosystem. The project uses ES modules (
"type": "module" in package.json) for better tree-shaking and alignment with modern JavaScript standards:typescript
1// All imports require .js extensions2import { User } from "../models/user.model.js";3import { authMiddleware } from "../middlewares/auth.middleware.js";This future-proofs the codebase as the Node.js ecosystem continues migrating to ESM.
MongoDB with Mongoose
MongoDB's document model fits naturally with JavaScript/TypeScript and provides flexibility for evolving schemas. Mongoose adds TypeScript interfaces, validation, and middleware hooks:
typescript
1interface IUser {2 username: string;3 email: string;4 fullName: string;5 avatar: IImageAsset;6 coverImage: IImageAsset;7 watchHistory: Types.ObjectId[];8 password: string;9 refreshToken?: string;10}11
12const userSchema = new Schema<IUser>(/* ... */);Database indexes on
username, email, and fullName optimize frequently executed queries.Zod for Validation and Documentation
Rather than maintaining separate validation logic and API documentation, I used Zod with
@asteasolutions/zod-to-openapi to generate OpenAPI specifications directly from validation schemas:typescript
1export const registerUserSchema = z.object({2 username: z3 .string()4 .trim()5 .min(3, "Username must be at least 3 characters")6 .max(30, "Username must not exceed 30 characters")7 .transform((value) => value.toLowerCase()),8 email: z9 .string()10 .trim()11 .email("Invalid email format")12 .transform((value) => value.toLowerCase()),13 // ...14});This schema validates incoming requests, transforms data (trimming whitespace, lowercasing), and auto-generates the OpenAPI documentation. Changes to validation rules automatically update the API docs, eliminating documentation drift.
Cloudinary for Media Storage
For production file handling, I integrated Cloudinary rather than storing files on the server filesystem:
- Automatic format optimization and quality adjustment
- CDN delivery for global performance
- No disk space management concerns
- Asset versioning and transformations
The implementation includes cleanup strategies to prevent orphaned files when database writes fail.
Argon2 for Password Hashing
Instead of bcrypt, I used Argon2, the winner of the Password Hashing Competition:
typescript
1userSchema.pre("save", async function () {2 if (!this.isModified("password")) return;3 this.password = await argon.hash(this.password);4});5
6userSchema.methods.isValidPassword = async function (password: string) {7 return await argon.verify(this.password, password);8};Argon2 provides better resistance against GPU-based attacks and is recommended by security experts for new applications.
Architecture Overview
The system follows a strict layered architecture with validation-first request handling:
Diagram Placeholder
TODO: Add a Mermaid flowchart showing: Client Request → Route → Auth Middleware → File Upload Middleware → Controller → Zod Validation → Business Logic → Database → Response → Error Handler
Request Flow
Every request passes through this pipeline:
- Route Definition (
src/routes/*.routes.ts) - Maps HTTP method and path to handler - Authentication Middleware (if protected) - Verifies JWT and attaches user to
req.user - File Upload Middleware (if multipart) - Parses form-data to
req.fileorreq.files - Controller (
src/controllers/*.controller.ts) - Validates with Zod schema - Business Logic - Interacts with Mongoose models
- Database Operations - Queries or aggregations on MongoDB
- Response - JSON with appropriate status code
- Error Handler - Global error catching and formatting
This pipeline ensures that invalid requests never reach business logic, reducing defensive programming in controllers.
Folder Organization
The codebase is organized by domain rather than by layer:
text
1src/2├── config/ # Database and environment configuration3├── controllers/ # Request handlers (user, video, tweet, etc.)4├── docs/ # OpenAPI schema generation and Swagger setup5├── middlewares/ # Auth, file upload, error handling6├── models/ # Mongoose schemas (user, video, tweet, etc.)7├── routes/ # API endpoint definitions8├── schema/ # Zod validation schemas9├── types/ # TypeScript type definitions10└── utils/ # Cloudinary, JWT generation, error classesEach domain (user, video, tweet, comment, etc.) has its own route, controller, schema, and model files. Related functionality is co-located, making it easier to understand and modify feature boundaries.
Authentication System
Dual-Token JWT Strategy
The authentication system uses two tokens with different lifespans and purposes:
Access Token:
- Short-lived (1 day default)
- Contains user identification:
_id,email,username - Sent via httpOnly cookie or
Authorization: Bearerheader - Used for authenticating API requests
Refresh Token:
- Longer-lived (7 days default)
- Contains minimal payload:
_idonly - Stored in MongoDB
refreshTokenfield - Used to generate new access tokens when expired
This strategy balances security and user experience. Short access token lifespans limit the window of vulnerability if a token is compromised. Refresh tokens allow users to maintain sessions without frequent re-authentication.
Login Flow
typescript
1// 1. User sends credentials2const user = await User.findOne({ email });3if (!user || !(await user.isValidPassword(password))) {4 throw new ApiError(401, "Invalid credentials");5}6
7// 2. Generate both tokens8const accessToken = generateAccessToken(user);9const refreshToken = generateRefreshToken(user);10
11// 3. Store refresh token in database12user.refreshToken = refreshToken;13await user.save();14
15// 4. Set httpOnly cookies16res.cookie("accessToken", accessToken, {17 httpOnly: true,18 secure: process.env.NODE_ENV === "production",19 sameSite: "strict",20});21res.cookie("refreshToken", refreshToken, { /* ... */ });22
23// 5. Return user details24return res.status(200).json({ user, accessToken, refreshToken });Diagram Placeholder
TODO: Add a Mermaid sequence diagram showing: Client → Login Request → Server validates credentials → Generate tokens → Store refresh token in DB → Set httpOnly cookies → Return user data
Authentication Middleware
Protected routes use
authMiddleware to verify tokens:typescript
1export const authMiddleware = async (req, res, next) => {2 // Extract from cookie or Authorization header3 const accessToken = 4 req.cookies.accessToken || 5 req.headers.authorization?.split(" ")[1];6
7 if (!accessToken) {8 return res.status(401).json({ message: "Unauthorized" });9 }10
11 // Verify and decode12 const decoded = jwt.verify(accessToken, ENV.ACCESS_TOKEN_SECRET);13
14 // Fetch user and attach to request15 const user = await User.findById(decoded._id).select("-password -refreshToken");16 req.user = {17 _id: user._id,18 username: user.username,19 email: user.email,20 fullName: user.fullName,21 avatar: user.avatar,22 coverImage: user.coverImage,23 };24
25 next();26};The middleware populates
req.user so controllers can access authenticated user information without additional database queries.File Upload System
Upload Pipeline
File uploads use a three-stage pipeline: local storage → cloud upload → database reference:
- Multer parses multipart/form-data and saves files to
./public/temp - Controller validates request body and file presence
- Cloudinary uploads file with automatic optimization
- Database stores Cloudinary URL and public_id
- Cleanup deletes local temp file
typescript
1export const publishAVideo = async (req, res, next) => {2 let uploadedVideo = null;3 let uploadedThumbnail = null;4
5 try {6 // Multer has already saved files to req.files7 const videoFile = req.files?.video?.[0];8 const thumbnailFile = req.files?.thumbnail?.[0];9
10 if (!videoFile || !thumbnailFile) {11 throw new ApiError(400, "Video and thumbnail are required");12 }13
14 // Validate request body with Zod15 const parsedBody = publishVideoSchema.safeParse(req.body);16 if (!parsedBody.success) {17 throw new ApiError(400, parsedBody.error.message);18 }19
20 // Upload to Cloudinary21 uploadedVideo = await cloudinaryUpload(videoFile.path);22 uploadedThumbnail = await cloudinaryUpload(thumbnailFile.path);23
24 // Create database record25 const video = await Video.create({26 videoFile: {27 url: uploadedVideo.secure_url,28 public_id: uploadedVideo.public_id,29 },30 thumbnail: {31 url: uploadedThumbnail.secure_url,32 public_id: uploadedThumbnail.public_id,33 },34 title: parsedBody.data.title,35 description: parsedBody.data.description,36 duration: parsedBody.data.duration,37 owner: req.user._id,38 });39
40 // If database write fails, cleanup uploaded assets41 if (!video) {42 await deleteFileFromCloudinary(uploadedVideo.public_id, "video");43 await deleteFileFromCloudinary(uploadedThumbnail.public_id, "image");44 throw new ApiError(500, "Failed to create video");45 }46
47 return res.status(201).json(video);48 } catch (error) {49 // Cleanup on any error50 if (uploadedVideo) {51 await deleteFileFromCloudinary(uploadedVideo.public_id, "video");52 }53 if (uploadedThumbnail) {54 await deleteFileFromCloudinary(uploadedThumbnail.public_id, "image");55 }56 next(error);57 }58};Cleanup Strategy
The system prevents orphaned files in Cloudinary:
- On update: Old asset is deleted before uploading new one
- On delete: Asset is removed from Cloudinary, then database record is deleted
- On error: If database write fails after upload, newly uploaded assets are deleted
This ensures consistency between database references and cloud storage, preventing storage bloat from failed transactions.
Multer Configuration
typescript
1const storage = multer.diskStorage({2 destination: (req, file, cb) => cb(null, "./public/temp"),3 filename: (req, file, cb) => cb(null, file.originalname),4});5
6export const upload = multer({7 storage: storage,8 limits: { fileSize: 50 * 1024 * 1024 }, // 50MB9});Files are temporarily stored locally because Cloudinary requires file paths for upload. The
public/temp directory serves as a staging area.Database Design
Schema Relationships
The system models complex relationships between seven entities:
Diagram Placeholder
TODO: Add a Mermaid ER diagram showing relationships between User, Video, Tweet, Comment, Like, Subscription, and Playlist entities
User Model:
- Has many videos (as owner)
- Has many tweets (as owner)
- Has many playlists (as owner)
- Has many subscriptions (as subscriber or channel)
- Tracks watch history (array of video IDs)
Video Model:
- Belongs to one user (owner)
- Has many comments
- Has many likes
- Can be in multiple playlists
Comment Model (Polymorphic):
- Can belong to either a Video or Tweet using
refPath - Belongs to one user (owner)
- Has many likes
typescript
1const commentSchema = new Schema({2 content: { type: String, required: true },3 contentId: { type: Schema.Types.ObjectId, required: true, refPath: "contentType" },4 contentType: { type: String, required: true, enum: ["Video", "Tweet"] },5 owner: { type: Schema.Types.ObjectId, ref: "User", required: true },6});The
refPath pattern allows a single Comment model to reference either videos or tweets, reducing schema duplication.Like Model (Polymorphic):
- Can reference a video, comment, or tweet (all optional fields)
- Belongs to one user (likedBy)
typescript
1const likeSchema = new Schema({2 video: { type: Schema.Types.ObjectId, ref: "Video" },3 comment: { type: Schema.Types.ObjectId, ref: "Comment" },4 tweet: { type: Schema.Types.ObjectId, ref: "Tweet" },5 likedBy: { type: Schema.Types.ObjectId, ref: "User" },6});This design uses optional fields rather than separate Like models for each content type, simplifying queries.
Aggregation Pipelines
Complex queries use MongoDB aggregation pipelines for efficient data retrieval:
typescript
1// Get channel statistics2const stats = await Video.aggregate([3 { $match: { owner: mongoose.Types.ObjectId(channelId) } },4 {5 $group: {6 _id: null,7 totalVideos: { $sum: 1 },8 totalViews: { $sum: "$views" },9 },10 },11]);12
13const subscriberCount = await Subscription.countDocuments({14 channel: channelId,15});16
17const totalLikes = await Like.countDocuments({18 video: { $in: videoIds },19});Aggregations reduce database round-trips by performing calculations server-side rather than fetching all documents and computing in application code.
Indexing Strategy
Frequently queried fields have database indexes:
typescript
1const userSchema = new Schema({2 username: { type: String, unique: true, index: true, lowercase: true },3 email: { type: String, unique: true, lowercase: true },4 fullName: { type: String, index: true },5});The
username and email unique indexes enforce data integrity and optimize login queries. The fullName index speeds up search operations.API Design
RESTful Endpoint Structure
The API follows REST conventions with semantic URL design:
Users:
POST /api/v1/users/register- Create accountPOST /api/v1/users/login- AuthenticateGET /api/v1/users/logout- End sessionGET /api/v1/users/getCurrentUser- Get authenticated userPOST /api/v1/users/updateAvatar- Upload new avatarGET /api/v1/users/:username/profile- Get public profile
Videos:
POST /api/v1/videos- Publish videoGET /api/v1/videos- List with pagination/search/sortGET /api/v1/videos/:videoId- Get detailsPATCH /api/v1/videos/:videoId- Update metadataDELETE /api/v1/videos/:videoId- Remove videoPATCH /api/v1/videos/toggle/publish/:videoId- Toggle visibilityPATCH /api/v1/videos/views/:videoId- Increment view count
Likes (Toggle Pattern):
POST /api/v1/likes/toggle/v/:videoId- Toggle video likePOST /api/v1/likes/toggle/c/:commentId- Toggle comment likePOST /api/v1/likes/toggle/t/:tweetId- Toggle tweet like
The toggle pattern simplifies client logic—a single endpoint handles both liking and unliking based on current state:
typescript
1export const toggleVideoLike = async (req, res) => {2 const existingLike = await Like.findOne({3 video: videoId,4 likedBy: req.user._id,5 });6
7 if (existingLike) {8 await Like.deleteOne({ _id: existingLike._id });9 return res.status(200).json({ liked: false });10 }11
12 await Like.create({ video: videoId, likedBy: req.user._id });13 return res.status(200).json({ liked: true });14};Pagination and Filtering
List endpoints support pagination, search, and sorting through query parameters:
typescript
1const getAllVideosQuerySchema = z.object({2 page: z.coerce.number().int().positive().default(1),3 limit: z.coerce.number().int().positive().default(10),4 query: z.string().trim().default(""),5 sortBy: z.enum(["createdAt", "views", "title"]).default("createdAt"),6 sortType: z.enum(["asc", "desc"]).default("desc"),7 userId: z.string().optional(),8});Query string parameters are coerced to correct types and validated:
text
1GET /api/v1/videos?page=2&limit=20&query=tutorial&sortBy=views&sortType=descThe controller builds a MongoDB filter and applies pagination:
typescript
1const { page, limit, query, sortBy, sortType, userId } = parsedQuery.data;2const skip = (page - 1) * limit;3
4const filter = {};5if (query) {6 filter.$or = [7 { title: { $regex: query, $options: "i" } },8 { description: { $regex: query, $options: "i" } },9 ];10}11if (userId) {12 filter.owner = userId;13}14
15const videos = await Video.find(filter)16 .skip(skip)17 .limit(limit)18 .sort({ [sortBy]: sortType === "asc" ? 1 : -1 });Validation-First Controllers
Controllers validate all inputs before executing business logic:
typescript
1export const updateVideo = async (req, res, next) => {2 try {3 // Validate URL parameter4 const parsedParams = videoIdParamSchema.safeParse(req.params);5 if (!parsedParams.success) {6 throw new ApiError(400, parsedParams.error.message);7 }8
9 // Validate request body10 const parsedBody = updateVideoSchema.safeParse(req.body);11 if (!parsedBody.success) {12 throw new ApiError(400, parsedBody.error.message);13 }14
15 const { videoId } = parsedParams.data;16 const { title, description } = parsedBody.data;17
18 // Business logic19 const video = await Video.findById(videoId);20 if (!video) {21 throw new ApiError(404, "Video not found");22 }23
24 if (video.owner.toString() !== req.user._id) {25 throw new ApiError(403, "Not authorized to update this video");26 }27
28 video.title = title;29 video.description = description;30 await video.save();31
32 return res.status(200).json(video);33 } catch (error) {34 next(error);35 }36};Failed validation throws detailed errors before database queries execute, improving performance and security.
Automated API Documentation
OpenAPI Generation from Schemas
The Zod schemas used for validation are registered in
src/docs/openapi.ts to generate OpenAPI 3.0 specifications:typescript
1import { extendZodWithOpenApi, OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";2import { z } from "zod";3
4extendZodWithOpenApi(z);5
6const registry = new OpenAPIRegistry();7
8registry.register("RegisterUserSchema", registerUserSchema);9registry.register("LoginUserSchema", loginUserSchema);10registry.register("PublishVideoSchema", publishVideoSchema);11// ... all schemas registeredWhen the server starts, it generates the OpenAPI document and serves it at
/openapi.json. Swagger UI at /api-docs consumes this document for interactive testing.Screenshot Placeholder
TODO: Add screenshot of the Swagger UI interface showing the /api/v1/videos endpoint with request/response schemas
Benefits of Schema-Driven Documentation
This approach provides several advantages:
- Single source of truth - Validation and documentation never drift apart
- Type safety - TypeScript infers request/response types from Zod schemas
- Automatic updates - Changing a schema updates both validation and docs
- Interactive testing - Swagger UI allows developers to test endpoints directly
- Client generation - OpenAPI specs can generate SDK clients automatically
Traditional approaches require manually writing OpenAPI YAML files and keeping them synchronized with code. Schema-driven generation eliminates this maintenance burden.
Error Handling
Custom Error Class
A custom
ApiError class provides structured error responses:typescript
1export class ApiError extends Error {2 statusCode: number;3 success: boolean;4 errors: unknown[];5
6 constructor(statusCode = 500, message = "Something went wrong", errors = []) {7 super(message);8 this.statusCode = statusCode;9 this.success = false;10 this.errors = errors;11 Error.captureStackTrace(this, this.constructor);12 }13}Controllers throw
ApiError with appropriate status codes:typescript
1if (!video) {2 throw new ApiError(404, "Video not found");3}4
5if (video.owner.toString() !== req.user._id) {6 throw new ApiError(403, "Not authorized to delete this video");7}Global Error Handler
A centralized error handler middleware catches all errors and formats responses consistently:
typescript
1export const errorHandler = (err, req, res, next) => {2 const statusCode = err.statusCode || 500;3
4 res.status(statusCode).json({5 success: false,6 message: err.message || "Internal Server Error",7 });8
9 if (process.env.NODE_ENV !== "production") {10 console.error(err.stack);11 }12};This middleware is registered last in the Express middleware chain (
app.use(errorHandler)), ensuring it catches errors from all routes.Validation Error Formatting
Zod validation errors are formatted to provide field-specific feedback:
typescript
1const parsedBody = registerUserSchema.safeParse(req.body);2if (!parsedBody.success) {3 const message = parsedBody.error.issues4 .map((issue) => issue.message)5 .join(", ");6 throw new ApiError(400, message);7}This produces user-friendly error messages:
json
1{2 "success": false,3 "message": "Username must be at least 3 characters, Invalid email format"4}Environment Configuration
Environment Variable Parsing
The
env.config.ts module handles environment variables with quote trimming:typescript
1const parseEnv = (value: string | undefined): string | undefined => {2 if (!value) return value;3
4 const trimmed = value.trim();5 const startsWithQuote = trimmed.startsWith('"') || trimmed.startsWith("'");6 const endsWithQuote = trimmed.endsWith('"') || trimmed.endsWith("'");7
8 return startsWithQuote && endsWithQuote9 ? trimmed.slice(1, -1).trim()10 : trimmed;11};12
13export const ENV = {14 PORT: Number(process.env.PORT) || 3000,15 MONGO_URI: parseEnv(process.env.MONGO_URI),16 ACCESS_TOKEN_SECRET: parseEnv(process.env.ACCESS_TOKEN_SECRET),17 // ...18};This allows
.env files to use both quoted and unquoted values:env
1MONGO_URI="mongodb://localhost:27017/bilal-tube"2PORT=3000Required Configuration
The system validates critical environment variables at startup:
typescript
1if (!ENV.CLOUDINARY_API_KEY || !ENV.CLOUDINARY_API_SECRET || !ENV.CLOUD_NAME) {2 throw new Error("Cloudinary configuration is missing");3}4
5if (!ENV.ACCESS_TOKEN_SECRET) {6 throw new Error("ACCESS_TOKEN_SECRET is not defined");7}This fail-fast approach prevents the server from starting with incomplete configuration, avoiding runtime errors.
Docker Deployment
Multi-Stage Build
The Dockerfile uses a multi-stage build to minimize production image size:
dockerfile
1# Stage 1: Base - Setup Node.js and pnpm2FROM node:22-bookworm-slim AS base3ENV PNPM_HOME="/pnpm"4ENV PATH="$PNPM_HOME:$PATH"5RUN corepack enable6
7# Stage 2: Dependencies - Install packages8FROM base AS deps9WORKDIR /app10COPY package.json pnpm-lock.yaml ./11RUN pnpm install --frozen-lockfile12
13# Stage 3: Build - Compile TypeScript14FROM deps AS build15WORKDIR /app16COPY tsconfig.json ./17COPY src ./src18COPY public ./public19RUN pnpm build20RUN pnpm prune --prod21
22# Stage 4: Runner - Minimal production image23FROM node:22-bookworm-slim AS runner24WORKDIR /app25ENV NODE_ENV=production26COPY --from=build /app/package.json ./package.json27COPY --from=build /app/node_modules ./node_modules28COPY --from=build /app/dist ./dist29COPY --from=build /app/public ./public30RUN useradd -m bilatube31USER bilatube32EXPOSE 300033CMD ["node", "dist/index.js"]The final runner stage contains only production dependencies and compiled JavaScript, reducing image size and attack surface.
Security Considerations
The production container runs as a non-root user:
dockerfile
1RUN useradd -m bilatube2USER bilatubeRunning as root inside containers is a security risk. If an attacker compromises the application, they have root-level access to the container. Running as an unprivileged user limits potential damage.
Docker Networking
When MongoDB runs on the host machine, the container uses
host.docker.internal:env
1MONGO_URI=mongodb://host.docker.internal:27017/bilal-tubeOn Linux systems where this may not work, the Docker bridge network IP can be used:
bash
1# Find Docker bridge IP2ip addr show docker0 | grep "inet\b" | awk '{print $2}' | cut -d/ -f13
4# Use in .env5MONGO_URI=mongodb://172.17.0.1:27017/bilal-tubeTechnical Challenges and Solutions
Challenge: Cloudinary Orphaned Files
Problem: If a database write fails after uploading files to Cloudinary, orphaned assets remain in cloud storage, consuming space and incurring costs.
Solution: Wrap file uploads and database writes in try-catch blocks with cleanup logic:
typescript
1try {2 uploadedVideo = await cloudinaryUpload(videoFile.path);3 uploadedThumbnail = await cloudinaryUpload(thumbnailFile.path);4
5 const video = await Video.create({ /* ... */ });6
7 if (!video) {8 await deleteFileFromCloudinary(uploadedVideo.public_id, "video");9 await deleteFileFromCloudinary(uploadedThumbnail.public_id, "image");10 throw new ApiError(500, "Failed to create video");11 }12} catch (error) {13 if (uploadedVideo) {14 await deleteFileFromCloudinary(uploadedVideo.public_id, "video");15 }16 if (uploadedThumbnail) {17 await deleteFileFromCloudinary(uploadedThumbnail.public_id, "image");18 }19 throw error;20}This ensures consistency between database references and cloud storage.
Challenge: Polymorphic Comments
Problem: Comments can belong to either videos or tweets. Creating separate
VideoComment and TweetComment models duplicates code and complicates queries.Solution: Use Mongoose
refPath for dynamic references:typescript
1const commentSchema = new Schema({2 content: String,3 contentId: { type: Schema.Types.ObjectId, refPath: "contentType" },4 contentType: { type: String, enum: ["Video", "Tweet"] },5 owner: { type: Schema.Types.ObjectId, ref: "User" },6});When populating:
typescript
1const comments = await Comment.find({ contentType: "Video", contentId: videoId })2 .populate("owner")3 .populate("contentId");Mongoose resolves
contentId to the correct model based on contentType.Challenge: Token Expiry Handling
Problem: Access tokens expire quickly for security. Forcing users to log in repeatedly degrades experience.
Solution: Implement refresh token rotation:
- Client detects expired access token (401 response)
- Client sends refresh token to
/api/v1/users/refresh-token - Server verifies refresh token and generates new access token
- New access token is returned to client
- Client retries original request with new access token
typescript
1export const refreshAccessToken = async (req, res) => {2 const incomingRefreshToken = 3 req.cookies.refreshToken || req.body.refreshToken;4
5 if (!incomingRefreshToken) {6 throw new ApiError(401, "Refresh token is required");7 }8
9 const decoded = jwt.verify(incomingRefreshToken, ENV.REFRESH_TOKEN_SECRET);10 const user = await User.findById(decoded._id);11
12 if (!user || user.refreshToken !== incomingRefreshToken) {13 throw new ApiError(401, "Invalid or expired refresh token");14 }15
16 const newAccessToken = generateAccessToken(user);17 const newRefreshToken = generateRefreshToken(user);18
19 user.refreshToken = newRefreshToken;20 await user.save();21
22 res.cookie("accessToken", newAccessToken, { /* ... */ });23 res.cookie("refreshToken", newRefreshToken, { /* ... */ });24
25 return res.status(200).json({ accessToken: newAccessToken });26};This maintains security while minimizing login friction.
Challenge: Environment Variable Quotes
Problem: Different deployment platforms handle
.env files differently. Some require quoted values, others fail if values are quoted.Solution: Parse environment variables to strip quotes if present:
typescript
1const parseEnv = (value: string | undefined): string | undefined => {2 if (!value) return value;3 const trimmed = value.trim();4 if ((trimmed.startsWith('"') || trimmed.startsWith("'")) &&5 (trimmed.endsWith('"') || trimmed.endsWith("'"))) {6 return trimmed.slice(1, -1).trim();7 }8 return trimmed;9};This handles both formats:
env
1MONGO_URI=mongodb://localhost:27017/db2MONGO_URI="mongodb://localhost:27017/db"Performance Considerations
Database Query Optimization
Indexes are placed on frequently queried fields:
typescript
1// User model2username: { type: String, unique: true, index: true }3email: { type: String, unique: true }4fullName: { type: String, index: true }These indexes significantly improve query performance:
- Login queries by email: O(log n) with index vs O(n) full table scan
- Profile lookups by username: O(log n)
- Search by fullName: O(log n)
Aggregation Over Multiple Queries
Rather than fetching data in multiple round-trips:
typescript
1// Inefficient: 3+ database queries2const videos = await Video.find({ owner: channelId });3const totalViews = videos.reduce((sum, v) => sum + v.views, 0);4const subscriberCount = await Subscription.countDocuments({ channel: channelId });5const videoIds = videos.map(v => v._id);6const totalLikes = await Like.countDocuments({ video: { $in: videoIds } });Use aggregation pipelines:
typescript
1// Efficient: 1 aggregation query2const stats = await Video.aggregate([3 { $match: { owner: channelId } },4 {5 $lookup: {6 from: "likes",7 localField: "_id",8 foreignField: "video",9 as: "likes",10 },11 },12 {13 $group: {14 _id: null,15 totalVideos: { $sum: 1 },16 totalViews: { $sum: "$views" },17 totalLikes: { $sum: { $size: "$likes" } },18 },19 },20]);Aggregations reduce network latency and database load.
Pagination Limits
List endpoints enforce maximum page sizes:
typescript
1const limit = z.coerce.number().int().positive().max(100).default(10);Without limits, clients could request thousands of records in a single query, causing memory issues and slow responses.
Security Considerations
Password Security
Passwords are hashed with Argon2 before storage:
typescript
1userSchema.pre("save", async function () {2 if (!this.isModified("password")) return;3 this.password = await argon.hash(this.password);4});Passwords never appear in API responses:
typescript
1const user = await User.findById(userId).select("-password -refreshToken");httpOnly Cookies
Tokens are set as httpOnly cookies when possible:
typescript
1res.cookie("accessToken", accessToken, {2 httpOnly: true,3 secure: process.env.NODE_ENV === "production",4 sameSite: "strict",5});httpOnly prevents JavaScript from accessing cookies, mitigating XSS attacks. The
secure flag ensures cookies are only sent over HTTPS in production.Input Validation
All user inputs are validated before processing:
typescript
1const parsedBody = registerUserSchema.safeParse(req.body);2if (!parsedBody.success) {3 throw new ApiError(400, parsedBody.error.message);4}This prevents injection attacks, malformed data, and unexpected behavior.
Authorization Checks
Controllers verify ownership before allowing modifications:
typescript
1const video = await Video.findById(videoId);2if (video.owner.toString() !== req.user._id) {3 throw new ApiError(403, "Not authorized to update this video");4}Without this check, any authenticated user could modify any video.
CORS Configuration
CORS is configured based on environment:
typescript
1const clientUrl = process.env.CLIENT_URL?.trim() || "*";2app.use(cors({3 origin: clientUrl === "*" ? true : clientUrl,4 credentials: clientUrl !== "*",5}));In development,
CLIENT_URL=* allows all origins. In production, a specific origin is required.Developer Experience
Type Safety
TypeScript strict mode catches errors at compile time:
typescript
1// Error: Property 'user' does not exist on type 'Request'2const userId = req.user._id;Custom type extensions fix this:
typescript
1// src/types/express.d.ts2declare global {3 namespace Express {4 interface Request {5 user?: {6 _id: string;7 username: string;8 email: string;9 // ...10 };11 }12 }13}Now
req.user has full type checking and autocomplete.Auto-Generated Documentation
Developers can test endpoints interactively without reading code or writing curl commands:
Screenshot Placeholder
TODO: Add screenshot of Swagger UI showing the "Try it out" feature for testing the POST /api/v1/videos endpoint
The Swagger UI provides:
- Request body schemas with example values
- Response schemas with status codes
- Authentication testing with JWT tokens
- Immediate feedback on validation errors
Development Scripts
bash
1pnpm dev # Run with hot reload2pnpm build # Compile TypeScript3pnpm start # Run production build4pnpm lint # Format code with PrettierThe
tsx development server watches for changes and restarts automatically, eliminating manual server restarts.Error Messages
Validation errors provide specific feedback:
json
1{2 "success": false,3 "message": "Username must be at least 3 characters, Password must be at least 8 characters"4}Rather than generic "Invalid input" messages, developers and users receive actionable feedback.
Lessons Learned
Validation as Documentation Source
Using Zod schemas for both validation and OpenAPI generation proved highly effective. Changes to validation rules automatically update documentation, eliminating drift. The TypeScript type inference from Zod schemas provides compile-time safety without manual type definitions.
Tradeoff: Initial setup requires more boilerplate than simple Express validators. The long-term maintainability benefits outweigh the upfront cost.
Polymorphic Models
Mongoose
refPath simplifies polymorphic relationships. A single Comment model handles both video and tweet comments without code duplication.Tradeoff: Dynamic references complicate some queries and reduce type safety compared to separate models. For this use case, the code reuse benefits justify the complexity.
Dual-Token Strategy
The access/refresh token pattern balances security and user experience well. Short-lived access tokens limit vulnerability windows, while refresh tokens allow seamless session extension.
Tradeoff: Implementing refresh token rotation adds complexity to both backend and frontend. For production applications, this complexity is justified by improved security.
Error Handling with Custom Classes
A centralized error handler with custom error classes ensures consistent error responses across all endpoints.
Tradeoff: Controllers must explicitly throw
ApiError rather than letting Express handle errors automatically. This explicitness improves clarity but requires discipline.Docker Multi-Stage Builds
Multi-stage builds significantly reduce production image size by excluding build tools and dev dependencies.
Tradeoff: Build times increase due to multiple stages. For production deployments, smaller images justify longer build times.
Future Improvements
While the current implementation is production-ready, several enhancements would improve the system:
Rate Limiting
Implement request rate limiting to prevent abuse:
typescript
1import rateLimit from "express-rate-limit";2
3const limiter = rateLimit({4 windowMs: 15 * 60 * 1000, // 15 minutes5 max: 100, // limit each IP to 100 requests per windowMs6});7
8app.use("/api/v1", limiter);Caching
Add Redis caching for frequently accessed data:
typescript
1// Cache video details for 5 minutes2const cachedVideo = await redis.get(`video:${videoId}`);3if (cachedVideo) {4 return res.json(JSON.parse(cachedVideo));5}6
7const video = await Video.findById(videoId);8await redis.setex(`video:${videoId}`, 300, JSON.stringify(video));Background Jobs
Move slow operations (video processing, email sending) to background jobs using Bull or BullMQ:
typescript
1import Queue from "bull";2
3const videoProcessingQueue = new Queue("video-processing", {4 redis: { host: "localhost", port: 6379 },5});6
7videoProcessingQueue.process(async (job) => {8 const { videoId } = job.data;9 // Process video, generate thumbnails, etc.10});Search Engine
Integrate Elasticsearch or Algolia for full-text search:
typescript
1// Instead of MongoDB regex2const results = await elasticsearchClient.search({3 index: "videos",4 body: {5 query: {6 multi_match: {7 query: searchTerm,8 fields: ["title^3", "description", "tags"],9 },10 },11 },12});WebSocket Notifications
Add real-time notifications using Socket.IO:
typescript
1io.on("connection", (socket) => {2 socket.on("subscribe", (userId) => {3 socket.join(`user:${userId}`);4 });5});6
7// Emit when new comment is added8io.to(`user:${videoOwnerId}`).emit("new-comment", commentData);Test Suite
Add unit and integration tests:
typescript
1describe("Video Controller", () => {2 it("should publish video with valid data", async () => {3 const res = await request(app)4 .post("/api/v1/videos")5 .set("Authorization", `Bearer ${accessToken}`)6 .attach("video", videoFile)7 .attach("thumbnail", thumbnailFile)8 .field("title", "Test Video")9 .field("description", "Test Description")10 .field("duration", "120");11
12 expect(res.status).toBe(201);13 expect(res.body).toHaveProperty("title", "Test Video");14 });15});Key Takeaways
-
Validation-first architecture — Using Zod for both validation and documentation generation eliminates schema drift and improves type safety. The upfront investment in schema definitions pays long-term dividends in maintainability.
-
Security by default — Argon2 password hashing, httpOnly cookies, strict CORS, input validation, and authorization checks protect against common vulnerabilities. Security must be designed into the architecture, not added as an afterthought.
-
Developer experience matters — Auto-generated API documentation, TypeScript strict mode, clear error messages, and development hot-reloading reduce friction and improve productivity. Production-grade systems should optimize for developer efficiency.
-
Resource cleanup strategies — File uploads require careful error handling to prevent orphaned assets. Always implement cleanup logic in try-catch blocks when database writes can fail after cloud uploads.
-
Architecture over abstraction — Domain-driven folder organization, validation-first request handling, and layered responsibility separation create maintainable systems. Clear architecture patterns are more valuable than premature abstractions.
Building production-ready backend systems requires thinking beyond features to consider security, maintainability, developer experience, and operational concerns. This project demonstrates that TypeScript, strict validation, and thoughtful architecture create reliable APIs that are pleasant to work with and easy to extend.
The complete source code, including all implementation details discussed in this case study, is available on GitHub.
Have questions about this architecture or interested in building a similar system? Reach out through the contact form.

