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

Prerequisites: Basic JavaScript, some familiarity with React concepts (components, useState), and Node.js installed on your machine. You don't need to know Express or SQLite — we'll cover the relevant parts.

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:

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.

When to upgrade from SQLite: When you need concurrent writes from multiple servers, when your data exceeds a few GB, or when you need database-level replication. For a portfolio project or small SaaS, SQLite + Railway is a perfectly legitimate production setup.

Sunday morning is a good time to add a few quality-of-life improvements:

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:

  1. Go to vercel.com and sign in with GitHub
  2. Click "Add New Project" and import your repository
  3. Set the root directory to frontend
  4. Vercel auto-detects Vite — click Deploy
  5. After deploy, add your backend URL as an environment variable: VITE_API_URL=https://your-backend.railway.app
  6. 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:

  1. Go to railway.app and sign in with GitHub
  2. Click "New Project" → "Deploy from GitHub repo"
  3. Select your repository and set the root directory to backend
  4. Railway detects Node.js and runs npm start automatically
  5. Add a Volume in Railway settings and mount it at /app so the SQLite file persists between deploys
  6. Set PORT environment variable to 3001 (Railway also sets this automatically)
  7. Copy the generated Railway URL and paste it into your Vercel environment variables
Done: You now have a live, full-stack web application — React frontend on Vercel, Express API on Railway, SQLite database persisted on a Railway volume. Total infrastructure cost: $0 on free tiers. Share the Vercel URL and watch it work.

What to Build Next

Now that you have a working full-stack template, you can extend it in any direction:

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 →
PC

Pal C

AI Engineer & Full-Stack Developer

Software engineer and AI specialist with 8+ years of experience. Has taught 500+ students from 15+ countries.