A complete to-do app - frontend, API and database - that you can hold in your head.
This guide builds a to-do list with three layers: a Vue interface in the browser, a Flask API that answers JSON requests, and a SQLite database file. It's deliberately small - four API routes and one Vue component - so you can see exactly how data travels from a click, through the server, into the database and back.
todo-app/
backend/
app.py
todos.db (created automatically)
frontend/ (created by Vite in step 4)
Set up the backend:
mkdir -p todo-app/backend
cd todo-app/backend
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install flask
Create backend/app.py:
import sqlite3
from pathlib import Path
from flask import Flask, g, jsonify, request
DATABASE = Path(__file__).parent / "todos.db"
app = Flask(__name__)
def get_db():
if "db" not in g:
g.db = sqlite3.connect(DATABASE)
g.db.row_factory = sqlite3.Row
return g.db
@app.teardown_appcontext
def close_db(exc):
db = g.pop("db", None)
if db is not None:
db.close()
def init_db():
with app.app_context():
db = get_db()
db.execute(
"""CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
)"""
)
db.commit()
@app.get("/api/todos")
def list_todos():
rows = get_db().execute("SELECT id, title, done FROM todos ORDER BY id").fetchall()
return jsonify([dict(row) for row in rows])
@app.post("/api/todos")
def add_todo():
title = (request.get_json(silent=True) or {}).get("title", "").strip()
if not title:
return jsonify(error="title is required"), 400
db = get_db()
cur = db.execute("INSERT INTO todos (title) VALUES (?)", (title,))
db.commit()
return jsonify(id=cur.lastrowid, title=title, done=0), 201
@app.patch("/api/todos/<int:todo_id>")
def toggle_todo(todo_id):
db = get_db()
db.execute("UPDATE todos SET done = 1 - done WHERE id = ?", (todo_id,))
db.commit()
return "", 204
@app.delete("/api/todos/<int:todo_id>")
def delete_todo(todo_id):
db = get_db()
db.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
db.commit()
return "", 204
init_db()
A few things worth noticing: the database path is built from __file__, so it's always next to app.py no matter where you start Flask; each request gets its own connection via Flask's g object, closed automatically afterwards; and every query uses ? placeholders rather than string formatting.
Start Flask:
flask --app app run --debug
In a second terminal, add and list a to-do:
curl -X POST http://127.0.0.1:5000/api/todos -H "Content-Type: application/json" -d '{"title": "Learn Flask"}'
curl http://127.0.0.1:5000/api/todos
(On Windows PowerShell, use curl.exe and escape the inner quotes, or test with a REST client extension in your editor.) Getting the API working before writing any frontend makes bugs much easier to locate.
From the todo-app folder:
npm create vite@latest frontend -- --template vue
cd frontend
npm install
The Vue dev server runs on a different port from Flask. Rather than configuring CORS, tell Vite to forward anything under /api to Flask. Replace frontend/vite.config.js with:
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
"/api": "http://127.0.0.1:5000",
},
},
});
Now the frontend can simply call fetch("/api/todos").
Replace frontend/src/App.vue:
<script setup>
import { ref, onMounted } from "vue";
const todos = ref([]);
const newTitle = ref("");
const error = ref("");
async function load() {
const res = await fetch("/api/todos");
todos.value = await res.json();
}
async function add() {
const title = newTitle.value.trim();
if (!title) return;
const res = await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (!res.ok) {
error.value = "Couldn't add that - is the API running?";
return;
}
todos.value.push(await res.json());
newTitle.value = "";
error.value = "";
}
async function toggle(todo) {
await fetch(`/api/todos/${todo.id}`, { method: "PATCH" });
todo.done = todo.done ? 0 : 1;
}
async function remove(todo) {
await fetch(`/api/todos/${todo.id}`, { method: "DELETE" });
todos.value = todos.value.filter((t) => t.id !== todo.id);
}
onMounted(load);
</script>
<template>
<main>
<h1>To-do</h1>
<form @submit.prevent="add">
<input v-model="newTitle" placeholder="What needs doing?" />
<button>Add</button>
</form>
<p v-if="error">{{ error }}</p>
<ul>
<li v-for="todo in todos" :key="todo.id">
<label>
<input type="checkbox" :checked="todo.done" @change="toggle(todo)" />
<span :class="{ done: todo.done }">{{ todo.title }}</span>
</label>
<button @click="remove(todo)">Delete</button>
</li>
</ul>
</main>
</template>
<style scoped>
.done {
text-decoration: line-through;
opacity: 0.6;
}
</style>
Keep Flask running in one terminal, and in another:
cd frontend
npm run dev
Open the address Vite prints (usually http://localhost:5173). Add a few to-dos, tick one off, refresh the page - they're still there, because they live in backend/todos.db.
When you press Add: Vue's add() sends a POST to /api/todos → Vite's proxy forwards it to Flask → add_todo() validates the title and inserts a row into SQLite → Flask responds with the new to-do as JSON and status 201 → Vue pushes it into todos, and because todos is reactive, the list re-renders. Every full-stack app, however large, is variations on that loop.
PUT /api/todos/<id> route and an edit mode to rename a to-do.due_date column and a computed "overdue" list in Vue.TodoForm.vue and TodoItem.vue, passing data with props and events.npm run build) and have Flask serve it - the stack write-up shows the three lines needed - then deploy with the Flask deployment guide, remembering SQLite needs persistent storage.