Full-Stack Task Manager App

Build a task manager with React, Express, and MySQL with JWT auth.

Introduction

30 Second Summary

Everyone has a to-do list, whether it's scribbled on a sticky note or typed into a phone app. Behind every task management tool you use is a full-stack system connecting a user interface, a server, and a database.

In this project, you will build a complete Task Manager application with user authentication, letting you create, edit, delete, and complete tasks. You'll wire together a React frontend, an Express API, and a MySQL database.

What You'll Build

A full-stack Task Manager application where users register, log in, and manage personal tasks through a React interface backed by an Express REST API and MySQL database.

By the end of this project, you'll have:

  • A React frontend with login and register forms plus a task management interface.
  • An Express REST API with JWT-based authentication middleware protecting task routes.
  • A MySQL database with users and tasks tables storing all application data.
  • Secret Mission: Add task filtering by status and a search feature to find tasks by title.

Are there any prerequisites?

This project assumes a basic understanding of JavaScript and HTML. You don't need prior experience with React, Express, or MySQL. The steps walk you through everything from installation to a working app.

Before We Start

You are about to build a full-stack Task Manager application. The app has three major pieces: a React frontend for the user interface, an Express backend API for handling logic, and a MySQL database for storing data.

Before writing any code, you need the foundational tools installed on your machine. This step gets Node.js (the JavaScript runtime) and MySQL (the database server) ready to go.

In this step, get ready to:
  • Install Node.js and verify it works in your terminal.
  • Install MySQL and set a root password.
  • Create the project database in MySQL.
Install Node.js

Node.js is the JavaScript runtime that powers both your backend server and your frontend build tools. This project requires version 20.19 or higher.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check if Node.js is already installed by running this command:
node --version

✔️ I see version 20.19 or higher

Node.js is ready. Skip ahead to the MySQL installation below.

ⓧ I see an older version

You have Node.js installed, but it is below the required 20.19 minimum. You need to upgrade.

  • Go to https://nodejs.org in your browser.
  • Download the LTS installer for your operating system.
  • Run the installer and accept the default settings.
  • Close and reopen your terminal, then run node --version again to confirm the new version.

macOS default version

Some macOS systems ship with an outdated version of Node.js. The installer from nodejs.org replaces it with the latest LTS release.

ⓧ Command not found

Node.js is not installed on your machine yet. You need to install it from scratch.

  • Go to https://nodejs.org in your browser.
  • Download the LTS installer for your operating system.
  • Run the installer and accept the default settings.
  • Close and reopen your terminal, then run node --version to confirm installation.
  • Verify that npm (the Node package manager) is available by running:
npm --version

You should see a version number printed in the terminal. npm is bundled with Node.js, so if Node is installed correctly, npm will be too.

npm command not found?

If node --version works but npm --version does not, try closing your terminal completely and reopening it. The installer may need a fresh terminal session to register the path.

help me troubleshoot why npm is not found after installing Node.js

Install and configure MySQL

MySQL is the relational database that stores your users and tasks. You will install MySQL Community Server and set a root password during setup.

  • Go to https://dev.mysql.com/downloads/ in your browser.
  • Click MySQL Community Server.
  • Select your operating system from the dropdown.
  • Download the installer for your platform.
  • Run the installer.

What happens during installation?

The MySQL installer will ask you to set a root password. This is the admin password for your database server. Choose something you will remember for the rest of this project.

On Windows, the installer may also prompt you to configure MySQL as a Windows Service. Accept the default so MySQL starts automatically.

  • Set a root password when prompted during installation. Remember this password for later.
  • Accept all other default settings and complete the installation.
  • Verify MySQL is installed by running this in your terminal:
mysql --version

✔️ I see a version number

MySQL is installed and available in your terminal. Move on to creating the database below.

ⓧ Command not found

Your terminal cannot find the mysql command. This usually means MySQL is not added to your system PATH.

  • On macOS, try restarting your terminal. If that does not work, you may need to add MySQL to your PATH by following the instructions in the installer's post-install notes.
  • On Windows, the installer should add MySQL to your PATH automatically. Try closing and reopening PowerShell. If it still does not work, search for Environment Variables in Windows Settings and add the MySQL bin folder to your PATH manually.

help me add MySQL to my system PATH

Create the project database

With MySQL running, you need a dedicated database to hold your app's tables. You will create one called task_manager.

  • Open the MySQL command-line client by running:
mysql -u root -p

You will be prompted to enter the root password you set during installation. The password will not appear on screen as you type.

  • Type your root password and press Enter.
  • Create the project database by running this SQL command:
CREATE DATABASE task_manager;

What does this command do?

This creates an empty database called task_manager on your MySQL server. Later steps will add tables inside this database to store users and tasks.

  • Verify the database was created by running:
SHOW DATABASES;

You should see task_manager in the list of databases printed to the terminal.

  • Exit the MySQL client by typing:
EXIT;

Access denied error?

If you see an access denied error when running mysql -u root -p, double-check that you are entering the root password you set during installation.

If you forgot your root password, you will need to reset it through your operating system's MySQL configuration.

help me fix a MySQL access denied error

Your development environment is ready. Next up, you will scaffold the project folder structure and install the packages your app needs.

Scaffold the Project Structure

Your database is ready. Now you need a place for your application code to live.

Full-stack apps separate the frontend (what users see) from the backend (what processes data). In this step, you will create two folders inside one project: a server folder for the Express API and a client folder for the React frontend.

In this step, get ready to:
  • Create the backend folder and install server dependencies.
  • Scaffold the React frontend with Vite.
  • Set up environment variables for database and authentication configuration.
Create the backend folder and install dependencies

The backend needs its own folder with its own package.json file to track dependencies. You will create the project structure and install six packages that power the API.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folders and navigate into the server folder by running these commands:
mkdir task-manager
cd task-manager
mkdir server
cd server
  • Confirm the folders exist by running:
ls

You should see an empty output because the server folder is still empty. That is expected.

  • Initialize a new Node.js project by running:
npm init -y

What does this command do?

The npm init -y command creates a package.json file with default values. This file tracks your project's name, version, and most importantly, its dependencies.

The -y flag accepts all defaults so you skip the interactive prompts.

  • Install the backend dependencies by running:
npm install express mysql2 jsonwebtoken bcryptjs cors dotenv

What are these packages?

  • express is the web framework that handles HTTP requests and routes.
  • mysql2 connects your server to the MySQL database.
  • jsonwebtoken creates and verifies JWT tokens for authentication.
  • bcryptjs hashes passwords so they are never stored as plain text.
  • cors allows the frontend to make requests to the backend from a different port.
  • dotenv loads environment variables from a .env file into your application.
  • Confirm the packages installed by running:
ls

You should see node_modules, package.json, and package-lock.json listed in the output.

Not seeing node_modules?

  • Make sure you ran npm init -y before the install command. Without a package.json file, npm does not know where to install packages.
  • Confirm you are inside the server folder by running pwd (macOS/Linux) or cd (Windows). The path should end with task-manager/server.

Having trouble? Help me debug my npm install issue

Scaffold the React frontend

Vite is a modern build tool that scaffolds a React project with a single command. It sets up the development server, hot module replacement, and production build configuration for you.

  • Navigate back to the task-manager root folder by running:
cd ..
  • Scaffold the React frontend by running:
npm create vite@latest client -- --template react

What does this command do?

This command downloads the create-vite scaffolding tool and generates a new React project inside a folder called client. The --template react flag tells Vite to use the React starter template.

The double dash (--) before --template is required by npm to pass flags through to the underlying create-vite tool.

Seeing a framework selection prompt on Windows?

PowerShell treats -- as special syntax. If you see a "Select a framework" prompt, cancel with Ctrl+C and run this alternative command instead:

npm create vite@latest client --- --template react

The triple dash (---) works around PowerShell's argument parsing.

You should see a message that says "Scaffolding project in" followed by the path to your client folder.

  • Move into the client folder and install its dependencies by running:
cd client
npm install
  • Confirm the frontend is ready by running:
ls

You should see folders like node_modules, src, and public, plus files like package.json and vite.config.js.

Command not found or permission errors?

  • Verify Node.js is still accessible by running node --version. If it fails, your terminal session may have lost the PATH. Close and reopen your terminal.
  • On macOS, if you see a permissions error, avoid using sudo. Instead, fix npm permissions by following the npm docs.

Help me fix my Vite scaffold issue

Set up environment variables

Environment variables keep sensitive configuration (like database passwords) out of your source code. The dotenv package reads from a .env file and loads these values into your application at runtime.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the task-manager folder, then click Open.

Why open the whole project folder?

Opening task-manager (the root) gives you access to both server and client subfolders in one VS Code window. This makes it easy to switch between frontend and backend code.

  • In the Explorer sidebar, right-click on the server folder.
  • Select New File.
  • Name the file .env and press Enter.
  • Paste the following configuration into .env:
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=your-mysql-password-here
DB_NAME=task_manager
JWT_SECRET=your-secret-key-here
PORT=5000

What do these variables do?

  • DB_HOST, DB_USER, DB_PASSWORD, and DB_NAME tell the server how to connect to your MySQL database.
  • JWT_SECRET is a private key used to sign authentication tokens. You can use any random string here.
  • PORT sets which port the Express server listens on.
  • Replace your-mysql-password-here with the root password you set when installing MySQL in Step 1.
  • Replace your-secret-key-here with any random string (for example, my-super-secret-jwt-key-2024).
  • Save the file with Cmd+S (macOS) or Ctrl+S (Windows).

File not showing in the sidebar?

  • Files starting with a dot are hidden by default on some systems. In VS Code's Explorer, they should still appear. If you do not see .env, try clicking the refresh icon in the Explorer panel.
  • Make sure you created the file inside the server folder, not in the task-manager root.

Help me find my .env file

✔️ Awesome, I've got everything!

Great. Double check you have saved your .env file with your actual MySQL password filled in.

ⓧ I'd like to double check the full code

Here is the complete server/.env file. Make sure your values match this structure (with your own password and secret substituted):

DB_HOST=localhost
DB_USER=root
DB_PASSWORD=your-mysql-password-here
DB_NAME=task_manager
JWT_SECRET=your-secret-key-here
PORT=5000

Your project skeleton is in place. You have a backend ready for application code and a frontend scaffolded with React. Next up, you will build the database tables and the Express API that handles user registration, login, and task management.

Build the Database and Express API

Your project structure is ready. Now you need the engine that powers it: a database to store users and tasks, and an API server that the React frontend will talk to.

In this step, you'll define the database schema in MySQL, then build a full REST API using Express with user authentication and task management routes.

In this step, get ready to:
  • Create the database tables for users and tasks.
  • Build the Express server with authentication routes.
  • Add protected CRUD routes for task management.
Create the database tables

Before your API can store anything, you need tables in MySQL to hold user accounts and their tasks.

  • In your code editor, create a new file called schema.sql inside the server folder.
  • Add the following SQL to define both tables:
CREATE DATABASE IF NOT EXISTS task_manager;
USE task_manager;

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) UNIQUE NOT NULL,
  password VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE tasks (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  title VARCHAR(255) NOT NULL,
  completed BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

What does this SQL do?

The users table stores email and hashed password for each account. The id column auto-increments so each user gets a unique number.

The tasks table links back to users through user_id. The ON DELETE CASCADE means if a user is deleted, all their tasks are automatically removed too.

  • Save schema.sql.
  • Run the script against your MySQL database by opening your terminal and running:
mysql -u root -p < server/schema.sql
  • Enter your MySQL root password when prompted.
  • You should see no errors. The terminal returns to the prompt silently on success.

Seeing an error about the database or tables?

  • If you see Access denied, double-check you're using the correct root password you set during MySQL installation.
  • If you see command not found, make sure MySQL's bin directory is in your system PATH, or use the full path to the mysql binary.
  • On Windows PowerShell, the < character is reserved. Use cmd.exe instead, or open the MySQL client and run source server/schema.sql from inside it.

Still stuck? Help me troubleshoot my MySQL schema script

Build the Express server with auth routes

Now you'll create the main server file. This sets up Express with the middleware it needs, connects to MySQL, and provides registration and login endpoints.

  • Create a new file called index.js inside the server folder.
  • Add the following code to set up the server foundation:
const express = require('express');
const cors = require('cors');
const mysql = require('mysql2/promise');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
require('dotenv').config();

const app = express();
app.use(cors());
app.use(express.json());

// Create a connection pool to the MySQL database
const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
});

What does this code do?

  • The require statements import the packages you installed in Step 2.
  • CORS middleware allows the React frontend (on a different port) to make requests to this server.
  • express.json() parses incoming JSON request bodies so you can access them via req.body.
  • A connection pool reuses database connections instead of opening a new one for every request. This is much more efficient.
  • Below the pool, add the authentication middleware function:
// Auth middleware
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  if (!token) return res.status(401).json({ message: 'Access token required' });

  jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
    if (err) return res.status(403).json({ message: 'Invalid token' });
    req.user = user;
    next();
  });
}

What is this middleware?

JWT tokens are sent in the Authorization header as Bearer <token>. This function extracts the token, verifies it, and attaches the decoded user info to req.user.

If the token is missing or invalid, the request is rejected before it reaches the route handler. You'll use this middleware to protect the task routes later.

  • Below the middleware, add the register route:
// Auth routes
app.post('/api/auth/register', async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) {
    return res.status(400).json({ message: 'Email and password are required' });
  }
  try {
    // Check if email is already taken
    const [existing] = await pool.execute('SELECT id FROM users WHERE email = ?', [email]);
    if (existing.length > 0) {
      return res.status(409).json({ message: 'Email already registered' });
    }
    // Hash the password and create the user
    const hashedPassword = await bcrypt.hash(password, 10);
    const [result] = await pool.execute(
      'INSERT INTO users (email, password) VALUES (?, ?)',
      [email, hashedPassword]
    );
    // Issue a JWT token for the new user
    const token = jwt.sign({ id: result.insertId, email }, process.env.JWT_SECRET, { expiresIn: '24h' });
    res.status(201).json({ token, user: { id: result.insertId, email } });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

What does the register route do?

  • It validates that both email and password are provided.
  • It checks if the email already exists to prevent duplicate accounts.
  • bcrypt.hash(password, 10) hashes the password with 10 salt rounds. The plain-text password is never stored.
  • jwt.sign() creates a token containing the user's ID and email, valid for 24 hours.
  • Below the register route, add the login route:
app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) {
    return res.status(400).json({ message: 'Email and password are required' });
  }
  try {
    // Find the user by email
    const [users] = await pool.execute('SELECT * FROM users WHERE email = ?', [email]);
    if (users.length === 0) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }
    // Compare the provided password with the stored hash
    const user = users[0];
    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }
    // Issue a JWT token
    const token = jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET, { expiresIn: '24h' });
    res.json({ token, user: { id: user.id, email: user.email } });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

What does the login route do?

It looks up the user by email, then uses bcrypt.compare() to check the provided password against the stored hash. If the password matches, it issues a fresh JWT token. The same generic error message is returned for both wrong-email and wrong-password cases to prevent leaking which emails are registered.

  • Save index.js.

Seeing red squiggles in your editor?

If your editor shows errors about require or async, that's likely a linting issue. The code uses CommonJS (require) which is the default for Node.js. The code will run correctly even if the editor shows warnings.

Need help? Help me fix my Express server setup

Add task CRUD routes

The auth routes let users register and log in. Now you need the routes that actually manage tasks. Each task route uses the authenticateToken middleware to ensure only logged-in users can access their own tasks.

  • Below the login route in index.js, add the GET and POST task routes:
// Task routes
app.get('/api/tasks', authenticateToken, async (req, res) => {
  try {
    const [tasks] = await pool.execute(
      'SELECT * FROM tasks WHERE user_id = ? ORDER BY created_at DESC',
      [req.user.id]
    );
    res.json(tasks);
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

app.post('/api/tasks', authenticateToken, async (req, res) => {
  const { title } = req.body;
  if (!title) {
    return res.status(400).json({ message: 'Title is required' });
  }
  try {
    // Insert the new task
    const [result] = await pool.execute(
      'INSERT INTO tasks (user_id, title, completed) VALUES (?, ?, ?)',
      [req.user.id, title, false]
    );
    // Return the newly created task
    const [newTask] = await pool.execute('SELECT * FROM tasks WHERE id = ?', [result.insertId]);
    res.status(201).json(newTask[0]);
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

What do these routes do?

  • GET /api/tasks fetches all tasks belonging to the logged-in user, ordered by newest first.
  • POST /api/tasks creates a new task linked to the current user. It returns the full task object after insertion.
  • Both routes pass authenticateToken as the second argument. Express runs middleware before the route handler, so unauthenticated requests never reach the database query.
  • Below the POST route, add the PUT, PATCH, and DELETE routes:
app.put('/api/tasks/:id', authenticateToken, async (req, res) => {
  const { title } = req.body;
  const { id } = req.params;
  if (!title) {
    return res.status(400).json({ message: 'Title is required' });
  }
  try {
    // Update the task title
    await pool.execute(
      'UPDATE tasks SET title = ? WHERE id = ? AND user_id = ?',
      [title, id, req.user.id]
    );
    const [updated] = await pool.execute('SELECT * FROM tasks WHERE id = ?', [id]);
    res.json(updated[0]);
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

app.patch('/api/tasks/:id', authenticateToken, async (req, res) => {
  const { id } = req.params;
  try {
    // Toggle the completed status
    await pool.execute(
      'UPDATE tasks SET completed = NOT completed WHERE id = ? AND user_id = ?',
      [id, req.user.id]
    );
    const [updated] = await pool.execute('SELECT * FROM tasks WHERE id = ?', [id]);
    res.json(updated[0]);
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

app.delete('/api/tasks/:id', authenticateToken, async (req, res) => {
  const { id } = req.params;
  try {
    // Delete the task
    await pool.execute('DELETE FROM tasks WHERE id = ? AND user_id = ?', [id, req.user.id]);
    res.json({ message: 'Task deleted' });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

What do these routes do?

  • PUT /api/tasks/:id edits a task's title. The AND user_id = ? clause ensures users can only edit their own tasks.
  • PATCH /api/tasks/:id toggles the completed status using NOT completed. This flips true to false or vice versa.
  • DELETE /api/tasks/:id removes a task permanently.
  • Finally, add the server listener at the bottom of the file:
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
  • Save index.js.
  • Test that the server starts by running this from the server folder:
node index.js
  • You should see Server running on port 5000 in your terminal.
  • Press Ctrl+C to stop the server for now.

Server won't start?

  • If you see a connection error mentioning MySQL, confirm MySQL is running on your machine and that the credentials in .env match your MySQL root password.
  • If you see Cannot find module, make sure you ran npm install inside the server folder in Step 2.
  • If port 5000 is already in use, stop whatever is using it or change PORT in your .env file.

Need help? Help me debug why my Express server won't start

✔️ Awesome, I've got everything!

Great. Double check you've saved index.js and that the server starts without errors.

ⓧ I'd like to double check the full code

Here is the complete server/index.js file:

const express = require('express');
const cors = require('cors');
const mysql = require('mysql2/promise');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
require('dotenv').config();

const app = express();
app.use(cors());
app.use(express.json());

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
});

// Auth middleware
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  if (!token) return res.status(401).json({ message: 'Access token required' });

  jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
    if (err) return res.status(403).json({ message: 'Invalid token' });
    req.user = user;
    next();
  });
}

// Auth routes
app.post('/api/auth/register', async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) {
    return res.status(400).json({ message: 'Email and password are required' });
  }
  try {
    const [existing] = await pool.execute('SELECT id FROM users WHERE email = ?', [email]);
    if (existing.length > 0) {
      return res.status(409).json({ message: 'Email already registered' });
    }
    const hashedPassword = await bcrypt.hash(password, 10);
    const [result] = await pool.execute(
      'INSERT INTO users (email, password) VALUES (?, ?)',
      [email, hashedPassword]
    );
    const token = jwt.sign({ id: result.insertId, email }, process.env.JWT_SECRET, { expiresIn: '24h' });
    res.status(201).json({ token, user: { id: result.insertId, email } });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) {
    return res.status(400).json({ message: 'Email and password are required' });
  }
  try {
    const [users] = await pool.execute('SELECT * FROM users WHERE email = ?', [email]);
    if (users.length === 0) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }
    const user = users[0];
    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }
    const token = jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET, { expiresIn: '24h' });
    res.json({ token, user: { id: user.id, email: user.email } });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

// Task routes
app.get('/api/tasks', authenticateToken, async (req, res) => {
  try {
    const [tasks] = await pool.execute(
      'SELECT * FROM tasks WHERE user_id = ? ORDER BY created_at DESC',
      [req.user.id]
    );
    res.json(tasks);
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

app.post('/api/tasks', authenticateToken, async (req, res) => {
  const { title } = req.body;
  if (!title) {
    return res.status(400).json({ message: 'Title is required' });
  }
  try {
    const [result] = await pool.execute(
      'INSERT INTO tasks (user_id, title, completed) VALUES (?, ?, ?)',
      [req.user.id, title, false]
    );
    const [newTask] = await pool.execute('SELECT * FROM tasks WHERE id = ?', [result.insertId]);
    res.status(201).json(newTask[0]);
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

app.put('/api/tasks/:id', authenticateToken, async (req, res) => {
  const { title } = req.body;
  const { id } = req.params;
  if (!title) {
    return res.status(400).json({ message: 'Title is required' });
  }
  try {
    await pool.execute(
      'UPDATE tasks SET title = ? WHERE id = ? AND user_id = ?',
      [title, id, req.user.id]
    );
    const [updated] = await pool.execute('SELECT * FROM tasks WHERE id = ?', [id]);
    res.json(updated[0]);
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

app.patch('/api/tasks/:id', authenticateToken, async (req, res) => {
  const { id } = req.params;
  try {
    await pool.execute(
      'UPDATE tasks SET completed = NOT completed WHERE id = ? AND user_id = ?',
      [id, req.user.id]
    );
    const [updated] = await pool.execute('SELECT * FROM tasks WHERE id = ?', [id]);
    res.json(updated[0]);
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

app.delete('/api/tasks/:id', authenticateToken, async (req, res) => {
  const { id } = req.params;
  try {
    await pool.execute('DELETE FROM tasks WHERE id = ? AND user_id = ?', [id, req.user.id]);
    res.json({ message: 'Task deleted' });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
});

const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Your backend is fully functional. Next up, you'll build the React frontend that lets users register, log in, and interact with these API routes.

Build the React Frontend - Auth

Your Express API is ready to handle user registration and login. Now you need a frontend that lets users actually interact with those endpoints.

In this step, you will build the authentication layer of your React frontend. This includes login and register forms that call your API, plus a routing system that protects pages behind authentication.

In this step, get ready to:
  • Create Login and Register components with forms that call your API.
  • Install react-router-dom and set up client-side routing.
  • Build auth state management that tracks whether a user is logged in.
Create the login and register components

Your login and register forms need to collect an email and password, send them to the API, and store the returned JWT token. Before creating these components, you need to install the routing library they depend on.

  • In your terminal, make sure you are inside the client folder, then install react-router-dom by running:
npm install react-router-dom

Why install react-router-dom?

React apps are single-page applications. Without a router, every page lives at the same URL. react-router-dom gives you components like Route, Navigate, and Link that let you map different URLs to different components and redirect users based on their auth state.

  • Inside the client/src folder, create a new folder called components.
  • Create a new file called Login.jsx inside client/src/components/ and paste the following code:
import { useState } from 'react';
import { Link } from 'react-router-dom';

const API_URL = '/api';

function Login({ setToken }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    try {
      const response = await fetch(`${API_URL}/auth/login`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });
      const data = await response.json();
      if (!response.ok) {
        setError(data.message || 'Login failed');
        return;
      }
      setToken(data.token);
    } catch (err) {
      setError('Network error. Please try again.');
    }
  };

  return (
    <div className="auth-form">
      <h2>Login</h2>
      {error && <p className="error">{error}</p>}
      <form onSubmit={handleSubmit}>
        <input
          type="email"
          placeholder="Email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
        <input
          type="password"
          placeholder="Password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
        <button type="submit">Login</button>
      </form>
      <p>
        Don't have an account? <Link to="/register">Register</Link>
      </p>
    </div>
  );
}

export default Login;

What does this code do?

  • API_URL is set to '/api' because Vite's proxy (configured later) forwards these requests to your Express backend.
  • handleSubmit sends a POST request to /api/auth/login with the email and password. If successful, it calls setToken(data.token) to store the JWT.
  • The error state displays error messages from the API (like invalid credentials) directly in the form.
  • The Link component provides client-side navigation to the register page without a full page reload.
  • Save Login.jsx.

You won't see a change yet because the app isn't running. The next component completes the auth pair.

  • Create a new file called Register.jsx inside client/src/components/ and paste the following code:
import { useState } from 'react';
import { Link } from 'react-router-dom';

const API_URL = '/api';

function Register({ setToken }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    try {
      const response = await fetch(`${API_URL}/auth/register`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });
      const data = await response.json();
      if (!response.ok) {
        setError(data.message || 'Registration failed');
        return;
      }
      setToken(data.token);
    } catch (err) {
      setError('Network error. Please try again.');
    }
  };

  return (
    <div className="auth-form">
      <h2>Register</h2>
      {error && <p className="error">{error}</p>}
      <form onSubmit={handleSubmit}>
        <input
          type="email"
          placeholder="Email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
        <input
          type="password"
          placeholder="Password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
        <button type="submit">Register</button>
      </form>
      <p>
        Already have an account? <Link to="/login">Login</Link>
      </p>
    </div>
  );
}

export default Register;

What does this code do?

Register.jsx follows the same pattern as Login.jsx. The key difference is that it calls /api/auth/register instead of /api/auth/login. On success, the API creates the user, hashes their password, and returns a JWT token. The component immediately logs the user in by calling setToken.

  • Save Register.jsx.

Seeing red underlines in your editor?

If your editor shows import errors for react-router-dom, make sure you ran npm install react-router-dom from inside the client folder (not the server folder). Check that react-router-dom appears in your client/package.json under dependencies.

help me debug import errors in my React components

Add routing and auth state

Your components exist, but the app doesn't know when to show Login versus Register versus the task list. You need a router to map URLs to components and auth state to decide what the user can access.

  • Open client/src/main.jsx and replace its entire contents with the following code:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>
);

What does this code do?

BrowserRouter wraps your entire app and enables client-side routing. It must sit at the top level so that any component inside the app tree can use routing features like Link, Routes, and Navigate.

  • Save main.jsx.

You won't see a change yet. The next chunk builds the routing logic that uses this wrapper.

  • Open client/src/App.jsx and replace its entire contents with the following code:
import { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import Login from './components/Login.jsx';
import Register from './components/Register.jsx';
import TaskList from './components/TaskList.jsx';

function App() {
  // Check localStorage for an existing token on first load
  const [token, setToken] = useState(localStorage.getItem('token'));

  // Sync token state with localStorage whenever it changes
  useEffect(() => {
    if (token) {
      localStorage.setItem('token', token);
    } else {
      localStorage.removeItem('token');
    }
  }, [token]);

  // Clear the token to log the user out
  const handleLogout = () => {
    setToken(null);
  };

  return (
    <div className="app">
      <header>
        <h1>Task Manager</h1>
        {token && <button onClick={handleLogout}>Logout</button>}
      </header>
      <main>
        <Routes>
          <Route
            path="/login"
            element={token ? <Navigate to="/" /> : <Login setToken={setToken} />}
          />
          <Route
            path="/register"
            element={token ? <Navigate to="/" /> : <Register setToken={setToken} />}
          />
          <Route
            path="/"
            element={token ? <TaskList token={token} /> : <Navigate to="/login" />}
          />
        </Routes>
      </main>
    </div>
  );
}

export default App;

What does this code do?

  • token state initializes from localStorage so the user stays logged in after a page refresh.
  • The useEffect keeps localStorage in sync. When the token is set (login), it saves it. When cleared (logout), it removes it.
  • Each Route uses a ternary to check if a token exists. Logged-in users on /login get redirected home. Logged-out users on / get redirected to login.
  • The TaskList import will show as an error for now. You will create that component in the next step.
  • Save App.jsx.

Editor warning about TaskList import?

Your editor will likely underline the TaskList import in red because client/src/components/TaskList.jsx does not exist yet. This is expected. You will create it in the next step.

help me understand why my import is showing an error

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: Login.jsx, Register.jsx, and App.jsx.

ⓧ I'd like to double check the full code

Here is the full code for each file you created or edited in this step.

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>
);
import { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import Login from './components/Login.jsx';
import Register from './components/Register.jsx';
import TaskList from './components/TaskList.jsx';

function App() {
  const [token, setToken] = useState(localStorage.getItem('token'));

  useEffect(() => {
    if (token) {
      localStorage.setItem('token', token);
    } else {
      localStorage.removeItem('token');
    }
  }, [token]);

  const handleLogout = () => {
    setToken(null);
  };

  return (
    <div className="app">
      <header>
        <h1>Task Manager</h1>
        {token && <button onClick={handleLogout}>Logout</button>}
      </header>
      <main>
        <Routes>
          <Route
            path="/login"
            element={token ? <Navigate to="/" /> : <Login setToken={setToken} />}
          />
          <Route
            path="/register"
            element={token ? <Navigate to="/" /> : <Register setToken={setToken} />}
          />
          <Route
            path="/"
            element={token ? <TaskList token={token} /> : <Navigate to="/login" />}
          />
        </Routes>
      </main>
    </div>
  );
}

export default App;
import { useState } from 'react';
import { Link } from 'react-router-dom';

const API_URL = '/api';

function Login({ setToken }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    try {
      const response = await fetch(`${API_URL}/auth/login`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });
      const data = await response.json();
      if (!response.ok) {
        setError(data.message || 'Login failed');
        return;
      }
      setToken(data.token);
    } catch (err) {
      setError('Network error. Please try again.');
    }
  };

  return (
    <div className="auth-form">
      <h2>Login</h2>
      {error && <p className="error">{error}</p>}
      <form onSubmit={handleSubmit}>
        <input
          type="email"
          placeholder="Email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
        <input
          type="password"
          placeholder="Password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
        <button type="submit">Login</button>
      </form>
      <p>
        Don't have an account? <Link to="/register">Register</Link>
      </p>
    </div>
  );
}

export default Login;
import { useState } from 'react';
import { Link } from 'react-router-dom';

const API_URL = '/api';

function Register({ setToken }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    try {
      const response = await fetch(`${API_URL}/auth/register`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });
      const data = await response.json();
      if (!response.ok) {
        setError(data.message || 'Registration failed');
        return;
      }
      setToken(data.token);
    } catch (err) {
      setError('Network error. Please try again.');
    }
  };

  return (
    <div className="auth-form">
      <h2>Register</h2>
      {error && <p className="error">{error}</p>}
      <form onSubmit={handleSubmit}>
        <input
          type="email"
          placeholder="Email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
        <input
          type="password"
          placeholder="Password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
        <button type="submit">Register</button>
      </form>
      <p>
        Already have an account? <Link to="/login">Login</Link>
      </p>
    </div>
  );
}

export default Register;

Your auth components and routing are wired up. Next, you will build the TaskList component that lets logged-in users create, edit, and delete tasks.

Build the React Frontend - Task Management

Your authentication components are in place. Users can register and log in, but once they're logged in, they land on an empty page. The TaskList component that App.jsx already imports doesn't exist yet.

In this step, you'll build the core of the app: the task management interface. This is where users will create, view, edit, complete, and delete tasks. Every action calls the REST API endpoints you built in Step 3.

In this step, get ready to:
  • Create the TaskList component that fetches and displays tasks from the API.
  • Add create, edit, and delete functionality with inline editing.
  • Implement mark-as-complete with a checkbox and style the entire app with CSS.
Create the TaskList component

The TaskList component is the heart of the app. It needs to fetch the logged-in user's tasks from the API and display them. To authenticate its requests, it sends the JWT token as a Bearer token in the Authorization header.

  • Create a new file called TaskList.jsx inside the client/src/components folder.
  • Add the following code to set up the component's foundation:
import { useState, useEffect } from 'react';

const API_URL = '/api';

function TaskList({ token }) {
  // State for the task list and form inputs
  const [tasks, setTasks] = useState([]);
  const [newTitle, setNewTitle] = useState('');
  const [editingId, setEditingId] = useState(null);
  const [editTitle, setEditTitle] = useState('');

  // Headers object includes the JWT for authenticated requests
  const headers = {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
  };

  // Fetch tasks from the API when the component first loads
  useEffect(() => {
    fetchTasks();
  }, []);

  const fetchTasks = async () => {
    try {
      const response = await fetch(`${API_URL}/tasks`, { headers });
      const data = await response.json();
      setTasks(data);
    } catch (err) {
      console.error('Failed to fetch tasks');
    }
  };

What does this code do?

This sets up the component's data layer. Four pieces of state track the task list, new task input, which task is being edited, and the edited title.

  • headers bundles the Content-Type and Authorization header so every API call is authenticated.
  • useEffect with an empty dependency array calls fetchTasks once when the component mounts.
  • fetchTasks calls GET /api/tasks and stores the returned array in state.
  • Keep TaskList.jsx open. You won't see a change yet because the component has no return statement. The next chunk adds the task operations.
Add create, edit, and delete functionality

Each task operation maps to one of the API endpoints you built in Step 3. Creating calls POST, editing calls PUT, and deleting calls DELETE.

  • In TaskList.jsx, add these functions directly below the fetchTasks function:
  // Create a new task via POST and add it to the top of the list
  const createTask = async (e) => {
    e.preventDefault();
    if (!newTitle.trim()) return;
    try {
      const response = await fetch(`${API_URL}/tasks`, {
        method: 'POST',
        headers,
        body: JSON.stringify({ title: newTitle }),
      });
      const task = await response.json();
      setTasks([task, ...tasks]);
      setNewTitle('');
    } catch (err) {
      console.error('Failed to create task');
    }
  };

  // Enter edit mode for a specific task
  const startEditing = (task) => {
    setEditingId(task.id);
    setEditTitle(task.title);
  };

  // Save the edited title via PUT
  const saveEdit = async (id) => {
    if (!editTitle.trim()) return;
    try {
      const response = await fetch(`${API_URL}/tasks/${id}`, {
        method: 'PUT',
        headers,
        body: JSON.stringify({ title: editTitle }),
      });
      const updated = await response.json();
      setTasks(tasks.map((t) => (t.id === id ? updated : t)));
      setEditingId(null);
      setEditTitle('');
    } catch (err) {
      console.error('Failed to edit task');
    }
  };

  // Remove a task via DELETE
  const deleteTask = async (id) => {
    try {
      await fetch(`${API_URL}/tasks/${id}`, {
        method: 'DELETE',
        headers,
      });
      setTasks(tasks.filter((t) => t.id !== id));
    } catch (err) {
      console.error('Failed to delete task');
    }
  };

What does this code do?

  • createTask prevents the default form submit, sends the new title to the API, then prepends the returned task to the list.
  • startEditing stores which task ID is being edited and pre-fills the edit input with the current title.
  • saveEdit sends the updated title via PUT and swaps the old task object with the updated one in state.
  • deleteTask calls DELETE on the API and removes the task from the local array using .filter().
  • You still won't see a change yet. The next chunk adds the toggle function and the JSX that renders everything to the screen.
Implement mark-as-complete and render the task list

The last function toggles a task's completed status by calling the PATCH endpoint. Then the JSX renders the full interface: a form to create tasks, and a list where each task has a checkbox, edit/delete buttons, or an inline edit input.

  • In TaskList.jsx, add the toggleComplete function below deleteTask:
  // Toggle the completed status via PATCH
  const toggleComplete = async (id) => {
    try {
      const response = await fetch(`${API_URL}/tasks/${id}`, {
        method: 'PATCH',
        headers,
      });
      const updated = await response.json();
      setTasks(tasks.map((t) => (t.id === id ? updated : t)));
    } catch (err) {
      console.error('Failed to update task');
    }
  };

Why PATCH instead of PUT?

PUT replaces the entire resource (used for editing the title). PATCH modifies a single field. Here the server flips the completed boolean without needing you to send a body.

  • Now add the return statement and closing export below toggleComplete to complete the component:
  return (
    <div className="task-list">
      <form onSubmit={createTask} className="task-form">
        <input
          type="text"
          placeholder="Add a new task..."
          value={newTitle}
          onChange={(e) => setNewTitle(e.target.value)}
        />
        <button type="submit">Add</button>
      </form>

      <ul>
        {tasks.map((task) => (
          <li key={task.id} className={task.completed ? 'completed' : ''}>
            <input
              type="checkbox"
              checked={task.completed}
              onChange={() => toggleComplete(task.id)}
            />
            {editingId === task.id ? (
              <>
                <input
                  type="text"
                  value={editTitle}
                  onChange={(e) => setEditTitle(e.target.value)}
                  className="edit-input"
                />
                <button onClick={() => saveEdit(task.id)}>Save</button>
                <button onClick={() => setEditingId(null)}>Cancel</button>
              </>
            ) : (
              <>
                <span className="task-title">{task.title}</span>
                <button onClick={() => startEditing(task)}>Edit</button>
                <button onClick={() => deleteTask(task.id)}>Delete</button>
              </>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TaskList;

What does this JSX do?

  • The form at the top calls createTask on submit to add a new task.
  • Each <li> gets the completed class when the task is done, which applies a strikethrough style.
  • The checkbox calls toggleComplete to flip the status.
  • A conditional check on editingId switches between showing the task title with Edit/Delete buttons and an inline edit input with Save/Cancel buttons.
  • Save TaskList.jsx.

Seeing a syntax error in your editor?

  • Make sure the opening function TaskList({ token }) { from the first chunk has a matching closing } right before export default TaskList;.
  • Check that the JSX fragment wrappers <>...</> are present around the edit input group and the title/button group.
  • help me find the syntax error in my TaskList.jsx

Now the component is complete. It needs CSS to look presentable.

  • Open client/src/index.css (this file was created by Vite's scaffold but is likely empty or minimal).
  • Replace its entire contents with the following styles:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #f5f5f5;
  color: #333;
  line-height: 1.6;
}

.app {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 30px;
}

header h1 {
  font-size: 1.8rem;
}

header button {
  background: #e74c3c;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}

.auth-form {
  background: white;
  padding: 30px;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.auth-form h2 {
  margin-bottom: 20px;
}

.auth-form form {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.auth-form input {
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.auth-form button {
  padding: 10px;
  background: #3498db;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
}

.auth-form p {
  margin-top: 15px;
  text-align: center;
}

.error {
  color: #e74c3c;
  margin-bottom: 10px;
}

.task-form {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.task-form input {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.task-form button {
  padding: 10px 20px;
  background: #27ae60;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

ul {
  list-style: none;
}

li {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px;
  background: white;
  border-radius: 4px;
  margin-bottom: 8px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

li.completed .task-title {
  text-decoration: line-through;
  color: #999;
}

.task-title {
  flex: 1;
}

.edit-input {
  flex: 1;
  padding: 6px;
  border: 1px solid #3498db;
  border-radius: 4px;
}

li button {
  padding: 4px 10px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.85rem;
}

li button:nth-of-type(1) {
  background: #f39c12;
  color: white;
}

li button:nth-of-type(2) {
  background: #e74c3c;
  color: white;
}

What do these styles do?

  • The .app class centers the layout at 600px max width.
  • .auth-form styles the Login and Register card with padding and a shadow.
  • .task-form puts the input and Add button side by side with a green accent.
  • li.completed .task-title applies the strikethrough and gray color for completed tasks.
  • The two li button:nth-of-type rules color the first action button orange (Edit/Save) and the second red (Delete/Cancel).
  • Save index.css.
  • You won't see the full app working until the next step when you start both servers. The frontend needs the Express backend running to fetch and create tasks.

Worried the CSS file is in the wrong place?

  • Check that your file is at client/src/index.css. It's already imported in main.jsx via import './index.css'.
  • If Vite's scaffold created an App.css file, you can ignore or delete it. All styles live in index.css for this project.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete TaskList.jsx file:

import { useState, useEffect } from 'react';

const API_URL = '/api';

function TaskList({ token }) {
  const [tasks, setTasks] = useState([]);
  const [newTitle, setNewTitle] = useState('');
  const [editingId, setEditingId] = useState(null);
  const [editTitle, setEditTitle] = useState('');

  const headers = {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
  };

  useEffect(() => {
    fetchTasks();
  }, []);

  const fetchTasks = async () => {
    try {
      const response = await fetch(`${API_URL}/tasks`, { headers });
      const data = await response.json();
      setTasks(data);
    } catch (err) {
      console.error('Failed to fetch tasks');
    }
  };

  const createTask = async (e) => {
    e.preventDefault();
    if (!newTitle.trim()) return;
    try {
      const response = await fetch(`${API_URL}/tasks`, {
        method: 'POST',
        headers,
        body: JSON.stringify({ title: newTitle }),
      });
      const task = await response.json();
      setTasks([task, ...tasks]);
      setNewTitle('');
    } catch (err) {
      console.error('Failed to create task');
    }
  };

  const toggleComplete = async (id) => {
    try {
      const response = await fetch(`${API_URL}/tasks/${id}`, {
        method: 'PATCH',
        headers,
      });
      const updated = await response.json();
      setTasks(tasks.map((t) => (t.id === id ? updated : t)));
    } catch (err) {
      console.error('Failed to update task');
    }
  };

  const startEditing = (task) => {
    setEditingId(task.id);
    setEditTitle(task.title);
  };

  const saveEdit = async (id) => {
    if (!editTitle.trim()) return;
    try {
      const response = await fetch(`${API_URL}/tasks/${id}`, {
        method: 'PUT',
        headers,
        body: JSON.stringify({ title: editTitle }),
      });
      const updated = await response.json();
      setTasks(tasks.map((t) => (t.id === id ? updated : t)));
      setEditingId(null);
      setEditTitle('');
    } catch (err) {
      console.error('Failed to edit task');
    }
  };

  const deleteTask = async (id) => {
    try {
      await fetch(`${API_URL}/tasks/${id}`, {
        method: 'DELETE',
        headers,
      });
      setTasks(tasks.filter((t) => t.id !== id));
    } catch (err) {
      console.error('Failed to delete task');
    }
  };

  return (
    <div className="task-list">
      <form onSubmit={createTask} className="task-form">
        <input
          type="text"
          placeholder="Add a new task..."
          value={newTitle}
          onChange={(e) => setNewTitle(e.target.value)}
        />
        <button type="submit">Add</button>
      </form>

      <ul>
        {tasks.map((task) => (
          <li key={task.id} className={task.completed ? 'completed' : ''}>
            <input
              type="checkbox"
              checked={task.completed}
              onChange={() => toggleComplete(task.id)}
            />
            {editingId === task.id ? (
              <>
                <input
                  type="text"
                  value={editTitle}
                  onChange={(e) => setEditTitle(e.target.value)}
                  className="edit-input"
                />
                <button onClick={() => saveEdit(task.id)}>Save</button>
                <button onClick={() => setEditingId(null)}>Cancel</button>
              </>
            ) : (
              <>
                <span className="task-title">{task.title}</span>
                <button onClick={() => startEditing(task)}>Edit</button>
                <button onClick={() => deleteTask(task.id)}>Delete</button>
              </>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TaskList;

And here is the complete index.css file:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #f5f5f5;
  color: #333;
  line-height: 1.6;
}

.app {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 30px;
}

header h1 {
  font-size: 1.8rem;
}

header button {
  background: #e74c3c;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}

.auth-form {
  background: white;
  padding: 30px;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.auth-form h2 {
  margin-bottom: 20px;
}

.auth-form form {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.auth-form input {
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.auth-form button {
  padding: 10px;
  background: #3498db;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
}

.auth-form p {
  margin-top: 15px;
  text-align: center;
}

.error {
  color: #e74c3c;
  margin-bottom: 10px;
}

.task-form {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.task-form input {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.task-form button {
  padding: 10px 20px;
  background: #27ae60;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

ul {
  list-style: none;
}

li {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px;
  background: white;
  border-radius: 4px;
  margin-bottom: 8px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

li.completed .task-title {
  text-decoration: line-through;
  color: #999;
}

.task-title {
  flex: 1;
}

.edit-input {
  flex: 1;
  padding: 6px;
  border: 1px solid #3498db;
  border-radius: 4px;
}

li button {
  padding: 4px 10px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.85rem;
}

li button:nth-of-type(1) {
  background: #f39c12;
  color: white;
}

li button:nth-of-type(2) {
  background: #e74c3c;
  color: white;
}

Your frontend is fully built. Next up, you'll start both servers and test the complete flow from registration to task management.

Connect and Test the Full App

You have a complete React frontend and a fully functional Express API. The last piece is connecting them so that the frontend can talk to the backend during development.

Right now, your React app makes requests to /api paths, but the Vite dev server doesn't know where to send those requests. In this step, you'll configure a proxy to forward API calls to your Express server, start everything up, and test the full application end-to-end.

In this step, get ready to:
  • Configure the Vite dev server to proxy API requests to Express.
  • Start both the backend and frontend servers.
  • Test registration, login, and full task CRUD functionality.
Configure the Vite proxy

Your React components call /api/tasks and /api/auth/login, but the Vite dev server runs on port 5173 while Express runs on port 5000. A proxy tells Vite to forward any request starting with /api to the Express server automatically.

  • Create a new file called vite.config.js inside your client folder (at the same level as package.json).
  • Paste in the following configuration:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// Configure Vite with React plugin and API proxy
export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      // Forward all /api requests to the Express backend
      '/api': {
        target: 'http://localhost:5000',
        changeOrigin: true,
      },
    },
  },
});

What does this config do?

The server.proxy setting intercepts any request the browser makes to a path starting with /api and reroutes it to http://localhost:5000. This avoids CORS issues during development because, from the browser's perspective, the API lives on the same origin as the frontend.

  • Save vite.config.js.

Why a proxy instead of just CORS?

Your Express server already has cors() enabled as a fallback. The proxy is the preferred approach because it mirrors how production deployments work, where the frontend and API share the same domain. It also simplifies your frontend code since you only need relative paths like /api/tasks instead of full URLs.

Start both servers

You need two terminal windows open at the same time. One runs the Express backend, and the other runs the Vite dev server for React.

  • Open a terminal window and navigate to your server folder inside task-manager.
  • Start the Express backend by running:
node index.js
  • You should see Server running on port 5000 printed in the terminal.

Seeing a connection error?

  • If you see an error about MySQL connection refused, make sure the MySQL service is running on your machine.
  • If the error mentions authentication, double-check that the DB_PASSWORD value in your server/.env file matches the root password you set during MySQL installation.
  • If port 5000 is already in use, check for other processes using that port or change the PORT value in .env.

Help me debug my Express server startup error.

  • Open a second terminal window and navigate to your client folder inside task-manager.
  • Start the React frontend by running:
npm run dev
  • The terminal will show a local URL. Open http://localhost:5173 in your browser.
  • You should see the Login page with email and password fields.

Seeing a blank page or error?

  • Make sure you ran npm run dev from inside the client folder, not the project root.
  • Check the terminal for any import or syntax errors in your React components.
  • Confirm that vite.config.js is saved directly inside the client folder (not inside src).

Help me fix my Vite dev server or blank page issue.

Test the complete flow

With both servers running, you can now test every feature of your Task Manager. This walkthrough confirms that registration, authentication, and all CRUD operations work correctly.

  • On the Login page, click the Register link at the bottom.
  • Enter an email address (e.g. test@example.com) and a password, then click Register.
  • You should be redirected to the main task view with an empty task list and an input field that says "Add a new task...".

Now test creating, completing, editing, and deleting tasks.

  • Type Buy groceries into the input field and click Add.
  • Add two more tasks (e.g. Walk the dog and Finish project).
  • Click the checkbox next to one task to mark it as complete. The task title should show a strikethrough style.
  • Click Edit on another task, change the title, then click Save.
  • Click Delete on one task to remove it from the list.

Finally, verify that your data persists in the MySQL database.

  • Refresh the browser page.
  • Your remaining tasks should still appear exactly as you left them. Completed tasks still have the strikethrough. The edited task still shows its new title.

Why does the data persist?

When you refresh the page, the React app re-mounts and calls fetchTasks() on load. This sends a GET request to your Express API, which reads the tasks from MySQL. Because the data lives in the database (not just in React state), it survives page refreshes and even server restarts.

  • Test logging out by clicking the Logout button in the header.
  • You should be redirected back to the Login page.
  • Log back in with the same email and password you registered with. Your tasks should all still be there.

Tasks not loading or getting a network error?

  • Open your browser's DevTools (F12) and check the Console and Network tabs for error details.
  • A 401 error means the JWT token is missing or expired. Try logging out and logging back in.
  • A 500 error means something went wrong on the server side. Check the terminal running your Express server for detailed error output.

Help me debug why my tasks are not loading.

✔️ Awesome, I've got everything!

Great. Double check you have saved vite.config.js and both servers are running.

ⓧ I'd like to double check the full code

Here is the complete vite.config.js file (the only new file created in this step):

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:5000',
        changeOrigin: true,
      },
    },
  },
});

Your full-stack Task Manager is alive. You have a React frontend communicating with an Express API, authenticated with JWT tokens, and persisting data in MySQL. Next up is an optional Secret Mission where you can add filtering and search to make the task list even more powerful.

Secret mission

Add Filtering and Search

Your task list works, but as it grows, finding specific tasks gets harder. Add the ability to filter tasks by status (all, active, completed) and search by title so users can quickly find what they need.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The task-manager project folder on your Desktop (contains server and client subfolders).
  • The task_manager MySQL database (stores your users and tasks tables).

Keep everything running

No action needed. Choose this if you are still actively building or want to come back and extend the project soon.

  • Your project files stay on your Desktop in the task-manager folder.
  • The task_manager database remains in MySQL, ready to use next time you start the Express server.
  • Node.js and MySQL remain installed on your machine.

Pause - I'll come back to this later

Stop running processes to free up system memory. Your files and database stay intact so you can resume later.

  • In each terminal window where the Express server or Vite dev server is running, press Ctrl+C to stop the process.
  • If MySQL is running as a background service and you want to free memory, stop it with the following command.

macOS

brew services stop mysql

Windows

net stop MySQL
  • To resume later, restart MySQL and run node index.js from the server folder and npm run dev from the client folder.

Delete - I don't want to use this again

Remove all project files and the database completely. This is irreversible.

  • Stop both the Express server and Vite dev server by pressing Ctrl+C in each terminal window.
  • Drop the MySQL database by opening a MySQL session and running the following command:
DROP DATABASE IF EXISTS task_manager;
  • Exit the MySQL session by typing exit and pressing Enter.
  • Delete the project folder from your Desktop.

macOS

rm -rf ~/Desktop/task-manager

Windows

Remove-Item -Recurse -Force ~\Desktop\task-manager
  • Confirm the folder is gone by checking your Desktop.

Nice Work!

Nice Work!

Nice work! You just built a full-stack Task Manager application with user authentication, a REST API, and a MySQL database.

You've learned how to:

  • Built a React frontend with login and register forms that store JWT tokens and protect routes using react-router-dom.
  • Created an Express REST API with JWT-protected routes for full CRUD operations on tasks, including password hashing with bcryptjs.
  • Designed and queried a MySQL database with users and tasks tables, connecting your frontend and backend into a working full-stack application.
  • Secret Mission: Added task filtering by status and a search feature to make the task list more usable.

Ready to quiz yourself?