Build a RESTful API with Node.js, Express, and PostgreSQL for your Task Management App
Backend development is the server-side of web applications. It handles data storage, business logic, authentication, and serves data to the frontend through APIs (Application Programming Interfaces).
JavaScript runtime that lets you run JavaScript on the server
Web framework for building APIs and handling HTTP requests
Powerful relational database for storing and querying data
REST (Representational State Transfer) is an architectural style for designing APIs. It uses standard HTTP methods to perform operations on resources (data).
Retrieve data from the server. Example: GET /api/tasks returns all tasks.
Create new data on the server. Example: POST /api/tasks creates a new task.
Update existing data. Example: PATCH /api/tasks/123 updates task with ID 123.
Remove data from the server. Example: DELETE /api/tasks/123 deletes task 123.
A complete RESTful API backend for our Task Management App. This will replace the frontend-only state management with a real database and server, making your app production-ready.
GET /api/tasks - Get all tasksGET /api/tasks/:id - Get single taskPOST /api/tasks - Create new taskPATCH /api/tasks/:id - Update taskDELETE /api/tasks/:id - Delete taskNode.js is a JavaScript runtime built on Chrome's V8 engine. It allows you to run JavaScript outside the browser, making it perfect for building servers and APIs.
Think of Node.js as the engine that powers your backend, just like how a browser engine powers your frontend JavaScript.
Create a new directory for your backend and initialize a Node.js project:
mkdir task-manager-backend
cd task-manager-backend
npm init -y
# Install required packages
npm install express cors dotenv
npm install --save-dev typescript @types/express @types/node @types/cors ts-node nodemon
# Initialize TypeScript
npx tsc --init💡 What's happening? We're creating a new Node.js project and installing Express (web framework), CORS (for cross-origin requests), and TypeScript for type safety.
Update tsconfig.json with these settings:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}Add these scripts to your package.json:
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}Create src/index.ts:
import express, { Request, Response } from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
// Create Express app
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors()); // Enable CORS for frontend requests
app.use(express.json()); // Parse JSON request bodies
// Test route
app.get('/', (req: Request, res: Response) => {
res.json({ message: 'Task Manager API is running!' });
});
// Health check endpoint
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
// Start server
app.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
});express() - Creates an Express application instance
app.use() - Registers middleware that runs for every request
app.get() - Defines a route that handles GET requests
app.listen() - Starts the server on specified port
Run your server and test it:
npm run dev
# In another terminal or browser, visit:
# http://localhost:3001
# You should see: {"message": "Task Manager API is running!"}You've created your first Node.js server! It's now listening for HTTP requests and can respond with JSON data.
A database is where we permanently store our application data. PostgreSQL is a powerful, open-source relational database that uses SQL (Structured Query Language) to manage data.
Think of a database like an Excel spreadsheet on steroids - it has tables with rows and columns, but it's much more powerful and can handle millions of records efficiently.
Install PostgreSQL on your system:
# macOS (using Homebrew)
brew install postgresql@15
brew services start postgresql@15
# Ubuntu/Debian
sudo apt update
sudo apt install postgresql postgresql-contrib
# Windows
# Download installer from: https://www.postgresql.org/download/windows/Install the PostgreSQL client library for Node.js:
npm install pg
npm install --save-dev @types/pgCreate a new database for your application:
# Connect to PostgreSQL
psql postgres
# Create database
CREATE DATABASE task_manager;
# Connect to the database
\c task_manager
# Exit psql
\qCreate .env file in your backend root:
PORT=3001
DATABASE_URL=postgresql://localhost:5432/task_manager💡 Security tip: Never commit .env files to Git! Add it to .gitignore.
Create src/db.ts:
import { Pool } from 'pg';
// Create a connection pool
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
// Test connection
pool.on('connect', () => {
console.log('✅ Connected to PostgreSQL database');
});
pool.on('error', (err) => {
console.error('❌ Database connection error:', err);
});
export default pool;A connection pool maintains multiple database connections that can be reused. Instead of creating a new connection for each query (slow), we reuse existing connections (fast). It's like having multiple phone lines instead of one.
Create src/migrations/001_create_tasks_table.sql:
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
description TEXT,
completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create index for faster queries
CREATE INDEX idx_tasks_completed ON tasks(completed);
CREATE INDEX idx_tasks_created_at ON tasks(created_at DESC);Run this migration:
psql task_manager < src/migrations/001_create_tasks_table.sqlCRUD stands for Create, Read, Update, Delete - the four basic operations for managing data. Every API typically implements these operations for each resource (like tasks, users, etc.).
Create src/types/task.ts:
export interface Task {
id: string;
title: string;
description: string;
completed: boolean;
created_at: Date;
updated_at: Date;
}
export interface CreateTaskInput {
title: string;
description?: string;
}
export interface UpdateTaskInput {
title?: string;
description?: string;
completed?: boolean;
}Create src/routes/tasks.ts:
import { Router, Request, Response } from 'express';
import pool from '../db';
import { CreateTaskInput, UpdateTaskInput } from '../types/task';
const router = Router();
// GET /api/tasks - Get all tasks
router.get('/', async (req: Request, res: Response) => {
try {
const result = await pool.query(
'SELECT * FROM tasks ORDER BY created_at DESC'
);
res.json(result.rows);
} catch (error) {
console.error('Error fetching tasks:', error);
res.status(500).json({ error: 'Failed to fetch tasks' });
}
});
// GET /api/tasks/:id - Get single task
router.get('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const result = await pool.query(
'SELECT * FROM tasks WHERE id = $1',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Task not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error fetching task:', error);
res.status(500).json({ error: 'Failed to fetch task' });
}
});
// POST /api/tasks - Create new task
router.post('/', async (req: Request, res: Response) => {
try {
const { title, description }: CreateTaskInput = req.body;
if (!title || title.trim() === '') {
return res.status(400).json({ error: 'Title is required' });
}
const result = await pool.query(
`INSERT INTO tasks (title, description)
VALUES ($1, $2)
RETURNING *`,
[title.trim(), description?.trim() || '']
);
res.status(201).json(result.rows[0]);
} catch (error) {
console.error('Error creating task:', error);
res.status(500).json({ error: 'Failed to create task' });
}
});
// PATCH /api/tasks/:id - Update task
router.patch('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { title, description, completed }: UpdateTaskInput = req.body;
// Build dynamic update query
const updates: string[] = [];
const values: any[] = [];
let paramCount = 1;
if (title !== undefined) {
updates.push(`title = $${paramCount++}`);
values.push(title.trim());
}
if (description !== undefined) {
updates.push(`description = $${paramCount++}`);
values.push(description.trim());
}
if (completed !== undefined) {
updates.push(`completed = $${paramCount++}`);
values.push(completed);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
updates.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(id);
const query = `
UPDATE tasks
SET ${updates.join(', ')}
WHERE id = $${paramCount}
RETURNING *
`;
const result = await pool.query(query, values);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Task not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating task:', error);
res.status(500).json({ error: 'Failed to update task' });
}
});
// DELETE /api/tasks/:id - Delete task
router.delete('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const result = await pool.query(
'DELETE FROM tasks WHERE id = $1 RETURNING *',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Task not found' });
}
res.json({ message: 'Task deleted successfully', task: result.rows[0] });
} catch (error) {
console.error('Error deleting task:', error);
res.status(500).json({ error: 'Failed to delete task' });
}
});
export default router;Parameterized Queries ($1, $2)
Prevents SQL injection attacks by safely escaping user input. Never concatenate user input directly into SQL!
Try-Catch Blocks
Handles errors gracefully. If database query fails, we send a proper error response instead of crashing.
HTTP Status Codes
200 (OK), 201 (Created), 400 (Bad Request), 404 (Not Found), 500 (Server Error) - communicate what happened.
RETURNING *
PostgreSQL feature that returns the inserted/updated/deleted row, so we can send it back to the client.
Update src/index.ts:
import express, { Request, Response } from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import taskRoutes from './routes/tasks';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors());
app.use(express.json());
// Routes
app.get('/', (req: Request, res: Response) => {
res.json({ message: 'Task Manager API is running!' });
});
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
// Register task routes
app.use('/api/tasks', taskRoutes);
// 404 handler
app.use((req: Request, res: Response) => {
res.status(404).json({ error: 'Route not found' });
});
// Start server
app.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
});Before connecting your frontend, you should test your API endpoints. We'll use Postman, a popular tool for testing APIs, and curl commands that work in any terminal.
Create a task:
curl -X POST http://localhost:3001/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn Node.js", "description": "Build a REST API"}'Get all tasks:
curl http://localhost:3001/api/tasksUpdate a task (replace TASK_ID):
curl -X PATCH http://localhost:3001/api/tasks/TASK_ID \
-H "Content-Type: application/json" \
-d '{"completed": true}'Delete a task:
curl -X DELETE http://localhost:3001/api/tasks/TASK_ID1. Download Postman from postman.com
2. Create a new request
3. Set the method (GET, POST, PATCH, DELETE)
4. Enter the URL: http://localhost:3001/api/tasks
5. For POST/PATCH, go to Body → raw → JSON and add your data
6. Click Send and view the response
Now that we have a working API, let's connect our React frontend from Module 1 to use real data from the backend instead of local state.
Update your app/page.tsx in the frontend project:
'use client';
import { useState, useEffect } from 'react';
import TaskForm from '@/components/TaskForm';
import TaskItem from '@/components/TaskItem';
import { Task } from '@/types/task';
const API_URL = 'http://localhost:3001/api/tasks';
export default function Home() {
const [tasks, setTasks] = useState<Task[]>([]);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch tasks from API on component mount
useEffect(() => {
fetchTasks();
}, []);
const fetchTasks = async () => {
try {
setLoading(true);
const response = await fetch(API_URL);
if (!response.ok) throw new Error('Failed to fetch tasks');
const data = await response.json();
setTasks(data);
setError(null);
} catch (err) {
setError('Failed to load tasks. Is the backend running?');
console.error(err);
} finally {
setLoading(false);
}
};
const addTask = async (taskData: Omit<Task, 'id' | 'created_at' | 'updated_at'>) => {
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData),
});
if (!response.ok) throw new Error('Failed to create task');
const newTask = await response.json();
setTasks([newTask, ...tasks]);
} catch (err) {
setError('Failed to create task');
console.error(err);
}
};
const toggleTask = async (id: string) => {
const task = tasks.find(t => t.id === id);
if (!task) return;
try {
const response = await fetch(`${API_URL}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !task.completed }),
});
if (!response.ok) throw new Error('Failed to update task');
const updatedTask = await response.json();
setTasks(tasks.map(t => t.id === id ? updatedTask : t));
} catch (err) {
setError('Failed to update task');
console.error(err);
}
};
const deleteTask = async (id: string) => {
try {
const response = await fetch(`${API_URL}/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete task');
setTasks(tasks.filter(t => t.id !== id));
} catch (err) {
setError('Failed to delete task');
console.error(err);
}
};
const filteredTasks = tasks.filter(task => {
if (filter === 'active') return !task.completed;
if (filter === 'completed') return task.completed;
return true;
});
if (loading) {
return (
<main className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4 max-w-3xl text-center">
<p className="text-xl">Loading tasks...</p>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4 max-w-3xl">
<h1 className="text-4xl font-bold mb-8 text-center
bg-gradient-to-r from-blue-500 to-cyan-500
bg-clip-text text-transparent">
Task Manager
</h1>
{error && (
<div className="bg-red-50 dark:bg-red-950 border border-red-200
dark:border-red-800 rounded-lg p-4 mb-6">
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<TaskForm onAddTask={addTask} />
{/* Filter buttons */}
<div className="flex gap-2 mb-6 flex-wrap">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-lg transition-colors font-medium ${
filter === 'all'
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
All ({tasks.length})
</button>
<button
onClick={() => setFilter('active')}
className={`px-4 py-2 rounded-lg transition-colors font-medium ${
filter === 'active'
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Active ({tasks.filter(t => !t.completed).length})
</button>
<button
onClick={() => setFilter('completed')}
className={`px-4 py-2 rounded-lg transition-colors font-medium ${
filter === 'completed'
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Completed ({tasks.filter(t => t.completed).length})
</button>
</div>
{/* Task list */}
<div className="space-y-3">
{filteredTasks.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800
rounded-lg border-2 border-dashed">
<p className="text-gray-500 text-lg">
{filter === 'all'
? 'No tasks yet. Add one above!'
: `No ${filter} tasks.`}
</p>
</div>
) : (
filteredTasks.map(task => (
<TaskItem
key={task.id}
task={task}
onToggle={toggleTask}
onDelete={deleteTask}
/>
))
)}
</div>
</div>
</main>
);
}useEffect Hook
Runs code when component mounts. We use it to fetch tasks from the API on page load.
async/await
Modern way to handle asynchronous operations like API calls. Makes code more readable than promises.
fetch API
Built-in browser function for making HTTP requests. We use it to call our backend API.
Error Handling
We catch errors and show user-friendly messages instead of crashing the app.
Update types/task.ts to match backend:
export interface Task {
id: string;
title: string;
description: string;
completed: boolean;
created_at: string; // Changed from Date to string (API returns ISO string)
updated_at: string;
}1. Start your backend: cd task-manager-backend && npm run dev
2. Start your frontend: cd task-manager && npm run dev
3. Open http://localhost:3000
4. Create, update, and delete tasks - they now persist in the database!
You've built a complete full-stack application! Your React frontend now communicates with a Node.js backend that stores data in PostgreSQL.