Back to Full-Stack Mastery

Module 2: Backend & API Development

Build a RESTful API with Node.js, Express, and PostgreSQL for your Task Management App

📚 Understanding Backend Development

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

The Backend Stack

Node.js

JavaScript runtime that lets you run JavaScript on the server

Express

Web framework for building APIs and handling HTTP requests

PostgreSQL

Powerful relational database for storing and querying data

What is a REST API?

REST (Representational State Transfer) is an architectural style for designing APIs. It uses standard HTTP methods to perform operations on resources (data).

GET

Retrieve data from the server. Example: GET /api/tasks returns all tasks.

POST

Create new data on the server. Example: POST /api/tasks creates a new task.

PUT/PATCH

Update existing data. Example: PATCH /api/tasks/123 updates task with ID 123.

DELETE

Remove data from the server. Example: DELETE /api/tasks/123 deletes task 123.

🎯 What We'll Build

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.

API Endpoints:

  • GET /api/tasks - Get all tasks
  • GET /api/tasks/:id - Get single task
  • POST /api/tasks - Create new task
  • PATCH /api/tasks/:id - Update task
  • DELETE /api/tasks/:id - Delete task

What You'll Learn:

• Node.js and npm basics
• Express server setup
• RESTful API design
• PostgreSQL database
• SQL queries and migrations
• Error handling
• Environment variables
• API testing with Postman

Lesson 1: Setting Up Node.js Backend

📖 Understanding Node.js

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

Step 1: Create Backend Project

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.

Step 2: Configure TypeScript

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"]
}

Step 3: Update package.json Scripts

Add these scripts to your package.json:

"scripts": {
  "dev": "nodemon --exec ts-node src/index.ts",
  "build": "tsc",
  "start": "node dist/index.js"
}

Step 4: Create Basic Express Server

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}`);
});

📚 Key Concepts:

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

Step 5: Test Your Server

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!"}

🎉 Success!

You've created your first Node.js server! It's now listening for HTTP requests and can respond with JSON data.

Lesson 2: Database Setup with PostgreSQL

📖 Understanding Databases

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.

Step 1: Install PostgreSQL

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/

Step 2: Install Database Client

Install the PostgreSQL client library for Node.js:

npm install pg
npm install --save-dev @types/pg

Step 3: Create Database

Create 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
\q

Step 4: Create Environment Variables

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

Step 5: Create Database Connection

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;

📚 What is a Connection 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.

Step 6: Create Tasks Table

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

🎯 What You Learned:

  • ✓ How to install and set up PostgreSQL
  • ✓ Creating databases and tables with SQL
  • ✓ Connecting Node.js to PostgreSQL
  • ✓ Using environment variables for configuration
  • ✓ Database migrations and schema design

Lesson 3: Building CRUD API Endpoints

📖 Understanding CRUD

CRUD 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.).

Step 1: Create Task Type

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;
}

Step 2: Create Task Routes

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;

💡 Code Breakdown:

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.

Step 3: Register Routes in Main App

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}`);
});

🎯 What You Learned:

  • ✓ Building RESTful API endpoints
  • ✓ CRUD operations with PostgreSQL
  • ✓ SQL queries and parameterization
  • ✓ Error handling and HTTP status codes
  • ✓ Request validation

Lesson 4: Testing Your API

📖 API Testing Tools

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.

Option 1: Using curl (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/tasks

Update 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_ID

Option 2: Using Postman

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

💡 Testing Best Practices:

  • • Test happy paths (valid data)
  • • Test error cases (invalid data, missing fields)
  • • Test edge cases (empty strings, very long text)
  • • Verify HTTP status codes
  • • Check response data structure

Lesson 5: Connecting Frontend to Backend

📖 Frontend-Backend Integration

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.

Step 1: Update Frontend to Use API

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>
  );
}

💡 Key Changes:

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.

Step 2: Update Task Type

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;
}

Step 3: Test Full-Stack App

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!

🎉 Congratulations!

You've built a complete full-stack application! Your React frontend now communicates with a Node.js backend that stores data in PostgreSQL.

What You've Learned:
✓ Node.js and Express
✓ RESTful API design
✓ PostgreSQL database
✓ SQL queries
✓ API testing
✓ Frontend-backend integration
✓ Error handling
✓ Environment variables

🚀 Next Steps

  • Add user authentication (JWT tokens)
  • Implement input validation with Zod
  • Add pagination for large task lists
  • Deploy backend to Railway or Render
Back to Course Overview