Your first full-stack app: Vue + Flask + SQLite

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.

You'll need: Python 3 and Node.js (current LTS) installed, and a code editor. Basic Python and JavaScript knowledge helps. Allow an hour or two.

1. Project layout

todo-app/
  backend/
    app.py
    todos.db        (created automatically)
  frontend/         (created by Vite in step 4)

2. The Flask API

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.


3. Test the API on its own

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.


4. Create the Vue app

From the todo-app folder:

npm create vite@latest frontend -- --template vue
cd frontend
npm install

5. Proxy API requests to Flask

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


6. The Vue component

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>

7. Run both halves

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.


How a click travels through the stack

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.


Extend it

  1. Add a PUT /api/todos/<id> route and an edit mode to rename a to-do.
  2. Add a due_date column and a computed "overdue" list in Vue.
  3. Split the component into TodoForm.vue and TodoItem.vue, passing data with props and events.
  4. Build the frontend (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.
All guides About this stack