Build a Todo App with React + Node.js (Full-Stack Tutorial)
Build a full-stack todo application with a React frontend, Node.js/Express backend, and SQLite database supporting CRUD operations, completion toggling, and status filtering.
What You’ll Build
You’ll build a todo app where users can add, edit, delete, and mark tasks as complete. Tasks persist in a SQLite database via a REST API. The React frontend updates in real time with filtering for All, Active, and Completed tasks. This same full-stack pattern (React + Express + SQLite) powers Doda Browser’s bookmark manager and DodaZIP’s file conversion queue.
Why Full-Stack Matters
A todo app is the perfect project to learn full-stack development because it touches every layer — database schema design, REST API patterns, state management in React, and the glue that connects them. Every feature you add (filtering, sorting, search) maps to real-world requirements in larger applications.
Prerequisites
- Node.js 18+ and npm installed
- Basic JavaScript (ES6) and React
- Familiarity with REST APIs
Step 1: Project Setup
mkdir todo-app
cd todo-app
mkdir server clientStep 2: Backend — Express + SQLite
cd server
npm init -y
npm install express cors better-sqlite3// server/index.js
const express = require("express");
const cors = require("cors");
const Database = require("better-sqlite3");
const path = require("path");
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
// Database setup
const db = new Database(path.join(__dirname, "todos.db"));
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Helper to prepare a todo for JSON response
function formatTodo(row) {
return {
id: row.id,
title: row.title,
completed: Boolean(row.completed),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
// GET /api/todos — list with optional filter
app.get("/api/todos", (req, res) => {
const { filter } = req.query;
let rows;
if (filter === "active") {
rows = db.prepare("SELECT * FROM todos WHERE completed = 0 ORDER BY created_at DESC").all();
} else if (filter === "completed") {
rows = db.prepare("SELECT * FROM todos WHERE completed = 1 ORDER BY created_at DESC").all();
} else {
rows = db.prepare("SELECT * FROM todos ORDER BY created_at DESC").all();
}
res.json(rows.map(formatTodo));
});
// POST /api/todos — create
app.post("/api/todos", (req, res) => {
const { title } = req.body;
if (!title || !title.trim()) {
return res.status(400).json({ error: "Title is required" });
}
const result = db.prepare("INSERT INTO todos (title) VALUES (?)").run(title.trim());
const todo = db.prepare("SELECT * FROM todos WHERE id = ?").get(result.lastInsertRowid);
res.status(201).json(formatTodo(todo));
});
// PUT /api/todos/:id — update (title and/or completed)
app.put("/api/todos/:id", (req, res) => {
const { id } = req.params;
const { title, completed } = req.body;
const existing = db.prepare("SELECT * FROM todos WHERE id = ?").get(id);
if (!existing) {
return res.status(404).json({ error: "Todo not found" });
}
const newTitle = title !== undefined ? title.trim() : existing.title;
const newCompleted = completed !== undefined ? (completed ? 1 : 0) : existing.completed;
db.prepare("UPDATE todos SET title = ?, completed = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.run(newTitle, newCompleted, id);
const updated = db.prepare("SELECT * FROM todos WHERE id = ?").get(id);
res.json(formatTodo(updated));
});
// DELETE /api/todos/:id
app.delete("/api/todos/:id", (req, res) => {
const { id } = req.params;
const existing = db.prepare("SELECT * FROM todos WHERE id = ?").get(id);
if (!existing) {
return res.status(404).json({ error: "Todo not found" });
}
db.prepare("DELETE FROM todos WHERE id = ?").run(id);
res.json({ message: "Deleted" });
});
// DELETE /api/todos — clear completed
app.delete("/api/todos", (req, res) => {
const result = db.prepare("DELETE FROM todos WHERE completed = 1").run();
res.json({ deleted: result.changes });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});Expected output when running the server:
node index.js
# Server running on http://localhost:3001Step 3: Frontend — React
cd ../client
npx create-react-app . --template minimal
npm install axios// client/src/App.js
import React, { useState, useEffect } from "react";
import axios from "axios";
import "./App.css";
const API = "http://localhost:3001/api/todos";
function App() {
const [todos, setTodos] = useState([]);
const [newTitle, setNewTitle] = useState("");
const [filter, setFilter] = useState("all");
const [error, setError] = useState("");
useEffect(() => {
fetchTodos();
}, [filter]);
async function fetchTodos() {
try {
const params = filter === "all" ? {} : { filter };
const res = await axios.get(API, { params });
setTodos(res.data);
} catch (err) {
setError("Failed to fetch todos");
}
}
async function addTodo() {
if (!newTitle.trim()) return;
try {
await axios.post(API, { title: newTitle });
setNewTitle("");
fetchTodos();
} catch (err) {
setError("Failed to add todo");
}
}
async function toggleTodo(id, completed) {
try {
await axios.put(`${API}/${id}`, { completed: !completed });
fetchTodos();
} catch (err) {
setError("Failed to update todo");
}
}
async function deleteTodo(id) {
try {
await axios.delete(`${API}/${id}`);
fetchTodos();
} catch (err) {
setError("Failed to delete todo");
}
}
async function clearCompleted() {
try {
await axios.delete(API);
fetchTodos();
} catch (err) {
setError("Failed to clear completed");
}
}
const activeCount = todos.filter((t) => !t.completed).length;
return (
<div className="app">
<h1>Todo App</h1>
{error && <div className="error">{error}</div>}
<div className="add-todo">
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTodo()}
placeholder="What needs to be done?"
/>
<button onClick={addTodo}>Add</button>
</div>
<div className="filters">
<button className={filter === "all" ? "active" : ""} onClick={() => setFilter("all")}>
All
</button>
<button className={filter === "active" ? "active" : ""} onClick={() => setFilter("active")}>
Active
</button>
<button className={filter === "completed" ? "active" : ""} onClick={() => setFilter("completed")}>
Completed
</button>
</div>
<ul className="todo-list">
{todos.map((todo) => (
<li key={todo.id} className={todo.completed ? "completed" : ""}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id, todo.completed)}
/>
<span className="title">{todo.title}</span>
<button className="delete-btn" onClick={() => deleteTodo(todo.id)}>
✕
</button>
</li>
))}
</ul>
<div className="footer">
<span>{activeCount} item{activeCount !== 1 ? "s" : ""} left</span>
{todos.some((t) => t.completed) && (
<button className="clear-btn" onClick={clearCompleted}>
Clear completed
</button>
)}
</div>
</div>
);
}
export default App;/* client/src/App.css */
.app {
max-width: 500px;
margin: 40px auto;
font-family: system-ui, sans-serif;
}
h1 {
text-align: center;
color: #333;
}
.error {
background: #ffe0e0;
color: #d32f2f;
padding: 8px 12px;
border-radius: 6px;
margin-bottom: 12px;
font-size: 14px;
}
.add-todo {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.add-todo input {
flex: 1;
padding: 10px 12px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 16px;
}
.add-todo input:focus {
border-color: #007bff;
outline: none;
}
.add-todo button {
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
}
.add-todo button:hover {
background: #0056b3;
}
.filters {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.filters button {
padding: 6px 16px;
border: 2px solid #ddd;
border-radius: 20px;
background: white;
cursor: pointer;
font-size: 14px;
}
.filters button.active {
border-color: #007bff;
color: #007bff;
font-weight: 600;
}
.todo-list {
list-style: none;
padding: 0;
}
.todo-list li {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #eee;
}
.todo-list li.completed .title {
text-decoration: line-through;
color: #999;
}
.todo-list li .title {
flex: 1;
}
.delete-btn {
background: none;
border: none;
color: #ccc;
cursor: pointer;
font-size: 18px;
padding: 4px;
}
.delete-btn:hover {
color: #d32f2f;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
color: #666;
font-size: 14px;
}
.clear-btn {
background: none;
border: 1px solid #ddd;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
color: #666;
}
.clear-btn:hover {
border-color: #d32f2f;
color: #d32f2f;
}Step 4: Run the App
Terminal 1 (server):
cd server
node index.jsTerminal 2 (client):
cd client
npm startOpen http://localhost:3000. Add a few todos, mark some as complete, filter by status, delete individual items, and clear all completed. Refresh the page — your todos persist because they’re stored in SQLite.
Expected flow:
[Input: "Learn React"] → Click "Add" → Task appears in list
Click checkbox → Task strikethrough, moves to "Completed" filter
Click "Active" filter → Only unfinished tasks shown
Click "Clear completed" → All done tasks disappearArchitecture
sequenceDiagram
participant User as User (Browser)
participant React as React App
participant API as Express Server
participant DB as SQLite Database
User->>React: Type todo, press Enter
React->>API: POST /api/todos {title: "Learn React"}
API->>DB: INSERT INTO todos
DB-->>API: New todo row
API-->>React: 201 + JSON
React->>React: Update state, re-render list
User->>React: Click checkbox
React->>API: PUT /api/todos/1 {completed: true}
API->>DB: UPDATE todos SET completed=1
DB-->>API: Updated row
API-->>React: JSON
React->>React: Re-render with strikethrough
User->>React: Click "Clear completed"
React->>API: DELETE /api/todos
API->>DB: DELETE FROM todos WHERE completed=1
DB-->>API: Deleted count
API-->>React: Success
React->>React: Re-render empty list
Common Errors
1. CORS error in the browser
The React app on port 3000 tries to call the API on port 3001. The browser blocks this unless the server sends Access-Control-Allow-Origin headers. Our cors() middleware on the server handles this — but make sure the server is running and the URL matches. If you see No 'Access-Control-Allow-Origin' header is present, the server likely isn’t running.
2. “Cannot POST /api/todos” — 404
The server expects Content-Type: application/json. Our axios.post sends JSON by default. If you test with curl or Postman, ensure you set the header: -H "Content-Type: application/json". Without it, req.body will be undefined.
3. Database file locked
SQLite doesn’t handle concurrent writes well. Our server uses better-sqlite3 (synchronous) rather than the default async SQLite — this avoids most locking issues. For production with many concurrent users, switch to PostgreSQL.
4. React state not updating after mutation
If fetchTodos() isn’t called after a mutation, the UI shows stale data. Every mutation function (addTodo, toggleTodo, deleteTodo, clearCompleted) calls fetchTodos() to refresh the list. If you add new mutations, remember to refresh.
Practice Questions
1. Why do we store completed as an integer (0/1) instead of a boolean?
SQLite doesn’t have a native boolean type. We store 0 for false, 1 for true. The formatTodo helper converts it back to a JavaScript boolean with Boolean(row.completed). This is a common pattern with SQLite.
2. What’s the purpose of better-sqlite3 vs the standard sqlite3 package?
better-sqlite3 is synchronous — database calls block until complete. This is simpler and faster for an app with a few concurrent users. The standard sqlite3 is asynchronous (callback-based) and better for high-concurrency scenarios. For a todo app, better-sqlite3 is the right choice.
3. How does the filter parameter work in the API?
The React app sends ?filter=active or ?filter=completed as a query parameter. The server checks req.query.filter and adjusts the SQL WHERE clause accordingly. ?filter=all (or no filter) returns every row.
4. Challenge: Add due dates
Add a due_date column (TEXT type, ISO 8601 format). The add-todo form should include an optional date picker. Display overdue items in red. Add a “Due today” filter button.
5. Challenge: Drag-and-drop reordering
Add a position column to the database (INTEGER). Use the react-beautiful-dnd library for drag-and-drop. On reorder, send a batch update with all new positions. Sort the list by position ASC instead of created_at DESC.
FAQ
Next Steps
- Add user authentication with JWT
- Switch to PostgreSQL for production
- Learn TypeScript to type-check both frontend and backend
- Build the Real-Time Dashboard project for live-updating features
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro