Roberto Tomé

ROBERTO TOMÉ

Complete Node.js Task Manager REST API Tutorial: Part 1: Development
Tutorials

Complete Node.js Task Manager REST API Tutorial: Part 1: Development

Complete Node.js Task Manager REST API Tutorial: Part 1: Development

This comprehensive tutorial will guide you through building a production-ready Task Manager REST API using modern Node.js tooling. You’ll learn everything from initial setup to deployment, following industry best practices and current recommendations. On part 1 will deal with the development side of things. Part 2 will be dedicated for setting up testing and deployment of our app.

1. Project Overview & Learning Objectives

What We’re Building: A complete Task Manager REST API that supports user authentication, CRUD operations for tasks, task categorization with tags, due dates, and proper security measures. This API will be production-ready with logging and error handling.

Learning Objectives:

  • Set up a modern Node.js project with proper structure and dependencies
  • Build RESTful APIs with Express.js using current best practices
  • Implement secure JWT-based authentication with password hashing
  • Design and manage databases using Prisma ORM with migrations
  • Add comprehensive input validation and error handling
  • Implement logging, security middleware, and rate limiting

2. Prerequisites & Initial Setup

Required Tools

  • Node.js 22 LTS (current recommended version as of 2025)
  • Git for version control
  • Basic PostgreSQL knowledge

Version Check

node -v    # Should show v22.x.x
npm -v     # Should show 10.x.x or higher
git --version

Project Initialization

# Create and initialize project
mkdir task-manager-api && cd task-manager-api
git init
npm init -y

# Install production dependencies
npm install express prisma @prisma/client bcryptjs jsonwebtoken dotenv cors helmet winston express-rate-limit express-validator

# Install development dependencies
npm install --save-dev nodemon eslint

Add these scripts to package.json:

  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js",
    "db:migrate": "prisma migrate dev",
    "db:generate": "prisma generate",
    "db:studio": "prisma studio"
  },

3. Project Structure

Create this opinionated folder structure for scalability and maintainability:

task-manager-api/
├── package.json
├── .env                    # Environment variables (never commit)
├── .env.example           # Template for environment variables
├── .env.test              # Tests environment variables
├── .gitignore
├── .eslintrc.js           # Linting configuration
├── jest.config.js         # Test configuration
├── Dockerfile             # Container definition
├── docker-compose.yml     # Multi-container setup
├── README.md
├── prisma/
│   ├── schema.prisma      # Database schema
│   └── migrations/        # Database migration files
├── src/
│   ├── index.js          # Server entry point
│   ├── app.js            # Express app configuration
│   ├── controllers/      # Request handlers
│   │   ├── authController.js
│   │   └── taskController.js
│   ├── services/         # Business logic
│   │   ├── authService.js
│   │   └── taskService.js
│   ├── middleware/       # Custom middleware
│   │   ├── auth.js
│   │   ├── validation.js
│   │   └── errorHandler.js
│   ├── routes/           # Route definitions
│   │   ├── auth.js
│   │   └── tasks.js
│   ├── utils/            # Helper functions
│   │   ├── logger.js
│   │   └── responses.js
│   ├── database/            # Centralized database module
│   │   ├── prisma.js
│   └── tests/            # Test files
│       ├── auth.test.js
│       └── tasks.test.js
└── .github/
    └── workflows/
        └── ci.yml        # GitHub Actions CI/CD

Folder Purpose:

  • src/controllers/: Handle HTTP requests and responses
  • src/services/: Contain business logic and database operations
  • src/middleware/: Custom Express middleware for auth, validation, etc.
  • src/routes/: Define API endpoints and attach middleware
  • src/utils/: Shared utilities like logging and response formatting
  • prisma/: Database schema and migration management

4. Step-by-Step Implementation

Step 1: Environment Configuration

Before we write any logic, we need a single source of truth for configuration. Things like database credentials, JWT secrets, or ports should never be hardcoded — both for security and flexibility. Using an .env file lets us keep sensitive data out of source control and change configurations without touching code.

Create .env.example:

# Server Configuration
NODE_ENV=development
PORT=3000

# Database
DATABASE_URL="neon-postgresql-bd-url"

# JWT Authentication
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=7d

# Logging
LOG_LEVEL=debug

# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100

Create your actual .env file:

cp .env.example .env
# Edit .env with your actual values

Security Note: Never commit .env files. Always use environment-specific values and strong secrets in production.

Step 2: Server Entry Point

This ensures DB connectivity before accepting traffic and cleans up resources on termination to avoid corrupted migrations or leaked DB connections.

Create src/index.js:

require("dotenv").config();
const app = require("./app");
const getPrismaClient = require("../database/prisma");
const logger = require("./utils/logger");

const prisma = getPrismaClient();
const PORT = process.env.PORT || 3000;

async function startServer() {
	try {
		// Test database connection
		await prisma.$connect();
		logger.info("Database connected successfully");

		// Start HTTP server
		const server = app.listen(PORT, () => {
			logger.info(`Server running on http://localhost:${PORT}`);
			logger.info(`Environment: ${process.env.NODE_ENV}`);
		});

		// Graceful shutdown handling
		process.on("SIGTERM", async () => {
			logger.info("SIGTERM received, shutting down gracefully");
			server.close(() => {
				prisma.$disconnect();
				process.exit(0);
			});
		});
	} catch (error) {
		logger.error("Failed to start server:", error);
		process.exit(1);
	}
}

startServer();

Step 3: Express App Configuration

Every Express app starts with an app.js — but what makes ours special is how it’s layered. This file acts like the “assembly line” where requests pass through a chain of middlewares:

  • Helmet adds safety gear (security headers).

  • CORS opens doors to trusted domains.

  • Rate limiting prevents overuse.

Finally, all routes get plugged in, and the app hands off errors to a centralized handler — just like a factory’s quality check at the end of the line.

Create src/app.js:

const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const logger = require("./utils/logger");
const errorHandler = require("./middleware/errorHandler");

// Import routes
const authRoutes = require("./routes/auth");
const taskRoutes = require("./routes/tasks");

const app = express();

// Security middleware
app.use(
	helmet({
		contentSecurityPolicy: {
			directives: {
				defaultSrc: ["'self'"],
				styleSrc: ["'self'", "'unsafe-inline'"],
			},
		},
	})
);

// CORS configuration
app.use(
	cors({
		origin:
			process.env.NODE_ENV === "production"
				? ["https://yourdomain.com"]
				: ["http://localhost:3000", "http://localhost:3001"],
		credentials: true,
	})
);

// Rate limiting
const limiter = rateLimit({
	windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
	max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
	message: {
		error: "Too many requests from this IP, please try again later.",
		retryAfter: Math.ceil(
			(parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 900000) / 1000
		),
	},
	standardHeaders: true,
	legacyHeaders: false,
});

app.use(limiter);

// Body parsing middleware
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true }));

// Request logging middleware
app.use((req, res, next) => {
	logger.info(`${req.method} ${req.path}`, {
		ip: req.ip,
		userAgent: req.get("User-Agent"),
		timestamp: new Date().toISOString(),
	});
	next();
});

// Health check endpoint
app.get("/healthz", (req, res) => {
	res.status(200).json({
		status: "ok",
		timestamp: new Date().toISOString(),
		environment: process.env.NODE_ENV,
	});
});

// API routes
app.use("/api/auth", authRoutes);
app.use("/api/tasks", taskRoutes);

// 404 handler
app.use((req, res) => {
	res.status(404).json({
		success: false,
		message: "Route not found",
		path: req.originalUrl,
	});
});

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

module.exports = app;

Expose /healthz so platforms like Docker, Kubernetes or Paas know when the service is ready. Keep it cheap and fast (no heavy queries).

Step 4: Setup PostgreSQL Database

For this project we’ll use Neon, a serverless, cloud-native implementation of PostgreSQL. If you don’t have an account sign-up and go to the projects page:

image-20251019212758564

Let’s create a new project: image-20251019212941108

After creating your project you should be directed to the dashboard page. For the purposes of this project we only need the database’s URL so we can use it on our app. We can access this information on the Connect to your database menu:

image-20251019165018866

Click the connection string with Copy snippet, the string should start with ‘postgresql://…’

Save the connection string in DATABASE_URL in the .env file.

This will allow to connect our project to our database when it’s time to create the project tables.

Step 5: Database Schema with Prisma

At this stage, we need a model of our world — users, tasks, tags. Prisma’s schema lets us describe these entities in plain English-like syntax and automatically generates type-safe accessors. You can think of it as the blueprint that both the database and our code will follow.

Create prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
  output        = "../generated/prisma"
  binaryTargets = ["native", "linux-musl-openssl-3.0.x"] //Solves the issue with the binary target
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  password  String   // Hashed password
  name      String?
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  // Relationships
  tasks     Task[]

  @@map("users")
}

model Task {
  id          Int       @id @default(autoincrement())
  title       String
  description String?
  status      TaskStatus @default(PENDING)
  priority    Priority   @default(MEDIUM)
  dueDate     DateTime?  @map("due_date")
  createdAt   DateTime   @default(now()) @map("created_at")
  updatedAt   DateTime   @updatedAt @map("updated_at")

  // Foreign key
  userId      Int        @map("user_id")

  // Relationships
  user        User       @relation(fields: [userId], references: [id], onDelete: Cascade)
  tags        TaskTag[]

  @@map("tasks")
}

model Tag {
  id        Int      @id @default(autoincrement())
  name      String   @unique
  color     String?  // Hex color code
  createdAt DateTime @default(now()) @map("created_at")

  // Relationships
  tasks     TaskTag[]

  @@map("tags")
}

model TaskTag {
  taskId Int @map("task_id")
  tagId  Int @map("tag_id")

  task   Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
  tag    Tag  @relation(fields: [tagId], references: [id], onDelete: Cascade)

  @@id([taskId, tagId])
  @@map("task_tags")
}

enum TaskStatus {
  PENDING
  IN_PROGRESS
  COMPLETED
  CANCELLED
}

enum Priority {
  LOW
  MEDIUM
  HIGH
  URGENT
}

Notice how we define relationships (User ↔ Task) and even enums like Priority — this ensures consistency across the entire app, so the backend and database always speak the same language.

Create a centralized database module in src/database/prisma.js:

const { PrismaClient } = require("../generated/prisma");

// Create a singleton Prisma client instance
let prisma;

function getPrismaClient() {
	if (!prisma) {
		prisma = new PrismaClient({
			datasources: {
				db: {
					url: process.env.DATABASE_URL,
				},
			},
		});
	}
	return prisma;
}

module.exports = getPrismaClient;

Initialize Prisma and create database:

# Generate Prisma client
npx prisma generate

# Create and apply first migration
npx prisma migrate dev --name init

# View your database (optional)
npx prisma studio

For PostgreSQL setup

# Update DATABASE_URL in .env for PostgreSQL:
# DATABASE_URL="postgresql://username:password@localhost:5432/taskmanager?schema=public"

# Then update schema.prisma datasource:
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

Step 6: Logging Utility

We’ll use a single logger across the app so log format and levels are consistent. In production logs can be parsed by monitoring tools.

Create src/utils/logger.js:

const winston = require("winston");

// Define log levels and colors
const levels = {
	error: 0,
	warn: 1,
	info: 2,
	http: 3,
	debug: 4,
};

const colors = {
	error: "red",
	warn: "yellow",
	info: "green",
	http: "magenta",
	debug: "blue",
};

winston.addColors(colors);

// Create logger configuration
const format = winston.format.combine(
	winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
	winston.format.errors({ stack: true }),
	winston.format.colorize({ all: true }),
	winston.format.printf(
		(info) => `${info.timestamp} ${info.level}: ${info.message}`
	)
);

// Define transports
const transports = [
	// Console transport for all logs
	new winston.transports.Console({
		format: format,
	}),

	// File transport for errors
	new winston.transports.File({
		filename: "logs/error.log",
		level: "error",
		format: winston.format.combine(
			winston.format.timestamp(),
			winston.format.json()
		),
	}),

	// File transport for all logs
	new winston.transports.File({
		filename: "logs/app.log",
		format: winston.format.combine(
			winston.format.timestamp(),
			winston.format.json()
		),
	}),
];

// Create logger instance
const logger = winston.createLogger({
	level: process.env.LOG_LEVEL || "info",
	levels,
	format,
	transports,
});

module.exports = logger;

Step 7: Authentication Implementation

A task manager without user authentication would be chaos — anyone could see or delete everyone’s tasks. To prevent that, we’re introducing an AuthService. It handles registration, login, and token issuance, so users can securely prove their identity on each request.

Create src/services/authService.js:

const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const getPrismaClient = require("../database/prisma");
const logger = require("../utils/logger");

const prisma = getPrismaClient();

class AuthService {
	async register(userData) {
		const { email, password, name } = userData;

		// Check if user already exists
		const existingUser = await prisma.user.findUnique({
			where: { email },
		});

		if (existingUser) {
			throw new Error("User with this email already exists");
		}

		// Hash password with salt rounds of 12 for security
		const hashedPassword = await bcrypt.hash(password, 12);

		// Create user
		const user = await prisma.user.create({
			data: {
				email,
				password: hashedPassword,
				name,
			},
			select: {
				id: true,
				email: true,
				name: true,
				createdAt: true,
			},
		});

		// Generate JWT token
		const token = this.generateToken(user.id);

		logger.info(`New user registered: ${email}`);

		return { user, token };
	}

	async login(email, password) {
		// Find user by email
		const user = await prisma.user.findUnique({
			where: { email },
		});

		if (!user) {
			throw new Error("Invalid credentials");
		}

		// Verify password
		const isValidPassword = await bcrypt.compare(password, user.password);

		if (!isValidPassword) {
			throw new Error("Invalid credentials");
		}

		// Generate JWT token
		const token = this.generateToken(user.id);

		// Return user without password
		const { password: _, ...userWithoutPassword } = user;

		logger.info(`User logged in: ${email}`);

		return { user: userWithoutPassword, token };
	}

	generateToken(userId) {
		return jwt.sign({ userId }, process.env.JWT_SECRET, {
			expiresIn: process.env.JWT_EXPIRES_IN || "7d",
			issuer: "task-manager-api",
			audience: "task-manager-client",
		});
	}

	verifyToken(token) {
		try {
			return jwt.verify(token, process.env.JWT_SECRET);
		} catch (error) {
			throw new Error("Invalid or expired token");
		}
	}
}

module.exports = new AuthService();

By using JWTs, we avoid session storage on the server. This means our API scales horizontally — more instances, same authentication flow.

Create src/middleware/auth.js to check tokens on each request:

const authService = require("../services/authService");
const getPrismaClient = require("../database/prisma");
const logger = require("../utils/logger");

const prisma = getPrismaClient();

const authenticate = async (req, res, next) => {
	try {
		// Get token from header
		const authHeader = req.headers.authorization;

		if (!authHeader) {
			return res.status(401).json({
				success: false,
				message: "Access token is required",
			});
		}

		// Extract token (format: "Bearer <token>")
		const token = authHeader.split(" ")[1];

		if (!token) {
			return res.status(401).json({
				success: false,
				message: "Access token is required",
			});
		}

		// Verify token
		const decoded = authService.verifyToken(token);

		// Get user from database
		const user = await prisma.user.findUnique({
			where: { id: decoded.userId },
			select: {
				id: true,
				email: true,
				name: true,
				createdAt: true,
			},
		});

		if (!user) {
			return res.status(401).json({
				success: false,
				message: "User not found",
			});
		}

		// Attach user to request object
		req.user = user;
		next();
	} catch (error) {
		logger.warn(`Authentication failed: ${error.message}`);
		res.status(401).json({
			success: false,
			message: "Invalid or expired token",
		});
	}
};

module.exports = { authenticate };

Step 8: Input Validation

Create src/middleware/validation.js to validate and sanitize request input at the boundary. This avoids invalid data reaching services and produces consistent error responses:

const { body, param, query, validationResult } = require("express-validator");

// Validation error handler
const handleValidationErrors = (req, res, next) => {
	const errors = validationResult(req);

	if (!errors.isEmpty()) {
		const formattedErrors = {};
		errors.array().forEach((error) => {
			if (!formattedErrors[error.path]) {
				formattedErrors[error.path] = [];
			}
			formattedErrors[error.path].push(error.msg);
		});

		return res.status(400).json({
			success: false,
			message: "Validation failed",
			errors: formattedErrors,
		});
	}

	next();
};

// Auth validation rules
const validateRegister = [
	body("email")
		.isEmail()
		.withMessage("Please provide a valid email address")
		.normalizeEmail(),
	body("password")
		.isLength({ min: 8 })
		.withMessage("Password must be at least 8 characters long")
		.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
		.withMessage(
			"Password must contain at least one lowercase letter, one uppercase letter, and one number"
		),
	body("name")
		.optional()
		.trim()
		.isLength({ min: 2, max: 50 })
		.withMessage("Name must be between 2 and 50 characters"),
	handleValidationErrors,
];

const validateLogin = [
	body("email")
		.isEmail()
		.withMessage("Please provide a valid email address")
		.normalizeEmail(),
	body("password").notEmpty().withMessage("Password is required"),
	handleValidationErrors,
];

// Task validation rules
const validateCreateTask = [
	body("title")
		.trim()
		.isLength({ min: 1, max: 200 })
		.withMessage("Title is required and must be less than 200 characters"),
	body("description")
		.optional()
		.trim()
		.isLength({ max: 1000 })
		.withMessage("Description must be less than 1000 characters"),
	body("status")
		.optional()
		.isIn(["PENDING", "IN_PROGRESS", "COMPLETED", "CANCELLED"])
		.withMessage(
			"Status must be one of: PENDING, IN_PROGRESS, COMPLETED, CANCELLED"
		),
	body("priority")
		.optional()
		.isIn(["LOW", "MEDIUM", "HIGH", "URGENT"])
		.withMessage("Priority must be one of: LOW, MEDIUM, HIGH, URGENT"),
	body("dueDate")
		.optional()
		.isISO8601()
		.withMessage("Due date must be a valid ISO 8601 date"),
	body("tags").optional().isArray().withMessage("Tags must be an array"),
	body("tags.*")
		.optional()
		.trim()
		.isLength({ min: 1, max: 50 })
		.withMessage("Each tag must be between 1 and 50 characters"),
	handleValidationErrors,
];

const validateUpdateTask = [
	param("id")
		.isInt({ min: 1 })
		.withMessage("Task ID must be a positive integer"),
	body("title")
		.optional()
		.trim()
		.isLength({ min: 1, max: 200 })
		.withMessage("Title must be between 1 and 200 characters"),
	body("description")
		.optional()
		.trim()
		.isLength({ max: 1000 })
		.withMessage("Description must be less than 1000 characters"),
	body("status")
		.optional()
		.isIn(["PENDING", "IN_PROGRESS", "COMPLETED", "CANCELLED"])
		.withMessage(
			"Status must be one of: PENDING, IN_PROGRESS, COMPLETED, CANCELLED"
		),
	body("priority")
		.optional()
		.isIn(["LOW", "MEDIUM", "HIGH", "URGENT"])
		.withMessage("Priority must be one of: LOW, MEDIUM, HIGH, URGENT"),
	body("dueDate")
		.optional()
		.isISO8601()
		.withMessage("Due date must be a valid ISO 8601 date"),
	handleValidationErrors,
];

const validateTaskId = [
	param("id")
		.isInt({ min: 1 })
		.withMessage("Task ID must be a positive integer"),
	handleValidationErrors,
];

// Query validation for listing tasks
const validateTaskQuery = [
	query("page")
		.optional()
		.isInt({ min: 1 })
		.withMessage("Page must be a positive integer"),
	query("limit")
		.optional()
		.isInt({ min: 1, max: 100 })
		.withMessage("Limit must be between 1 and 100"),
	query("status")
		.optional()
		.isIn(["PENDING", "IN_PROGRESS", "COMPLETED", "CANCELLED"])
		.withMessage(
			"Status must be one of: PENDING, IN_PROGRESS, COMPLETED, CANCELLED"
		),
	query("priority")
		.optional()
		.isIn(["LOW", "MEDIUM", "HIGH", "URGENT"])
		.withMessage("Priority must be one of: LOW, MEDIUM, HIGH, URGENT"),
	handleValidationErrors,
];

module.exports = {
	validateRegister,
	validateLogin,
	validateCreateTask,
	validateUpdateTask,
	validateTaskId,
	validateTaskQuery,
};

Step 9: Task CRUD Implementation

With users now identified, we can focus on the core of the app: managing tasks. But we don’t want controllers to get messy with database logic. That’s where the TaskService comes in — it’s the brain behind all task operations.

Lets create src/services/taskService.js:

const getPrismaClient = require("../database/prisma");
const logger = require("../utils/logger");

const prisma = getPrismaClient();

class TaskService {
	async createTask(userId, taskData) {
		const { title, description, status, priority, dueDate, tags } = taskData;

		try {
			// Handle tags if provided
			let taskTags = [];
			if (tags && tags.length > 0) {
				// Create tags that don't exist and get all tag IDs
				for (const tagName of tags) {
					const tag = await prisma.tag.upsert({
						where: { name: tagName },
						update: {},
						create: { name: tagName },
					});
					taskTags.push({ tagId: tag.id });
				}
			}

			// Create task with tags
			const task = await prisma.task.create({
				data: {
					title,
					description,
					status: status || "PENDING",
					priority: priority || "MEDIUM",
					dueDate: dueDate ? new Date(dueDate) : null,
					userId,
					tags: {
						create: taskTags,
					},
				},
				include: {
					tags: {
						include: {
							tag: true,
						},
					},
				},
			});

			// Format response
			const formattedTask = this.formatTask(task);
			logger.info(`Task created: ${task.id} for user: ${userId}`);

			return formattedTask;
		} catch (error) {
			logger.error(`Error creating task: ${error.message}`);
			throw error;
		}
	}

	async getTasks(userId, options = {}) {
		const { page = 1, limit = 10, status, priority, search } = options;

		const skip = (page - 1) * limit;

		// Build where clause
		const where = {
			userId,
			...(status && { status }),
			...(priority && { priority }),
			...(search && {
				OR: [
					{ title: { contains: search, mode: "insensitive" } },
					{ description: { contains: search, mode: "insensitive" } },
				],
			}),
		};

		try {
			const [tasks, total] = await Promise.all([
				prisma.task.findMany({
					where,
					skip,
					take: limit,
					orderBy: [
						{ priority: "desc" },
						{ dueDate: "asc" },
						{ createdAt: "desc" },
					],
					include: {
						tags: {
							include: {
								tag: true,
							},
						},
					},
				}),
				prisma.task.count({ where }),
			]);

			const formattedTasks = tasks.map((task) => this.formatTask(task));

			return {
				tasks: formattedTasks,
				pagination: {
					page,
					limit,
					total,
					pages: Math.ceil(total / limit),
				},
			};
		} catch (error) {
			logger.error(`Error fetching tasks: ${error.message}`);
			throw error;
		}
	}

	async getTaskById(userId, taskId) {
		try {
			const task = await prisma.task.findFirst({
				where: {
					id: parseInt(taskId),
					userId,
				},
				include: {
					tags: {
						include: {
							tag: true,
						},
					},
				},
			});

			if (!task) {
				throw new Error("Task not found");
			}

			return this.formatTask(task);
		} catch (error) {
			logger.error(`Error fetching task ${taskId}: ${error.message}`);
			throw error;
		}
	}

	async updateTask(userId, taskId, updateData) {
		try {
			// Check if task exists and belongs to user
			const existingTask = await prisma.task.findFirst({
				where: {
					id: parseInt(taskId),
					userId,
				},
			});

			if (!existingTask) {
				throw new Error("Task not found");
			}

			// Update task
			const task = await prisma.task.update({
				where: { id: parseInt(taskId) },
				data: {
					...updateData,
					...(updateData.dueDate && { dueDate: new Date(updateData.dueDate) }),
				},
				include: {
					tags: {
						include: {
							tag: true,
						},
					},
				},
			});

			logger.info(`Task updated: ${taskId} by user: ${userId}`);
			return this.formatTask(task);
		} catch (error) {
			logger.error(`Error updating task ${taskId}: ${error.message}`);
			throw error;
		}
	}

	async deleteTask(userId, taskId) {
		try {
			// Check if task exists and belongs to user
			const existingTask = await prisma.task.findFirst({
				where: {
					id: parseInt(taskId),
					userId,
				},
			});

			if (!existingTask) {
				throw new Error("Task not found");
			}

			// Delete task (cascade will handle task_tags)
			await prisma.task.delete({
				where: { id: parseInt(taskId) },
			});

			logger.info(`Task deleted: ${taskId} by user: ${userId}`);
			return { message: "Task deleted successfully" };
		} catch (error) {
			logger.error(`Error deleting task ${taskId}: ${error.message}`);
			throw error;
		}
	}

	// Helper method to format task response
	formatTask(task) {
		return {
			id: task.id,
			title: task.title,
			description: task.description,
			status: task.status,
			priority: task.priority,
			dueDate: task.dueDate,
			createdAt: task.createdAt,
			updatedAt: task.updatedAt,
			tags: task.tags
				? task.tags.map((taskTag) => ({
						id: taskTag.tag.id,
						name: taskTag.tag.name,
						color: taskTag.tag.color,
					}))
				: [],
		};
	}
}

module.exports = new TaskService();

Abstracting this logic into a service not only keeps routes clean but also makes future changes (like adding notifications or deadlines) much easier.

Step 10: Controllers

Controllers should be thin: they validate preconditions, call services and return tidy JSON so clients always receive a predictable shape. By separating HTTP concerns (status codes, input context) from pure business logic we get easier testing and reusability.

Create src/controllers/authController.js:

const authService = require("../services/authService");
const logger = require("../utils/logger");

class AuthController {
	async register(req, res, next) {
		try {
			const { email, password, name } = req.body;

			const result = await authService.register({ email, password, name });

			res.status(201).json({
				success: true,
				message: "User registered successfully",
				data: {
					user: result.user,
					token: result.token,
				},
			});
		} catch (error) {
			next(error);
		}
	}

	async login(req, res, next) {
		try {
			const { email, password } = req.body;

			const result = await authService.login(email, password);

			res.status(200).json({
				success: true,
				message: "Login successful",
				data: {
					user: result.user,
					token: result.token,
				},
			});
		} catch (error) {
			next(error);
		}
	}

	async getProfile(req, res, next) {
		try {
			// User is available from authentication middleware
			res.status(200).json({
				success: true,
				data: {
					user: req.user,
				},
			});
		} catch (error) {
			next(error);
		}
	}
}

module.exports = new AuthController();

Create src/controllers/taskController.js:

const taskService = require("../services/taskService");
const logger = require("../utils/logger");

class TaskController {
	async createTask(req, res, next) {
		try {
			const userId = req.user.id;
			const task = await taskService.createTask(userId, req.body);

			res.status(201).json({
				success: true,
				message: "Task created successfully",
				data: { task },
			});
		} catch (error) {
			next(error);
		}
	}

	async getTasks(req, res, next) {
		try {
			const userId = req.user.id;
			const options = {
				page: parseInt(req.query.page) || 1,
				limit: parseInt(req.query.limit) || 10,
				status: req.query.status,
				priority: req.query.priority,
				search: req.query.search,
			};

			const result = await taskService.getTasks(userId, options);

			res.status(200).json({
				success: true,
				data: result,
			});
		} catch (error) {
			next(error);
		}
	}

	async getTask(req, res, next) {
		try {
			const userId = req.user.id;
			const taskId = req.params.id;

			const task = await taskService.getTaskById(userId, taskId);

			res.status(200).json({
				success: true,
				data: { task },
			});
		} catch (error) {
			next(error);
		}
	}

	async updateTask(req, res, next) {
		try {
			const userId = req.user.id;
			const taskId = req.params.id;

			const task = await taskService.updateTask(userId, taskId, req.body);

			res.status(200).json({
				success: true,
				message: "Task updated successfully",
				data: { task },
			});
		} catch (error) {
			next(error);
		}
	}

	async deleteTask(req, res, next) {
		try {
			const userId = req.user.id;
			const taskId = req.params.id;

			await taskService.deleteTask(userId, taskId);

			res.status(200).json({
				success: true,
				message: "Task deleted successfully",
			});
		} catch (error) {
			next(error);
		}
	}
}

module.exports = new TaskController();

Step 11: Routes

By having modular routers we keep code organized, while limiting auth endpoints reduce brute force risk and attaching middleware to each route enforces the right policies.

Create src/routes/auth.js:

const express = require("express");
const authController = require("../controllers/authController");
const { authenticate } = require("../middleware/auth");
const { validateRegister, validateLogin } = require("../middleware/validation");
const rateLimit = require("express-rate-limit");

const router = express.Router();

// Strict rate limiting for auth endpoints
const authLimiter = rateLimit({
	windowMs: 15 * 60 * 1000, // 15 minutes
	max: 5, // 5 attempts per window
	message: {
		success: false,
		message: "Too many authentication attempts, please try again later",
	},
	standardHeaders: true,
	legacyHeaders: false,
	skipSuccessfulRequests: true, // Don't count successful requests
});

// Public routes
router.post(
	"/register",
	authLimiter,
	validateRegister,
	authController.register
);
router.post("/login", authLimiter, validateLogin, authController.login);

// Protected routes
router.get("/profile", authenticate, authController.getProfile);

module.exports = router;

Create src/routes/tasks.js:

const express = require("express");
const taskController = require("../controllers/taskController");
const { authenticate } = require("../middleware/auth");
const {
	validateCreateTask,
	validateUpdateTask,
	validateTaskId,
	validateTaskQuery,
} = require("../middleware/validation");

const router = express.Router();

// All task routes require authentication
router.use(authenticate);

// Task CRUD routes
router.get("/", validateTaskQuery, taskController.getTasks);
router.post("/", validateCreateTask, taskController.createTask);
router.get("/:id", validateTaskId, taskController.getTask);
router.put("/:id", validateUpdateTask, taskController.updateTask);
router.delete("/:id", validateTaskId, taskController.deleteTask);

module.exports = router;

Step 12: Error Handling

Bugs and bad requests will happen — what matters is how gracefully we deal with them. Instead of letting the app crash or return cryptic errors, we funnel all mistakes into one place. This global error handler acts as a translator between raw exceptions and human-readable messages.

Create src/middleware/errorHandler.js:

const logger = require("../utils/logger");

const errorHandler = (err, req, res, next) => {
	let error = { ...err };
	error.message = err.message;

	// Log error
	logger.error(`Error: ${error.message}`, {
		stack: err.stack,
		path: req.path,
		method: req.method,
		ip: req.ip,
		userAgent: req.get("User-Agent"),
	});

	// Prisma errors
	if (err.code === "P2002") {
		const message = "Duplicate field value entered";
		error = { message, statusCode: 400 };
	}

	if (err.code === "P2025") {
		const message = "Record not found";
		error = { message, statusCode: 404 };
	}

	// JWT errors
	if (err.name === "JsonWebTokenError") {
		const message = "Invalid token";
		error = { message, statusCode: 401 };
	}

	if (err.name === "TokenExpiredError") {
		const message = "Token expired";
		error = { message, statusCode: 401 };
	}

	// Validation errors
	if (err.name === "ValidationError") {
		const message = Object.values(err.errors).map((val) => val.message);
		error = { message, statusCode: 400 };
	}

	// Default error response
	const statusCode = error.statusCode || 500;
	const message = error.message || "Internal Server Error";

	res.status(statusCode).json({
		success: false,
		message,
		...(process.env.NODE_ENV === "development" && { stack: err.stack }),
	});
};

module.exports = errorHandler;

Add these scripts to your package.json:

{
	"scripts": {
		"start": "node src/index.js",
		"dev": "nodemon src/index.js",
		"test": "NODE_ENV=test jest",
		"test:watch": "NODE_ENV=test jest --watch",
		"test:coverage": "NODE_ENV=test jest --coverage",
		"lint": "eslint src/",
		"lint:fix": "eslint src/ --fix",
		"format": "prettier --write src/",
		"db:migrate": "prisma migrate dev",
		"db:generate": "prisma generate",
		"db:studio": "prisma studio"
	}
}

5. Checkpoints

Checkpoint 1: Basic Setup

Exercise: After Step 4, verify your setup works:

npm run dev
curl http://localhost:3000/healthz

Expected: JSON response with status “ok”

Checkpoint 2: Authentication

Exercise: After implementing auth (Step 6), test the endpoints:

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

# Login with the user
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"TestPass123"}'

Checkpoint 3: CRUD Operations

Exercise: After implementing tasks (Step 8), test full CRUD:

# Get auth token first, then:
TOKEN="your-jwt-token-here"

# Create task
curl -X POST http://localhost:3000/api/tasks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"My First Task","description":"Test task","priority":"HIGH"}'

# List tasks
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/tasks

# Update task (replace 1 with actual task ID)
curl -X PUT http://localhost:3000/api/tasks/1 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"COMPLETED"}'

Extension Ideas:

  1. Add task tagging system: Extend the many-to-many relationship between tasks and tags
  2. Implement search functionality: Add full-text search across task titles and descriptions
  3. Add file attachments: Allow users to upload files and associate them with tasks
  4. Session-based auth: Replace JWT with express-session and Redis for session storage
  5. Real-time updates: Add WebSocket support for real-time task updates
  6. Email notifications: Send email reminders for due dates using nodemailer
  7. API versioning: Implement /api/v1 and /api/v2 with different response formats
  8. Swagger documentation: Add OpenAPI documentation with swagger-ui-express

6. Common Issues & Troubleshooting

CORS Errors

Problem: Browser blocks API requests due to CORS policy Solution: Ensure CORS is properly configured in app.js:

app.use(
	cors({
		origin: ["http://localhost:3000", "http://localhost:3001"],
		credentials: true,
	})
);

Prisma Client Issues

Problem: “PrismaClient is unable to be run in the browser” Solution: Regenerate client after schema changes:

npx prisma generate
npx prisma migrate dev

JWT Token Errors

Problem: “JsonWebTokenError: invalid token” Solutions:

  • Ensure JWT_SECRET is set in environment variables
  • Check token format: “Bearer
  • Verify token hasn’t expired

Database Connection Issues

Problem: “Can’t reach database server” Solutions:

# For PostgreSQL - verify connection string
echo $DATABASE_URL

# Test database connection
npx prisma db pull

What’s Next?

On part 2 of this tutorial we’ll tackle:

  • Testing with Jest and Supertest
  • Docker Configuration
  • CI/CD with GitHub Actions
  • Deployment Configuration
  • Security Hardening & Production Optimizations

Congratulations! You’ve built a REST API using modern Node.js practices. This foundation will serve you well for building scalable backend applications. Stay tuned for part 2 of this tutorial!

Project source code available here: https://github.com/rtome85/task-manager

Tags:

Node.js Express.js REST API Backend Development API Development

Share this post:

Complete Node.js Task Manager REST API Tutorial: Part 1: Development