One of the best ways to cement your web development skills is to take a project from idea to deployed URL in a single weekend. No tutorials to copy, no one else's architecture to follow — just you, your stack, and a goal. This guide walks through exactly that: building and deploying a full-stack task manager app from scratch over two days. By Sunday evening you'll have a live URL you can share.
What we're building: A simple task manager where users can add, complete, and delete tasks. Frontend in React (Vite), backend with Express.js, database with SQLite, deployed on Vercel (frontend) and Railway (backend).
Saturday Morning: Planning and Project Setup
Plan the App First (30 minutes)
Before writing a line of code, sketch out the data model and API endpoints. Our task manager needs:
- Data model: A
taskstable with:id(integer, primary key),title(text),completed(boolean, default false),created_at(timestamp) - API endpoints: GET /tasks (list all), POST /tasks (create), PATCH /tasks/:id (toggle complete), DELETE /tasks/:id (remove)
- Frontend components: App, TaskList, TaskForm, TaskItem
Writing this down before coding saves hours of refactoring later.
Set Up the Project Structure
# Create project folders
mkdir taskmanager
cd taskmanager
mkdir frontend backend
# Set up the React frontend with Vite
cd frontend
npm create vite@latest . -- --template react
npm install
# Set up the Express backend
cd ../backend
npm init -y
npm install express cors better-sqlite3
npm install -D nodemon
Add a dev script to your backend's package.json:
{
"scripts": {
"dev": "nodemon index.js",
"start": "node index.js"
}
}
Saturday Afternoon: Building the React Frontend
Open the frontend folder and start with the TaskForm component — the simplest piece of the UI:
// frontend/src/components/TaskForm.jsx
import { useState } from 'react';
export default function TaskForm({ onAdd }) {
const [title, setTitle] = useState('');
function handleSubmit(e) {
e.preventDefault();
if (!title.trim()) return;
onAdd(title.trim());
setTitle('');
}
return (
<form onSubmit={handleSubmit} className="task-form">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a new task..."
className="task-input"
/>
<button type="submit" className="task-btn">Add Task</button>
</form>
);
}
Next, the TaskItem component for individual task rows:
// frontend/src/components/TaskItem.jsx
export default function TaskItem({ task, onToggle, onDelete }) {
return (
<li className={`task-item ${task.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
<span className="task-title">{task.title}</span>
<button
onClick={() => onDelete(task.id)}
className="delete-btn"
aria-label="Delete task"
>
×
</button>
</li>
);
}
Now the main App.jsx that holds state and calls the API:
// frontend/src/App.jsx
import { useState, useEffect } from 'react';
import TaskForm from './components/TaskForm';
import TaskItem from './components/TaskItem';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function App() {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_URL}/tasks`)
.then(r => r.json())
.then(data => { setTasks(data); setLoading(false); });
}, []);
async function addTask(title) {
const res = await fetch(`${API_URL}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
const newTask = await res.json();
setTasks(prev => [...prev, newTask]);
}
async function toggleTask(id) {
const res = await fetch(`${API_URL}/tasks/${id}`, { method: 'PATCH' });
const updated = await res.json();
setTasks(prev => prev.map(t => t.id === id ? updated : t));
}
async function deleteTask(id) {
await fetch(`${API_URL}/tasks/${id}`, { method: 'DELETE' });
setTasks(prev => prev.filter(t => t.id !== id));
}
if (loading) return <div className="loading">Loading tasks...</div>;
return (
<div className="app">
<h1>My Task Manager</h1>
<TaskForm onAdd={addTask} />
<ul className="task-list">
{tasks.map(task => (
<TaskItem
key={task.id}
task={task}
onToggle={toggleTask}
onDelete={deleteTask}
/>
))}
</ul>
<p className="task-count">
{tasks.filter(t => t.completed).length} of {tasks.length} completed
</p>
</div>
);
}
Saturday Evening: Building the Express REST API
Now switch to the backend folder and build the API:
// backend/index.js
const express = require('express');
const cors = require('cors');
const Database = require('better-sqlite3');
const app = express();
const db = new Database('tasks.db');
app.use(cors());
app.use(express.json());
// Initialise the database table
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
`);
// GET /tasks — return all tasks
app.get('/tasks', (req, res) => {
const tasks = db.prepare('SELECT * FROM tasks ORDER BY created_at DESC').all();
res.json(tasks.map(t => ({ ...t, completed: t.completed === 1 })));
});
// POST /tasks — create a new task
app.post('/tasks', (req, res) => {
const { title } = req.body;
if (!title || !title.trim()) {
return res.status(400).json({ error: 'Title is required' });
}
const stmt = db.prepare('INSERT INTO tasks (title) VALUES (?)');
const info = stmt.run(title.trim());
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(info.lastInsertRowid);
res.status(201).json({ ...task, completed: false });
});
// PATCH /tasks/:id — toggle completed status
app.patch('/tasks/:id', (req, res) => {
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
if (!task) return res.status(404).json({ error: 'Task not found' });
db.prepare('UPDATE tasks SET completed = ? WHERE id = ?')
.run(task.completed ? 0 : 1, req.params.id);
const updated = db.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
res.json({ ...updated, completed: updated.completed === 1 });
});
// DELETE /tasks/:id — remove a task
app.delete('/tasks/:id', (req, res) => {
const result = db.prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Task not found' });
res.status(204).send();
});
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Test your API with curl or a tool like Bruno/Insomnia before building the UI on top of it. Run both servers — npm run dev in each folder — and verify the app works end-to-end locally before thinking about deployment.
Sunday Morning: SQLite Is Already In There
The good news: we used better-sqlite3 from the start, so the database layer is done. SQLite is a file-based database that stores everything in a single tasks.db file. For a weekend project or small production app with moderate traffic, SQLite is underrated — it's fast, zero-configuration, and doesn't require a separate database service.
Sunday morning is a good time to add a few quality-of-life improvements:
- Error boundaries in React so the app doesn't crash on API failure
- Loading skeleton states for the initial data fetch
- Empty state UI ("No tasks yet — add one above!")
- Basic CSS styling so it looks presentable
Sunday Afternoon: Deploy to Vercel and Railway
Deploy the Frontend to Vercel
Vercel is the fastest way to deploy a Vite React app. Push your project to GitHub, then:
- Go to vercel.com and sign in with GitHub
- Click "Add New Project" and import your repository
- Set the root directory to
frontend - Vercel auto-detects Vite — click Deploy
- After deploy, add your backend URL as an environment variable:
VITE_API_URL=https://your-backend.railway.app - Redeploy to pick up the environment variable
Deploy the Backend to Railway
Railway handles Node.js deployments with a database volume for persistent SQLite storage:
- Go to railway.app and sign in with GitHub
- Click "New Project" → "Deploy from GitHub repo"
- Select your repository and set the root directory to
backend - Railway detects Node.js and runs
npm startautomatically - Add a Volume in Railway settings and mount it at
/appso the SQLite file persists between deploys - Set
PORTenvironment variable to3001(Railway also sets this automatically) - Copy the generated Railway URL and paste it into your Vercel environment variables
What to Build Next
Now that you have a working full-stack template, you can extend it in any direction:
- Add authentication with Clerk or Auth.js so each user sees their own tasks
- Add due dates and priority levels to the data model
- Migrate to PostgreSQL on Railway when you want a production-grade database
- Add an AI feature — use the OpenAI API to auto-categorise tasks or suggest subtasks
- Add TypeScript to both frontend and backend for type safety
Go Deep on Full-Stack Development
Our Web Development course covers React, Node.js, databases, authentication, and deployment — with progressively complex projects that teach you to ship real applications.
View the Web Development Course →