Real-Time Collaborative Task Board
Build a MERN stack Kanban board with JWT auth and live Socket.io sync.
Introduction
30 Second Summary
Think about how tools like Trello or Jira update in real time when a teammate moves a task card. One person drags a ticket to "Done" and everyone else sees it happen instantly. That kind of live collaboration is powered by WebSockets working alongside a traditional REST API.
In this project, you will build a full-stack collaborative task board where multiple users can create, move, and delete tasks across columns with real-time synchronization.
What You'll Build
A Kanban-style task board with three columns (To Do, In Progress, Done) powered by a MERN stack, JWT authentication, and Socket.io for live updates across all connected clients.
By the end of this project, you'll have:
- An Express REST API with JWT-based registration and login protecting all task endpoints.
- A React frontend built with Vite that displays tasks in a three-column board layout.
- Real-time synchronization via WebSockets so that creating or moving a task in one browser tab updates every other connected tab instantly.
- Secret Mission: Add drag-and-drop so users can visually drag task cards between columns.
Are there any prerequisites?
This project assumes basic familiarity with the MERN stack (MongoDB, Express, React, Node.js). You should have Node.js (v20.19+ or v22.12+), VS Code, and Git installed on your machine.
Before We Start
A real-time collaborative task board needs several pieces working together: a backend API, a database, a frontend, and live communication between them all.
In this step, you will set up the full project structure with all dependencies installed so that you can start building features immediately in the next steps.
In this step, get ready to:
- Create the project structure and initialize the Express backend with all dependencies.
- Scaffold the React frontend with Vite and install the Socket.io client.
- Set up MongoDB Atlas and configure environment variables.
Create the project structure and initialize the backend
Your project will follow a monorepo structure with two separate directories: one for the backend server and one for the React frontend.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
Before creating the project, confirm that Node.js is ready on your machine.
- Check your Node.js version by running this command:
node --version
You should see v20.19 or higher (or v22.12 or higher). This project requires Node.js 20.19+ or 22.12+ because Vite needs it.
Seeing an older version or command not found?
If you see a version below 20.19, visit nodejs.org and download the latest LTS installer for your operating system. If you see "command not found", Node.js is not installed yet. Download it from nodejs.org before continuing.
Help me fix my Node.js version
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project root folder and the server directory by running these commands:
mkdir taskboard
cd taskboard
mkdir server
cd server
- Initialize the backend Node.js project by running:
npm init -y
What does npm init -y do?
This creates a package.json file with default values. The -y flag accepts all the defaults so you do not have to answer prompts. You will update a few fields shortly.
- Install the backend dependencies by running:
npm install express mongoose jsonwebtoken bcryptjs socket.io cors dotenv
What are these packages?
express is the web framework for your API. mongoose connects to MongoDB. jsonwebtoken creates JWT tokens for authentication.
bcryptjs hashes passwords securely. socket.io enables real-time WebSocket communication. cors allows the frontend to talk to the backend. dotenv loads environment variables from a .env file.
You should see a success message listing the installed packages and their versions.
Now update your package.json to set the correct entry point and start script for the server.
- Open server/package.json in the VS Code editor by clicking on it in the Explorer sidebar.
- Replace the entire contents of the file with this code:
{
"name": "taskboard-server",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"mongoose": "^9.7.3",
"socket.io": "^4.8.3"
}
}
What changed?
You set main to server.js because that will be the file that starts your Express app. The start script lets you run the server with npm start later. The dependency versions may differ slightly from what npm installed. That is fine.
- Save package.json.
- Create the folder structure for your server code by running:
mkdir models routes middleware
- Confirm the folders exist by running:
ls
You should see middleware, models, node_modules, package-lock.json, package.json, and routes in the output.
Missing folders or packages?
Make sure your terminal is inside the server folder. Run pwd to confirm. You should see a path ending in taskboard/server. If not, run cd ~/Desktop/taskboard/server and try again.
Help me fix my project folder structure
Scaffold the React frontend with Vite
The frontend is a React app that Vite will scaffold for you. Vite is a fast build tool that creates a ready-to-use React project in seconds.
- Navigate back to the project root by running:
cd ..
You should now be inside the taskboard folder.
- Scaffold a new React project called client by running:
npm create vite@latest client -- --template react
What does this command do?
npm create vite@latest runs the Vite scaffolding tool. client is the folder name. The -- --template react part tells Vite to use the React template so you get a working React project out of the box.
- Move into the new client folder and install its dependencies by running:
cd client
npm install
- Install the Socket.io client library by running:
npm install socket.io-client
Why socket.io-client?
socket.io-client is the browser-side companion to the socket.io server you installed in the backend. It lets your React app receive real-time events pushed from the server without polling.
- Test that the frontend works by running:
npm run dev
The Vite dev server should start and print a URL like http://localhost:5173. Open that URL in your browser.
You should see the default Vite + React welcome page with a spinning logo.
- Stop the dev server by pressing Ctrl+C in the terminal. You will restart it later after building the board.
Vite not starting?
If you see a version error, confirm your Node.js version is 20.19+ or 22.12+ with node --version. Vite requires one of these minimum versions.
If the command hangs, ensure you are inside the client folder by checking with pwd.
Help me debug why Vite won't start
Set up MongoDB Atlas and create environment variables
MongoDB Atlas is a cloud database service that hosts your MongoDB data. The free tier gives you 512 MB of storage, which is more than enough for this project.
- Open your browser and go to mongodb.com.
- Create a free account if you do not have one already. Sign in if you do.
- On the Project Overview page, click the Create button to create a new cluster.
- Select the M0 option. This is the free tier that never expires.
- Choose a cloud provider (AWS, GCP, or Azure) and select the region closest to you.
- Give your cluster a name (the default name is fine) and click Create.
Atlas will take a few seconds to deploy your cluster. Next, it opens the Security Quickstart wizard.
- Create a database user. Type a username and a password (or copy the suggested password). Click Create Database User.
- Add your IP address. Click Add My Current IP Address.
- Click Finish and Close, then click Go to Overview.
Save your credentials somewhere safe
Write down the database username and password you just created. You will paste them into your connection string in the next step. If you forget the password, you can reset it later in the Atlas Database Access page.
Now retrieve your connection string so the backend can connect to the database.
- On the cluster overview page, click the Connect button next to your cluster.
- Click Drivers under the "Connect to your application" section.
- Select Node.js as the driver.
- Copy the connection string. It looks like: mongodb+srv://<username>:<password>@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majority.
With the connection string copied, create the .env file that stores your backend configuration.
- In the VS Code terminal, navigate back to the server folder by running:
cd ~/Desktop/taskboard/server
- Create a new file called .env in the server folder and paste the following content:
MONGO_URI=mongodb+srv://your-username:your-password@cluster0.xxxxx.mongodb.net/taskboard?retryWrites=true&w=majority
JWT_SECRET=your-secret-key-here
PORT=5000
What are these environment variables?
MONGO_URI is the connection string to your Atlas database. Replace your-username, your-password, and cluster0.xxxxx with the values from your Atlas connection string. Add /taskboard before the ? to name your database.
JWT_SECRET is a random string used to sign authentication tokens. Replace your-secret-key-here with any random phrase (e.g. my-super-secret-2026). PORT sets the backend server port to 5000.
- Save .env.
Connection string not working later?
The most common issue is forgetting to replace the placeholder password. Double-check that you pasted your actual database user password (not the placeholder text). If your password contains special characters like @ or #, you need to percent-encode them.
Help me troubleshoot my MongoDB connection string
- Verify the backend is ready by running this quick test in the terminal:
node -e "console.log('Backend ready')"
You should see Backend ready printed in the terminal. This confirms Node.js can run code in your server directory.
✔️ Awesome, I've got everything!
Great. Double check you have saved both package.json and .env in the server folder.
ⓧ I'd like to double check the full code
Here is what your server/package.json should look like:
{
"name": "taskboard-server",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"mongoose": "^9.7.3",
"socket.io": "^4.8.3"
}
}
And here is what your server/.env should look like (with your own values substituted):
MONGO_URI=mongodb+srv://your-username:your-password@cluster0.xxxxx.mongodb.net/taskboard?retryWrites=true&w=majority
JWT_SECRET=your-secret-key-here
PORT=5000
Your project structure is ready with both the backend and frontend fully initialized. Next up, you will build the user authentication system with JWT tokens so users can register and log in to the task board.
Build User Authentication
Your project structure is ready with all dependencies installed. Now it is time to build something users can interact with.
Every collaborative app needs to know who is making changes. In this step, you will build the authentication system that lets users register and log in. The server will issue a JWT (JSON Web Token) on successful login, and a middleware function will verify that token on every protected request.
In this step, get ready to:
- Create the User model and auth middleware.
- Create auth routes for registration and login.
- Wire up Express and test the auth flow.
Create the User model and auth middleware
Before writing any routes, you need two foundational pieces. A Mongoose schema defines what a user looks like in the database. An auth middleware function checks incoming requests for a valid token.
- In VS Code, create a new file at server/models/User.js and add the following code:
const mongoose = require("mongoose");
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true,
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
},
password: {
type: String,
required: true,
},
createdAt: {
type: Date,
default: Date.now,
},
});
module.exports = mongoose.model("User", userSchema);
What does this code do?
This defines the shape of every user document stored in MongoDB.
- username and email are both marked unique, so no two users can share either value.
- password stores the hashed version of the password (never the plain text).
- mongoose.model("User", userSchema) compiles the schema into a model you can query and save with.
- Save server/models/User.js.
Seeing a red underline on require?
This can happen if VS Code's JavaScript language service is not pointing at the server/ folder. Make sure you opened the taskboard root folder (not a parent folder) in VS Code so that the node_modules inside server/ are discoverable.
help me fix require underlines in my server code
- Create a new file at server/middleware/auth.js and add the following code:
const jwt = require("jsonwebtoken");
// Middleware that checks for a valid JWT in the Authorization header
function auth(req, res, next) {
const authHeader = req.headers.authorization;
// Reject if no token or wrong format
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ message: "No token provided" });
}
// Extract the token after "Bearer "
const token = authHeader.split(" ")[1];
try {
// Verify the token and attach userId to the request
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.userId = decoded.userId;
next();
} catch (error) {
return res.status(401).json({ message: "Invalid token" });
}
}
module.exports = auth;
What does this middleware do?
Every protected API request must include an Authorization: Bearer <token> header. This middleware extracts the token, verifies it against your JWT_SECRET, and attaches the user's ID to req.userId for downstream route handlers.
If the token is missing or invalid, the middleware short-circuits the request with a 401 status. The route handler never runs.
- Save server/middleware/auth.js.
You won't see a visible change yet. These two files provide the foundation that the auth routes will use next.
Create the auth routes
With the User model and middleware ready, you can now build the two endpoints every user hits first: register and login.
- Create a new file at server/routes/auth.js and add the following code:
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const User = require("../models/User");
const router = express.Router();
// POST /register - Create a new user and return a JWT
router.post("/register", async (req, res) => {
try {
const { username, email, password } = req.body;
// Check if a user with the same email or username already exists
const existingUser = await User.findOne({
$or: [{ email }, { username }],
});
if (existingUser) {
return res.status(400).json({ message: "User already exists" });
}
// Hash the password before saving
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// Create and save the new user
const user = new User({ username, email, password: hashedPassword });
await user.save();
// Generate a JWT that expires in 7 days
const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, {
expiresIn: "7d",
});
res.status(201).json({ token, username: user.username });
} catch (error) {
res.status(500).json({ message: "Server error" });
}
});
// POST /login - Verify credentials and return a JWT
router.post("/login", async (req, res) => {
try {
const { email, password } = req.body;
// Find user by email
const user = await User.findOne({ email });
if (!user) {
return res.status(400).json({ message: "Invalid credentials" });
}
// Compare submitted password with stored hash
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(400).json({ message: "Invalid credentials" });
}
// Generate a JWT that expires in 7 days
const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, {
expiresIn: "7d",
});
res.json({ token, username: user.username });
} catch (error) {
res.status(500).json({ message: "Server error" });
}
});
module.exports = router;
What does this code do?
The register route hashes the password with bcryptjs before saving, so plain-text passwords never touch the database. It returns a signed JWT the frontend can store.
The login route finds the user by email, compares the submitted password against the stored hash, and returns the same JWT shape on success. Both routes use a generic error message for invalid credentials to avoid revealing whether an email exists.
- Save server/routes/auth.js.
Seeing a typo warning in your route?
Double-check that the import path ../models/User matches the exact filename you created earlier. If you named the file user.js (lowercase), the import will fail on case-sensitive file systems like Linux.
help me fix my auth route imports
Now you need a server entry point that connects all these pieces together.
- Create a new file at server/server.js and add the following code:
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const mongoose = require("mongoose");
const cors = require("cors");
require("dotenv").config();
const authRoutes = require("./routes/auth");
// Create Express app and HTTP server
const app = express();
const server = http.createServer(app);
// Attach Socket.io to the HTTP server for real-time features later
const io = new Server(server, {
cors: {
origin: "http://localhost:5173",
methods: ["GET", "POST", "PUT", "DELETE"],
},
});
// Middleware
app.use(cors({ origin: "http://localhost:5173" }));
app.use(express.json());
// Routes
app.use("/api/auth", authRoutes);
// Socket.io connection logging
io.on("connection", (socket) => {
console.log("User connected:", socket.id);
socket.on("disconnect", () => {
console.log("User disconnected:", socket.id);
});
});
// Connect to MongoDB and start the server
mongoose
.connect(process.env.MONGO_URI)
.then(() => {
const PORT = process.env.PORT || 5000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
})
.catch((err) => console.error("MongoDB connection error:", err));
What does this code do?
This is the entry point for your backend. It wires together Express, Socket.io, and MongoDB in one file.
- http.createServer(app) creates an HTTP server that both Express and Socket.io share. You cannot use app.listen() directly when Socket.io is attached.
- CORS is configured for http://localhost:5173 (the Vite dev server port) so the React frontend can talk to this backend.
- mongoose.connect() reads the connection string from your .env file and only starts listening for requests after the database connection succeeds.
- Save server/server.js.
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file at this point:
const mongoose = require("mongoose");
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true,
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
},
password: {
type: String,
required: true,
},
createdAt: {
type: Date,
default: Date.now,
},
});
module.exports = mongoose.model("User", userSchema);
const jwt = require("jsonwebtoken");
function auth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ message: "No token provided" });
}
const token = authHeader.split(" ")[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.userId = decoded.userId;
next();
} catch (error) {
return res.status(401).json({ message: "Invalid token" });
}
}
module.exports = auth;
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const User = require("../models/User");
const router = express.Router();
router.post("/register", async (req, res) => {
try {
const { username, email, password } = req.body;
const existingUser = await User.findOne({
$or: [{ email }, { username }],
});
if (existingUser) {
return res.status(400).json({ message: "User already exists" });
}
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
const user = new User({ username, email, password: hashedPassword });
await user.save();
const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, {
expiresIn: "7d",
});
res.status(201).json({ token, username: user.username });
} catch (error) {
res.status(500).json({ message: "Server error" });
}
});
router.post("/login", async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.status(400).json({ message: "Invalid credentials" });
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(400).json({ message: "Invalid credentials" });
}
const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, {
expiresIn: "7d",
});
res.json({ token, username: user.username });
} catch (error) {
res.status(500).json({ message: "Server error" });
}
});
module.exports = router;
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const mongoose = require("mongoose");
const cors = require("cors");
require("dotenv").config();
const authRoutes = require("./routes/auth");
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "http://localhost:5173",
methods: ["GET", "POST", "PUT", "DELETE"],
},
});
app.use(cors({ origin: "http://localhost:5173" }));
app.use(express.json());
app.use("/api/auth", authRoutes);
io.on("connection", (socket) => {
console.log("User connected:", socket.id);
socket.on("disconnect", () => {
console.log("User disconnected:", socket.id);
});
});
mongoose
.connect(process.env.MONGO_URI)
.then(() => {
const PORT = process.env.PORT || 5000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
})
.catch((err) => console.error("MongoDB connection error:", err));
Test the auth flow
All four files are saved. Time to start the server and confirm that registration and login actually work.
- Open a terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Navigate to the server folder and start the server by running these commands:
cd server
node server.js
- You should see Server running on port 5000 in the terminal. This confirms MongoDB connected successfully and your server is listening.
Seeing a MongoDB connection error?
Make sure your MONGO_URI in server/.env contains the correct connection string from MongoDB Atlas. Check that you replaced your-username and your-password with your actual database user credentials.
Also confirm that your Atlas cluster allows connections from your IP address. In Atlas, go to Network Access and add your current IP or 0.0.0.0/0 for development.
help me fix my MongoDB connection error
Now test the register endpoint. You will use your browser's developer console to send a POST request.
- Open any browser tab (a blank tab works fine).
- Press F12 (Windows) or Cmd+Option+I (macOS) to open Developer Tools.
- Click the Console tab.
- Paste the following code and press Enter:
fetch('http://localhost:5000/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'testuser', email: 'test@example.com', password: 'password123' })
}).then(res => res.json()).then(data => console.log(data))
- You should see a JSON response with a token field and a username field logged in the console.
Now test the login endpoint with the same credentials.
- Paste the following code into the same console and press Enter:
fetch('http://localhost:5000/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'test@example.com', password: 'password123' })
}).then(res => res.json()).then(data => console.log(data))
- You should see another JSON response with a token and username. Both tokens are valid JWTs that expire in 7 days.
Why use the browser console for testing?
The browser's built-in fetch() function lets you send HTTP requests without installing extra tools. Since your server has CORS enabled for all origins during development, any browser tab can reach http://localhost:5000.
Getting a network error or empty response?
Make sure your server is still running in the terminal (you should see Server running on port 5000). If you closed the terminal or it crashed, restart it with node server.js from inside the server/ folder.
If you see User already exists on the register call, that means you already registered successfully on a previous attempt. Skip straight to the login test.
help me debug my auth endpoint errors
Your authentication system is complete. Users can register, log in, and receive tokens that prove their identity. Next up, you will build the Task API so users can create and manage tasks on the board.
Create the Task API
Your authentication system is working. Users can register and log in, and you have a JWT token to prove their identity.
Now you need the core feature: tasks. In this step, you will build the Task data model and create REST API endpoints that let authenticated users create, read, update, and delete tasks across three columns.
In this step, get ready to:
- Create the Task model with status tracking for board columns.
- Build protected CRUD routes for tasks.
- Wire up Socket.io to broadcast task changes in real time.
Create the Task model
Each task on the board needs a title, a description, a status that tracks which column it belongs to, and a reference back to the user who created it.
- Create a new file called Task.js inside the server/models/ folder.
- Add the following code to define the Task schema:
const mongoose = require("mongoose");
const taskSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
},
description: {
type: String,
default: "",
},
status: {
type: String,
enum: ["todo", "in-progress", "done"],
default: "todo",
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
},
createdAt: {
type: Date,
default: Date.now,
},
});
module.exports = mongoose.model("Task", taskSchema);
What does this code do?
This schema defines the shape of every task document in MongoDB.
- The status field uses an enum to restrict values to "todo", "in-progress", or "done". These map directly to your board's three columns.
- The user field stores an ObjectId referencing the User who created the task. This lets you track ownership.
- New tasks default to "todo" status, so they appear in the first column automatically.
- Save server/models/Task.js.
- Confirm the file exists by running:
ls server/models/
You should see both Task.js and User.js listed.
Don't see Task.js?
Make sure you created the file inside server/models/ and not in the root taskboard/ folder.
help me find where my Task.js file ended up
Create task routes with Socket.io broadcasting
The task routes handle all CRUD operations. Each route is protected by the auth middleware, so only logged-in users can access them. The special part: every time a task is created, updated, or deleted, the route broadcasts the change to all connected clients via Socket.io.
- Create a new file called tasks.js inside the server/routes/ folder.
- Add the following code to define all four task endpoints:
const express = require("express");
const Task = require("../models/Task");
const auth = require("../middleware/auth");
module.exports = function (io) {
const router = express.Router();
// GET all tasks, sorted newest first
router.get("/", auth, async (req, res) => {
try {
const tasks = await Task.find().sort({ createdAt: -1 });
res.json(tasks);
} catch (error) {
res.status(500).json({ message: "Server error" });
}
});
// POST a new task (defaults to "todo" status)
router.post("/", auth, async (req, res) => {
try {
const { title, description } = req.body;
const task = new Task({
title,
description,
user: req.userId,
});
await task.save();
io.emit("task-created", task);
res.status(201).json(task);
} catch (error) {
res.status(500).json({ message: "Server error" });
}
});
// PUT to update a task (move between columns by changing status)
router.put("/:id", auth, async (req, res) => {
try {
const { title, description, status } = req.body;
const task = await Task.findByIdAndUpdate(
req.params.id,
{ title, description, status },
{ new: true }
);
if (!task) {
return res.status(404).json({ message: "Task not found" });
}
io.emit("task-updated", task);
res.json(task);
} catch (error) {
res.status(500).json({ message: "Server error" });
}
});
// DELETE a task by ID
router.delete("/:id", auth, async (req, res) => {
try {
const task = await Task.findByIdAndDelete(req.params.id);
if (!task) {
return res.status(404).json({ message: "Task not found" });
}
io.emit("task-deleted", req.params.id);
res.json({ message: "Task deleted" });
} catch (error) {
res.status(500).json({ message: "Server error" });
}
});
return router;
};
What does this code do?
This file exports a factory function that accepts the io (Socket.io server) instance. This lets every route handler broadcast events to all connected clients.
- GET / fetches all tasks sorted by newest first.
- POST / creates a task, saves it to MongoDB, then emits a task-created event so all browsers update instantly.
- PUT /:id updates a task's title, description, or status (moving it between columns) and emits task-updated.
- DELETE /:id removes a task and emits task-deleted with the task ID.
Every route uses the auth middleware you built in the previous step, so unauthenticated requests get rejected.
- Save server/routes/tasks.js.
You will not see a change yet. The next substep wires this file into server.js so the routes are reachable.
Wire up task routes in server.js
The task routes file is ready, but Express does not know about it yet. You need to import it and mount it at the /api/tasks path, passing in the io instance so the routes can broadcast.
- In server/server.js, find this line near the top:
const authRoutes = require("./routes/auth");
- Add the task routes import directly below it:
const taskRoutes = require("./routes/tasks");
- Now find this line further down:
app.use("/api/auth", authRoutes);
- Add the task routes mount directly below it:
app.use("/api/tasks", taskRoutes(io));
Why pass io as a function argument?
Unlike the auth routes, the task routes need access to the Socket.io server to broadcast events. By exporting a factory function that accepts io, you keep the routes file decoupled from the server setup.
Calling taskRoutes(io) invokes the factory and returns the configured router.
- Save server/server.js.
- In your terminal, stop the running server with Ctrl+C and restart it by running:
node server.js
You should see Server running on port 5000 in the terminal with no errors.
- Open your browser to http://localhost:5000 and open the browser DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows).
- Register a test user and create a task by pasting this into the console:
const reg = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'tasktest', email: 'task@test.com', password: 'test123' })
});
const { token } = await reg.json();
const create = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ title: 'My first task' })
});
console.log('Created:', await create.json());
- Then fetch all tasks by pasting:
const all = await fetch('/api/tasks', {
headers: { 'Authorization': 'Bearer ' + token }
});
console.log('All tasks:', await all.json());
You should see the created task object logged with a title of "My first task" and a status of "todo". The GET request should return an array containing that same task.
Seeing a 401 or empty response?
- If you get No token provided, make sure the Authorization header is included and starts with Bearer (with a space after Bearer).
- If register returns User already exists, change the username and email to something you have not used before.
- If you see a MongoDB connection error, confirm your MONGO_URI in .env is correct and your Atlas cluster is running.
Help me debug my task API responses
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: server/models/Task.js, server/routes/tasks.js, and server/server.js.
ⓧ I'd like to double check the full code
Here are the three files you created or modified in this step.
const mongoose = require("mongoose");
const taskSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
},
description: {
type: String,
default: "",
},
status: {
type: String,
enum: ["todo", "in-progress", "done"],
default: "todo",
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
},
createdAt: {
type: Date,
default: Date.now,
},
});
module.exports = mongoose.model("Task", taskSchema);
const express = require("express");
const Task = require("../models/Task");
const auth = require("../middleware/auth");
module.exports = function (io) {
const router = express.Router();
router.get("/", auth, async (req, res) => {
try {
const tasks = await Task.find().sort({ createdAt: -1 });
res.json(tasks);
} catch (error) {
res.status(500).json({ message: "Server error" });
}
});
router.post("/", auth, async (req, res) => {
try {
const { title, description } = req.body;
const task = new Task({
title,
description,
user: req.userId,
});
await task.save();
io.emit("task-created", task);
res.status(201).json(task);
} catch (error) {
res.status(500).json({ message: "Server error" });
}
});
router.put("/:id", auth, async (req, res) => {
try {
const { title, description, status } = req.body;
const task = await Task.findByIdAndUpdate(
req.params.id,
{ title, description, status },
{ new: true }
);
if (!task) {
return res.status(404).json({ message: "Task not found" });
}
io.emit("task-updated", task);
res.json(task);
} catch (error) {
res.status(500).json({ message: "Server error" });
}
});
router.delete("/:id", auth, async (req, res) => {
try {
const task = await Task.findByIdAndDelete(req.params.id);
if (!task) {
return res.status(404).json({ message: "Task not found" });
}
io.emit("task-deleted", req.params.id);
res.json({ message: "Task deleted" });
} catch (error) {
res.status(500).json({ message: "Server error" });
}
});
return router;
};
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const mongoose = require("mongoose");
const cors = require("cors");
require("dotenv").config();
const authRoutes = require("./routes/auth");
const taskRoutes = require("./routes/tasks");
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "http://localhost:5173",
methods: ["GET", "POST", "PUT", "DELETE"],
},
});
app.use(cors({ origin: "http://localhost:5173" }));
app.use(express.json());
app.use("/api/auth", authRoutes);
app.use("/api/tasks", taskRoutes(io));
io.on("connection", (socket) => {
console.log("User connected:", socket.id);
socket.on("disconnect", () => {
console.log("User disconnected:", socket.id);
});
});
mongoose
.connect(process.env.MONGO_URI)
.then(() => {
const PORT = process.env.PORT || 5000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
})
.catch((err) => console.error("MongoDB connection error:", err));
Your task API is live and broadcasting. Next up, you will build the React frontend that displays these tasks as a visual board with columns.
Build the React Task Board UI
Your backend API can handle user registration, login, and full task CRUD operations. But right now, the only way to interact with it is through raw HTTP requests.
In this step, you will build the React frontend that gives users a visual interface. You will create an authentication form, a three-column task board, and wire them together so the app shows the right screen based on login state.
In this step, get ready to:
- Create an authentication form that toggles between Register and Login modes.
- Build the board layout with three task columns and movable task cards.
- Wire up the app to show the login screen or the board based on authentication state.
Build the authentication UI
Users need a way to register and log in before they can access the task board. You will create a single form component that switches between register and login modes.
- Create the components folder inside your client by running this command from the client directory:
mkdir src/components
- Confirm the folder exists by running:
ls src
You should see components listed alongside the default Vite files.
- Create a new file called AuthForm.jsx inside client/src/components/ and add the following code:
import { useState } from "react";
const API_URL = "http://localhost:5000/api/auth";
function AuthForm({ onLogin }) {
const [isRegister, setIsRegister] = useState(false);
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
async function handleSubmit(e) {
e.preventDefault();
setError("");
const endpoint = isRegister ? "/register" : "/login";
const body = isRegister
? { username, email, password }
: { email, password };
try {
const res = await fetch(API_URL + endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
setError(data.message || "Something went wrong");
return;
}
onLogin(data.token, data.username);
} catch (err) {
setError("Network error");
}
}
return (
<div
style={{
maxWidth: "400px",
margin: "100px auto",
padding: "20px",
border: "1px solid #ccc",
borderRadius: "8px",
}}
>
<h2>{isRegister ? "Register" : "Login"}</h2>
<form onSubmit={handleSubmit}>
{isRegister && (
<div style={{ marginBottom: "10px" }}>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
style={{ width: "100%", padding: "8px" }}
/>
</div>
)}
<div style={{ marginBottom: "10px" }}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{ width: "100%", padding: "8px" }}
/>
</div>
<div style={{ marginBottom: "10px" }}>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{ width: "100%", padding: "8px" }}
/>
</div>
{error && <p style={{ color: "red" }}>{error}</p>}
<button
type="submit"
style={{ width: "100%", padding: "10px", marginBottom: "10px" }}
>
{isRegister ? "Register" : "Login"}
</button>
</form>
<p style={{ textAlign: "center" }}>
{isRegister ? "Already have an account?" : "Need an account?"}{" "}
<button
onClick={() => setIsRegister(!isRegister)}
style={{
background: "none",
border: "none",
color: "blue",
cursor: "pointer",
}}
>
{isRegister ? "Login" : "Register"}
</button>
</p>
</div>
);
}
export default AuthForm;
What does this code do?
- The isRegister state toggles the form between Register and Login mode. In register mode, an extra Username field appears.
- The handleSubmit function sends credentials to either /register or /login on your backend API.
- On success, it calls the onLogin prop with the JWT token and username so the parent component can update the app state.
- If the request fails, the error message from the server is displayed below the form fields.
- Save AuthForm.jsx.
Seeing a red underline on the import?
If VS Code shows an error on import { useState } from "react", make sure you opened the client folder (or the root taskboard folder) in VS Code so it can find the node_modules.
help me fix import errors in my React project
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
import { useState } from "react";
const API_URL = "http://localhost:5000/api/auth";
function AuthForm({ onLogin }) {
const [isRegister, setIsRegister] = useState(false);
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
async function handleSubmit(e) {
e.preventDefault();
setError("");
const endpoint = isRegister ? "/register" : "/login";
const body = isRegister
? { username, email, password }
: { email, password };
try {
const res = await fetch(API_URL + endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
setError(data.message || "Something went wrong");
return;
}
onLogin(data.token, data.username);
} catch (err) {
setError("Network error");
}
}
return (
<div
style={{
maxWidth: "400px",
margin: "100px auto",
padding: "20px",
border: "1px solid #ccc",
borderRadius: "8px",
}}
>
<h2>{isRegister ? "Register" : "Login"}</h2>
<form onSubmit={handleSubmit}>
{isRegister && (
<div style={{ marginBottom: "10px" }}>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
style={{ width: "100%", padding: "8px" }}
/>
</div>
)}
<div style={{ marginBottom: "10px" }}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{ width: "100%", padding: "8px" }}
/>
</div>
<div style={{ marginBottom: "10px" }}>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{ width: "100%", padding: "8px" }}
/>
</div>
{error && <p style={{ color: "red" }}>{error}</p>}
<button
type="submit"
style={{ width: "100%", padding: "10px", marginBottom: "10px" }}
>
{isRegister ? "Register" : "Login"}
</button>
</form>
<p style={{ textAlign: "center" }}>
{isRegister ? "Already have an account?" : "Need an account?"}{" "}
<button
onClick={() => setIsRegister(!isRegister)}
style={{
background: "none",
border: "none",
color: "blue",
cursor: "pointer",
}}
>
{isRegister ? "Login" : "Register"}
</button>
</p>
</div>
);
}
export default AuthForm;
Build the board component
The board needs to display tasks in three columns and let users create, move, and delete them. You will split this into two components: a small TaskCard for each individual task, and a larger Board that manages the columns and API calls.
- Create a new file called TaskCard.jsx inside client/src/components/ and add the following code:
function TaskCard({ task, onMove, onDelete }) {
return (
<div
style={{
backgroundColor: "white",
border: "1px solid #ddd",
borderRadius: "6px",
padding: "12px",
marginBottom: "10px",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
}}
>
<h4 style={{ margin: "0 0 8px 0" }}>{task.title}</h4>
{task.description && (
<p style={{ margin: "0 0 8px 0", color: "#666", fontSize: "14px" }}>
{task.description}
</p>
)}
<div style={{ display: "flex", gap: "5px", flexWrap: "wrap" }}>
{task.status !== "todo" && (
<button
onClick={() => onMove(task._id, "todo")}
style={{ fontSize: "12px", padding: "4px 8px" }}
>
To Do
</button>
)}
{task.status !== "in-progress" && (
<button
onClick={() => onMove(task._id, "in-progress")}
style={{ fontSize: "12px", padding: "4px 8px" }}
>
In Progress
</button>
)}
{task.status !== "done" && (
<button
onClick={() => onMove(task._id, "done")}
style={{ fontSize: "12px", padding: "4px 8px" }}
>
Done
</button>
)}
<button
onClick={() => onDelete(task._id)}
style={{
fontSize: "12px",
padding: "4px 8px",
color: "red",
marginLeft: "auto",
}}
>
Delete
</button>
</div>
</div>
);
}
export default TaskCard;
What does this code do?
- Each TaskCard renders the task title and an optional description.
- Move buttons are conditionally rendered. A task in "To Do" only shows "In Progress" and "Done" buttons since it is already in "To Do".
- The red Delete button always appears and is pushed to the right with marginLeft: "auto".
- Save TaskCard.jsx.
Syntax error on the JSX?
Make sure the file extension is .jsx and not .js. Vite needs the .jsx extension to process JSX syntax.
help me fix JSX syntax errors in my Vite React project
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
function TaskCard({ task, onMove, onDelete }) {
return (
<div
style={{
backgroundColor: "white",
border: "1px solid #ddd",
borderRadius: "6px",
padding: "12px",
marginBottom: "10px",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
}}
>
<h4 style={{ margin: "0 0 8px 0" }}>{task.title}</h4>
{task.description && (
<p style={{ margin: "0 0 8px 0", color: "#666", fontSize: "14px" }}>
{task.description}
</p>
)}
<div style={{ display: "flex", gap: "5px", flexWrap: "wrap" }}>
{task.status !== "todo" && (
<button
onClick={() => onMove(task._id, "todo")}
style={{ fontSize: "12px", padding: "4px 8px" }}
>
To Do
</button>
)}
{task.status !== "in-progress" && (
<button
onClick={() => onMove(task._id, "in-progress")}
style={{ fontSize: "12px", padding: "4px 8px" }}
>
In Progress
</button>
)}
{task.status !== "done" && (
<button
onClick={() => onMove(task._id, "done")}
style={{ fontSize: "12px", padding: "4px 8px" }}
>
Done
</button>
)}
<button
onClick={() => onDelete(task._id)}
style={{
fontSize: "12px",
padding: "4px 8px",
color: "red",
marginLeft: "auto",
}}
>
Delete
</button>
</div>
</div>
);
}
export default TaskCard;
Now create the main Board component that manages tasks and renders the three columns.
- Create a new file called Board.jsx inside client/src/components/ and add the following code:
import { useState, useEffect } from "react";
import TaskCard from "./TaskCard";
const API_URL = "http://localhost:5000/api/tasks";
const COLUMNS = [
{ key: "todo", title: "To Do" },
{ key: "in-progress", title: "In Progress" },
{ key: "done", title: "Done" },
];
function Board({ token }) {
const [tasks, setTasks] = useState([]);
const [newTitle, setNewTitle] = useState("");
useEffect(() => {
fetchTasks();
}, []);
async function fetchTasks() {
try {
const res = await fetch(API_URL, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setTasks(data);
} catch (err) {
console.error("Failed to fetch tasks");
}
}
async function createTask(e) {
e.preventDefault();
if (!newTitle.trim()) return;
try {
await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ title: newTitle }),
});
setNewTitle("");
} catch (err) {
console.error("Failed to create task");
}
}
async function moveTask(taskId, newStatus) {
try {
await fetch(`${API_URL}/${taskId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: newStatus }),
});
} catch (err) {
console.error("Failed to move task");
}
}
async function deleteTask(taskId) {
try {
await fetch(`${API_URL}/${taskId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
} catch (err) {
console.error("Failed to delete task");
}
}
return (
<div>
<form
onSubmit={createTask}
style={{ marginBottom: "20px", display: "flex", gap: "10px" }}
>
<input
type="text"
placeholder="New task title..."
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
style={{ flex: 1, padding: "8px" }}
/>
<button type="submit" style={{ padding: "8px 16px" }}>
Add Task
</button>
</form>
<div style={{ display: "flex", gap: "20px" }}>
{COLUMNS.map((col) => (
<div
key={col.key}
style={{
flex: 1,
backgroundColor: "#f4f4f4",
borderRadius: "8px",
padding: "15px",
minHeight: "300px",
}}
>
<h3 style={{ textAlign: "center", marginBottom: "15px" }}>
{col.title}
</h3>
{tasks
.filter((task) => task.status === col.key)
.map((task) => (
<TaskCard
key={task._id}
task={task}
onMove={moveTask}
onDelete={deleteTask}
/>
))}
</div>
))}
</div>
</div>
);
}
export default Board;
What does this code do?
- The COLUMNS array defines the three board columns with their API status keys and display titles.
- The fetchTasks function calls GET /api/tasks with the JWT in the Authorization header. It runs once when the component mounts.
- The createTask, moveTask, and deleteTask functions call the corresponding API endpoints. Moving a task sends a PUT request with the new status value.
- Each column filters the tasks array by status and renders a TaskCard for each matching task.
- Save Board.jsx.
Seeing an import error for TaskCard?
Make sure both TaskCard.jsx and Board.jsx are in the same src/components/ folder. The import path ./TaskCard expects them to be siblings.
help me fix my component import paths
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
import { useState, useEffect } from "react";
import TaskCard from "./TaskCard";
const API_URL = "http://localhost:5000/api/tasks";
const COLUMNS = [
{ key: "todo", title: "To Do" },
{ key: "in-progress", title: "In Progress" },
{ key: "done", title: "Done" },
];
function Board({ token }) {
const [tasks, setTasks] = useState([]);
const [newTitle, setNewTitle] = useState("");
useEffect(() => {
fetchTasks();
}, []);
async function fetchTasks() {
try {
const res = await fetch(API_URL, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setTasks(data);
} catch (err) {
console.error("Failed to fetch tasks");
}
}
async function createTask(e) {
e.preventDefault();
if (!newTitle.trim()) return;
try {
await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ title: newTitle }),
});
setNewTitle("");
} catch (err) {
console.error("Failed to create task");
}
}
async function moveTask(taskId, newStatus) {
try {
await fetch(`${API_URL}/${taskId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: newStatus }),
});
} catch (err) {
console.error("Failed to move task");
}
}
async function deleteTask(taskId) {
try {
await fetch(`${API_URL}/${taskId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
} catch (err) {
console.error("Failed to delete task");
}
}
return (
<div>
<form
onSubmit={createTask}
style={{ marginBottom: "20px", display: "flex", gap: "10px" }}
>
<input
type="text"
placeholder="New task title..."
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
style={{ flex: 1, padding: "8px" }}
/>
<button type="submit" style={{ padding: "8px 16px" }}>
Add Task
</button>
</form>
<div style={{ display: "flex", gap: "20px" }}>
{COLUMNS.map((col) => (
<div
key={col.key}
style={{
flex: 1,
backgroundColor: "#f4f4f4",
borderRadius: "8px",
padding: "15px",
minHeight: "300px",
}}
>
<h3 style={{ textAlign: "center", marginBottom: "15px" }}>
{col.title}
</h3>
{tasks
.filter((task) => task.status === col.key)
.map((task) => (
<TaskCard
key={task._id}
task={task}
onMove={moveTask}
onDelete={deleteTask}
/>
))}
</div>
))}
</div>
</div>
);
}
export default Board;
Wire up the app
The app needs to decide what to show based on whether the user is logged in. If a token exists in localStorage, you show the Board. Otherwise, you show the AuthForm.
- Open client/src/App.jsx and replace all of its contents with the following code:
import { useState, useEffect } from "react";
import AuthForm from "./components/AuthForm";
import Board from "./components/Board";
function App() {
const [token, setToken] = useState(localStorage.getItem("token"));
const [username, setUsername] = useState(localStorage.getItem("username"));
function handleLogin(newToken, newUsername) {
localStorage.setItem("token", newToken);
localStorage.setItem("username", newUsername);
setToken(newToken);
setUsername(newUsername);
}
function handleLogout() {
localStorage.removeItem("token");
localStorage.removeItem("username");
setToken(null);
setUsername(null);
}
if (!token) {
return <AuthForm onLogin={handleLogin} />;
}
return (
<div style={{ padding: "20px" }}>
<header
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
}}
>
<h1>Task Board</h1>
<div>
<span style={{ marginRight: "10px" }}>Hello, {username}</span>
<button onClick={handleLogout}>Logout</button>
</div>
</header>
<Board token={token} />
</div>
);
}
export default App;
What does this code do?
- The token state is initialized from localStorage so the user stays logged in even after a page refresh.
- The handleLogin function saves the token and username to both state and localStorage.
- The handleLogout function clears both, which causes the app to re-render and show the AuthForm.
- If no token exists, the component returns early with the AuthForm. Otherwise it renders the header with a logout button and the Board.
- Save App.jsx.
- Make sure your backend server is still running in one terminal (from the server/ folder).
- Start the Vite dev server by opening a second terminal, navigating to the client/ folder, and running:
npm run dev
- Open http://localhost:5173 in your browser. You should see the Login form.
- Click Register at the bottom of the form to switch to register mode.
- Enter a username, email, and password, then click Register.
- After registering, you should see the Task Board with three empty columns: To Do, In Progress, and Done.
- Type a task title in the input field and click Add Task.
The new task appears in the To Do column. Click the In Progress button on the task card to move it.
Why do I have to refresh to see changes from another tab?
Right now, the board only fetches tasks on mount. If you open a second browser tab and create a task there, the first tab will not update automatically. You will fix this in the next step by connecting Socket.io for real-time updates.
Seeing a blank page or console errors?
- Open your browser DevTools console. If you see a CORS error, make sure the backend is running on port 5000 and the cors origin is set to http://localhost:5173 in server.js.
- If you see a "Network error" after clicking Register, confirm the server terminal shows no crash. Restart it with node server.js from the server/ folder.
- If you see "User already exists", switch to Login mode and use the same email and password you registered with.
help me debug my React task board
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
import { useState, useEffect } from "react";
import AuthForm from "./components/AuthForm";
import Board from "./components/Board";
function App() {
const [token, setToken] = useState(localStorage.getItem("token"));
const [username, setUsername] = useState(localStorage.getItem("username"));
function handleLogin(newToken, newUsername) {
localStorage.setItem("token", newToken);
localStorage.setItem("username", newUsername);
setToken(newToken);
setUsername(newUsername);
}
function handleLogout() {
localStorage.removeItem("token");
localStorage.removeItem("username");
setToken(null);
setUsername(null);
}
if (!token) {
return <AuthForm onLogin={handleLogin} />;
}
return (
<div style={{ padding: "20px" }}>
<header
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
}}
>
<h1>Task Board</h1>
<div>
<span style={{ marginRight: "10px" }}>Hello, {username}</span>
<button onClick={handleLogout}>Logout</button>
</div>
</header>
<Board token={token} />
</div>
);
}
export default App;
Your task board is fully functional for a single user. You can create tasks, move them between columns, and delete them. Next up, you will add real-time synchronization with Socket.io so that every connected user sees changes the moment they happen.
Add Real-Time Updates with Socket.io
Your task board works, but there is a problem. If you create a task in one browser tab, the other tab has no idea it happened until you refresh the page.
Real-time collaboration means every connected client sees changes the instant they happen. Your backend already broadcasts Socket.io events when tasks change. In this step, you will connect the frontend to those events so the board updates live.
In this step, get ready to:
- Create a shared Socket.io client instance.
- Wire up real-time event listeners in the Board component.
- Test live collaboration across two browser tabs.
Set up the Socket.io client
The Socket.io client needs a single shared instance that any component can import. Creating this in its own file keeps the connection logic out of your components.
- In VS Code, create a new file called socket.js inside the client/src/ folder.
- Add the following code to socket.js:
import { io } from "socket.io-client";
// Connect to the backend Socket.io server
const URL = "http://localhost:5000";
export const socket = io(URL);
What does this code do?
The io function from socket.io-client creates a persistent WebSocket connection to your backend. By exporting the socket instance, any React component can import it and listen for events without creating duplicate connections.
- Save socket.js.
- Check the terminal where your backend is running. You should see a User connected: message with a socket ID, confirming the frontend connected.
Don't see the connection message?
Make sure your backend server is still running on port 5000. If you stopped it, run node server.js again from the server/ folder.
Also confirm your Vite dev server is still running. The Socket.io connection only fires when the React app loads in the browser.
Help me debug my Socket.io connection
Listen for real-time events in the Board component
Your backend already emits three events when tasks change: task-created, task-updated, and task-deleted. The Board component needs to subscribe to these events and update its local state when they fire.
- Open client/src/components/Board.jsx.
- Find the import lines at the top of the file:
import { useState, useEffect } from "react";
import TaskCard from "./TaskCard";
- Add a new import line between the two existing imports, so the top of the file looks like this:
import { useState, useEffect } from "react";
import { socket } from "../socket";
import TaskCard from "./TaskCard";
Why import the shared socket here?
This imports the same socket instance you created in socket.js. Every component that imports from this file shares a single connection to the server, which prevents duplicate WebSocket connections.
Now you need a second useEffect that registers the socket event listeners when the Board mounts and cleans them up when it unmounts.
- In Board.jsx, find the existing useEffect that calls fetchTasks():
useEffect(() => {
fetchTasks();
}, []);
- Directly below that useEffect block, add the following second useEffect for socket listeners:
useEffect(() => {
function onTaskCreated(task) {
setTasks((prev) => [task, ...prev]);
}
function onTaskUpdated(updatedTask) {
setTasks((prev) =>
prev.map((t) => (t._id === updatedTask._id ? updatedTask : t))
);
}
function onTaskDeleted(taskId) {
setTasks((prev) => prev.filter((t) => t._id !== taskId));
}
socket.on("task-created", onTaskCreated);
socket.on("task-updated", onTaskUpdated);
socket.on("task-deleted", onTaskDeleted);
return () => {
socket.off("task-created", onTaskCreated);
socket.off("task-updated", onTaskUpdated);
socket.off("task-deleted", onTaskDeleted);
};
}, []);
What does this code do?
- onTaskCreated prepends the new task to the top of the list so it appears instantly.
- onTaskUpdated finds the matching task by _id and replaces it with the updated version (including its new status).
- onTaskDeleted filters out the deleted task by its ID.
- The return function calls socket.off() to remove the listeners when the component unmounts. This prevents memory leaks and duplicate event handling.
- Save Board.jsx.
- Return to your browser tab at http://localhost:5173. The board should still load and display any tasks you created earlier.
Seeing an error in the browser?
If you see an import error, make sure the path ../socket is correct. The socket.js file lives in client/src/ (one level up from components/).
If the board loads but tasks do not appear, check that you did not accidentally remove the first useEffect that calls fetchTasks(). Both useEffect blocks need to exist side by side.
Help me fix my Board.jsx socket integration
✔️ Awesome, I've got everything!
Great. Make sure you have saved both socket.js and Board.jsx.
ⓧ I'd like to double check the full code
Here is the complete client/src/socket.js:
import { io } from "socket.io-client";
const URL = "http://localhost:5000";
export const socket = io(URL);
Here is the complete client/src/components/Board.jsx:
import { useState, useEffect } from "react";
import { socket } from "../socket";
import TaskCard from "./TaskCard";
const API_URL = "http://localhost:5000/api/tasks";
const COLUMNS = [
{ key: "todo", title: "To Do" },
{ key: "in-progress", title: "In Progress" },
{ key: "done", title: "Done" },
];
function Board({ token }) {
const [tasks, setTasks] = useState([]);
const [newTitle, setNewTitle] = useState("");
useEffect(() => {
fetchTasks();
}, []);
useEffect(() => {
function onTaskCreated(task) {
setTasks((prev) => [task, ...prev]);
}
function onTaskUpdated(updatedTask) {
setTasks((prev) =>
prev.map((t) => (t._id === updatedTask._id ? updatedTask : t))
);
}
function onTaskDeleted(taskId) {
setTasks((prev) => prev.filter((t) => t._id !== taskId));
}
socket.on("task-created", onTaskCreated);
socket.on("task-updated", onTaskUpdated);
socket.on("task-deleted", onTaskDeleted);
return () => {
socket.off("task-created", onTaskCreated);
socket.off("task-updated", onTaskUpdated);
socket.off("task-deleted", onTaskDeleted);
};
}, []);
async function fetchTasks() {
try {
const res = await fetch(API_URL, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setTasks(data);
} catch (err) {
console.error("Failed to fetch tasks");
}
}
async function createTask(e) {
e.preventDefault();
if (!newTitle.trim()) return;
try {
await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ title: newTitle }),
});
setNewTitle("");
} catch (err) {
console.error("Failed to create task");
}
}
async function moveTask(taskId, newStatus) {
try {
await fetch(`${API_URL}/${taskId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: newStatus }),
});
} catch (err) {
console.error("Failed to move task");
}
}
async function deleteTask(taskId) {
try {
await fetch(`${API_URL}/${taskId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
} catch (err) {
console.error("Failed to delete task");
}
}
return (
<div>
<form
onSubmit={createTask}
style={{ marginBottom: "20px", display: "flex", gap: "10px" }}
>
<input
type="text"
placeholder="New task title..."
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
style={{ flex: 1, padding: "8px" }}
/>
<button type="submit" style={{ padding: "8px 16px" }}>
Add Task
</button>
</form>
<div style={{ display: "flex", gap: "20px" }}>
{COLUMNS.map((col) => (
<div
key={col.key}
style={{
flex: 1,
backgroundColor: "#f4f4f4",
borderRadius: "8px",
padding: "15px",
minHeight: "300px",
}}
>
<h3 style={{ textAlign: "center", marginBottom: "15px" }}>
{col.title}
</h3>
{tasks
.filter((task) => task.status === col.key)
.map((task) => (
<TaskCard
key={task._id}
task={task}
onMove={moveTask}
onDelete={deleteTask}
/>
))}
</div>
))}
</div>
</div>
);
}
export default Board;
Test real-time collaboration
The whole point of Socket.io is that multiple users see changes at the same time. You will simulate this by opening two browser tabs.
- Open your browser to http://localhost:5173 and log in with your account.
- Open a second browser tab and navigate to the same URL: http://localhost:5173.
- Log in with the same account in the second tab.
- In the first tab, type a task title and click Add Task.
- Switch to the second tab. The new task should already be visible in the To Do column without refreshing.
- In the second tab, click the Done button on the task to move it.
- Switch back to the first tab. The task should now appear in the Done column.
What is happening behind the scenes?
When you create or move a task, the REST API saves the change to MongoDB. The server then broadcasts a Socket.io event to every connected client. Each client's useEffect listener picks up the event and updates React state, which re-renders the board instantly.
This is the same pattern used by tools like Slack, Figma, and Trello to keep all users in sync.
Changes not appearing in the other tab?
Check your backend terminal for the User connected: messages. You should see two socket IDs (one per tab). If you only see one, the second tab may not have loaded the socket connection.
If both connections show but events are not arriving, make sure the server's CORS origin matches http://localhost:5173 exactly (no trailing slash).
Help me debug real-time updates not syncing between tabs
Your task board now updates in real-time across every connected client. Next up, you will take on the Secret Mission and add drag-and-drop so users can visually move cards between columns.
Secret mission
Add Drag-and-Drop
Your task board works, but clicking buttons to move tasks between columns feels clunky. Real Kanban boards let you grab a card and drop it where you want. Can you add drag-and-drop to make your board feel like a professional tool?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs locally with a free MongoDB Atlas M0 cluster, so there are no ongoing costs.
Resources you used:
- Running processes (backend server on port 5000, Vite dev server on port 5173).
- Local project folder taskboard/ on your Desktop.
- MongoDB Atlas M0 cluster and taskboard database.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your task board.
- Your backend server and Vite dev server will keep running until you close the terminal windows.
- Your MongoDB Atlas M0 cluster is free forever and will stay active.
- Your project files remain in the taskboard/ folder on your Desktop.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and database stay intact for when you return.
- Switch to the terminal running your backend server and press Ctrl+C to stop it.
- Switch to the terminal running the Vite dev server and press Ctrl+C to stop it.
- Your MongoDB Atlas cluster and local files stay as they are. When you return, start both servers again to pick up where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your code and database.
- Stop both running servers by pressing Ctrl+C in each terminal window.
- Delete the local project folder. On macOS/Linux, run:
rm -rf ~/Desktop/taskboard
On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\taskboard
- Delete your MongoDB Atlas cluster by signing in to your Atlas account at cloud.mongodb.com. Navigate to your cluster, open its options menu, and select the terminate/delete option. Confirm deletion when prompted.
- If you created the MongoDB Atlas account solely for this project and want to remove it entirely, visit your account settings in Atlas and look for the option to delete your organization and account.
Nice Work!
Nice Work!
Nice work! You just built a production-structured, real-time collaborative task board from scratch using the full MERN stack.
You've learned how to:
- Built a full Express REST API with JWT authentication for user registration and login.
- Integrated Socket.io for real-time task synchronization across multiple connected clients.
- Created a React frontend with Vite that displays a Kanban board with three columns and clean separation of concerns across models, routes, and middleware.
- Secret Mission: Added drag-and-drop functionality for visually moving task cards between columns.
Ready to quiz yourself?