Build a Task Tracker API with FastAPI

Build a full-stack REST API with FastAPI, SQLite, and a JavaScript frontend.

Introduction

30 Second Summary

Every app you use daily, from your to-do list to your email inbox, stores and retrieves data through an API. Learning how to build one yourself is the single most valuable backend skill you can pick up.

In this project, you will build a task tracker application from scratch using Python. You'll create a backend API that stores tasks in a database, then connect it to a frontend interface where you can add, complete, and delete tasks in real time.

What You'll Build

A full-stack task tracker with a FastAPI backend serving a REST API connected to a SQLite database, plus a browser-based frontend that calls your API.

By the end of this project, you'll have:

  • A FastAPI backend with 5 REST endpoints that create, read, update, and delete tasks.
  • A SQLite database managed by SQLModel that persists your tasks between server restarts.
  • A frontend interface that lets you add tasks, mark them complete, and delete them from the browser.
  • Auto-generated Swagger UI documentation you can use to test and showcase your API.
  • Secret Mission: Add filtering by completion status and search by title to your task list.

Are there any prerequisites?

You should have basic programming experience and be comfortable running commands in a terminal. No prior Python web development experience is needed.

Set Up Your Python Environment

Before you can build an API, you need a clean workspace where your project's dependencies live in isolation. A Python virtual environment gives you exactly that.

In this step, you will create a project folder, set up a virtual environment, install FastAPI and SQLModel, and confirm everything works by running a hello world server.

In this step, get ready to:
  • Create a project directory and Python virtual environment.
  • Install FastAPI and SQLModel.
  • Run a hello world server and view the auto-generated API docs.
Create your project directory and virtual environment

A virtual environment is a sandboxed Python installation tied to a single project. Packages you install inside it stay isolated from the rest of your system.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir task-tracker-api
cd task-tracker-api
  • Confirm the folder exists by running:
ls

You should see the task-tracker-api folder listed in the output (it will appear empty since you are now inside it).

  • Create a virtual environment by running this command:

macOS

python3 -m venv venv

Windows

python -m venv venv
  • Activate the virtual environment:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms you are working inside the virtual environment.

  • Verify your Python version by running:
python --version

You should see Python 3.10 or higher.

What is a virtual environment?

A virtual environment creates a copy of Python that belongs only to this project. When you install packages with pip inside it, they do not affect any other project on your machine.

The (venv) prefix in your terminal is a visual reminder that you are in the isolated environment. If you close and reopen your terminal later, you will need to re-activate it.

Not seeing (venv) in your prompt?

Make sure you ran the activation command for your operating system. On macOS use source venv/bin/activate and on Windows use venv\Scripts\activate.

If you see a permissions error on Windows, try running PowerShell as Administrator and entering Set-ExecutionPolicy RemoteSigned first.

help me activate my Python virtual environment

Install FastAPI and SQLModel

FastAPI is the web framework that powers your API. SQLModel is the library that connects your Python code to a database.

  • Install FastAPI with its standard dependencies by running:
pip install "fastapi[standard]"
  • Install SQLModel by running:
pip install sqlmodel

You should see output ending with a success message listing the installed packages.

What does fastapi[standard] include?

The [standard] extra bundles Uvicorn (the server that runs your app) and other useful tools for development. Without it, you would need to install a server separately.

Seeing install errors?

Double check that (venv) still appears in your terminal prompt. If it does not, re-run the activation command from the previous substep.

If you see a message about pip being outdated, you can safely ignore it. The packages still install correctly.

help me fix pip install errors for FastAPI or SQLModel

Create a hello world endpoint and start the server

With your tools installed, you can write your first API endpoint and see it running in your browser.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the task-tracker-api folder, then click Open.
  • Create a new file called main.py by clicking the new file icon in the Explorer sidebar (or pressing Cmd+N on macOS / Ctrl+N on Windows and saving as main.py).
  • Paste the following code into main.py:
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"Hello": "World"}

What does this code do?

This is the smallest possible FastAPI application.

  • from fastapi import FastAPI imports the framework.
  • app = FastAPI() creates your application instance.
  • @app.get("/") registers a GET endpoint at the root URL.
  • The function returns a Python dictionary, which FastAPI automatically converts to JSON.
  • Save main.py.
  • Switch back to your terminal (where (venv) is active) and start the development server by running:
fastapi dev

What does fastapi dev do?

The fastapi dev command automatically detects your main.py file and the app object inside it. It starts a Uvicorn server with auto-reload enabled, so any code changes you save take effect immediately.

  • Open your browser and navigate to http://127.0.0.1:8000.

You should see a JSON response: {"Hello":"World"}.

  • Now navigate to http://127.0.0.1:8000/docs.

You should see the Swagger UI page. This is FastAPI's auto-generated interactive documentation where you can test every endpoint directly from your browser.

Server not starting?

Make sure (venv) is visible in your prompt before running fastapi dev. If it is missing, re-activate your virtual environment.

Check that your file is named exactly main.py and is saved in the task-tracker-api folder (not inside a subfolder). The fastapi dev command auto-detects a file named main.py in the current directory.

help me start my FastAPI development server

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.py and your server is still running in the terminal.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point:

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"Hello": "World"}

Your development environment is ready and your first endpoint is live. Next up, you will define your Task data model and connect it to a database.

Define Your Data Model with SQLModel

Your FastAPI server is running and returning JSON. That's the transport layer sorted. Now you need something meaningful to transport: your application's data.

Every task in your tracker needs a consistent structure. You'll define that structure once using SQLModel, and it will serve double duty: validating incoming API requests AND mapping directly to your database table.

In this step, get ready to:
  • Define a Task model that maps to a SQLite database table.
  • Create a TaskUpdate model for partial updates.
  • Configure the database connection and session management.
Create the Task and TaskUpdate models

SQLModel combines two libraries into one class definition. Pydantic handles data validation ("is this a valid string?"). SQLAlchemy handles database mapping ("which table and column does this go to?").

With SQLModel, a single class definition does both jobs. You describe your data once and get validation plus database persistence for free.

  • In main.py, select all the existing code and replace it with the following:
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Annotated

from fastapi import Depends, FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine, select


# Define the Task table - each instance maps to a row in the database
class Task(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str = Field(index=True)
    description: str | None = None
    completed: bool = Field(default=False)
    created_at: str = Field(default_factory=lambda: datetime.now().isoformat())

What does this code do?

The imports bring in everything you need for database models, sessions, and app lifecycle management.

  • table=True tells SQLModel this class represents an actual database table, not just a data shape.
  • Field(default=None, primary_key=True) lets the database auto-generate the ID for each new task.
  • Field(index=True) on title creates a database index so searching by title is fast.
  • Field(default_factory=...) on created_at automatically timestamps each task when it's created.

You won't see a change yet. The next chunk adds the update model.

  • Below the Task class, add the TaskUpdate model:
# Model for partial updates - all fields optional so you only send what changes
class TaskUpdate(SQLModel):
    title: str | None = None
    description: str | None = None
    completed: bool | None = None

Why a separate update model?

When updating a task, you might only want to change one field (like marking it completed). TaskUpdate makes every field optional so the API accepts partial data without requiring you to resend the entire task.

Notice there's no table=True here. This model is only for validating incoming request data. It doesn't create its own database table.

You won't see a change yet. The next chunk connects these models to a database.

Set up the database engine and session dependency

Your models define the shape of your data. Now you need the plumbing: a database engine to store data, a session to manage transactions, and a startup hook to create the tables.

  • Below the TaskUpdate class in main.py, add the database engine, session dependency, lifespan handler, and app initialization:
# Connect to a local SQLite file (created automatically if it doesn't exist)
sqlite_url = "sqlite:///database.db"
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})

# Provide a fresh database session for each request
def get_session():
    with Session(engine) as session:
        yield session

# Shorthand type so endpoints can declare their session dependency
SessionDep = Annotated[Session, Depends(get_session)]


# Create database tables when the app starts, then clean up on shutdown
@asynccontextmanager
async def lifespan(app: FastAPI):
    SQLModel.metadata.create_all(engine)
    yield

# Initialize the app with the lifespan handler
app = FastAPI(lifespan=lifespan)

What does this code do?

  • sqlite_url points to a local SQLite file. SQLite stores the entire database in a single file with zero setup.
  • check_same_thread: False is required because FastAPI may handle a single request across multiple threads.
  • get_session() uses Python's yield to open a session, hand it to the endpoint, then automatically close it when the request finishes.
  • SessionDep is a type alias. Any endpoint that declares a parameter with this type automatically gets a database session injected by FastAPI's dependency injection system.
  • lifespan runs SQLModel.metadata.create_all(engine) at startup, which creates the task table in your database file.
  • Save main.py.

Seeing a red underline in your editor?

If VS Code shows import errors for sqlmodel, make sure your virtual environment is selected as the Python interpreter. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Python: Select Interpreter, and choose the one inside your project's .venv folder.

help me fix import errors for sqlmodel in VS Code

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py before moving on.

ⓧ I'd like to double check the full code

Here's the complete main.py at this point:

from contextlib import asynccontextmanager
from datetime import datetime
from typing import Annotated

from fastapi import Depends, FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine, select


class Task(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str = Field(index=True)
    description: str | None = None
    completed: bool = Field(default=False)
    created_at: str = Field(default_factory=lambda: datetime.now().isoformat())


class TaskUpdate(SQLModel):
    title: str | None = None
    description: str | None = None
    completed: bool | None = None


sqlite_url = "sqlite:///database.db"
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})


def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]


@asynccontextmanager
async def lifespan(app: FastAPI):
    SQLModel.metadata.create_all(engine)
    yield


app = FastAPI(lifespan=lifespan)
Run the server and verify the database

Your code is complete for this step. Time to confirm that the database gets created when the server starts.

  • In your terminal, start the development server by running:
fastapi dev main.py
  • Open a second terminal tab or window (keep the server running in the first one).
  • Confirm the database file was created by running:
ls
  • You should see database.db listed alongside main.py in the output.
  • Open your browser and navigate to http://127.0.0.1:8000/docs to confirm the Swagger UI loads successfully.

What should I see at /docs?

The Swagger UI page loads with the title "FastAPI" at the top. You won't see any endpoints listed yet because you haven't created any route functions. The Task schema will appear in the docs once you add endpoints in the next step.

The important thing is that the page loads without errors. This confirms your app starts correctly and the database tables are created.

Server won't start?

If you see an ImportError, make sure your virtual environment is activated. You should see (.venv) at the beginning of your terminal prompt.

If you see a SyntaxError, double check your code against the full file in the tab group above. A missing colon or incorrect indentation is the most common cause.

help me fix my FastAPI server startup error

Your data model and database are ready. Next up, you'll build the API endpoints that create, read, update, and delete tasks using this model.

Build CRUD API Endpoints

Your database model is ready and waiting for data. The next piece is giving the outside world a way to interact with it.

In this step, you will build five REST API endpoints that handle the full lifecycle of a task: creating, reading, updating, and deleting. These four operations are known as CRUD, and they form the backbone of almost every web application.

In this step, get ready to:
  • Create endpoints for adding and listing tasks.
  • Create endpoints for fetching, updating, and deleting individual tasks.
  • Test all five endpoints using Swagger UI.
Create the POST and GET endpoints

Before adding endpoints, you need to import a few new tools from FastAPI that the endpoints will use.

  • In main.py, find this import line near the top of the file:
from fastapi import Depends, FastAPI
  • Replace it with this updated import that adds HTTPException and Query:
from fastapi import Depends, FastAPI, HTTPException, Query
  • Also add this import on a new line between the datetime import and the typing import:
from pathlib import Path

What are these new imports for?

HTTPException lets you return proper HTTP error responses (like 404 Not Found) when something goes wrong.

Query lets you add validation rules to query parameters, like capping how many results a client can request at once.

Path is a Python standard library tool for reading files. You will use it in the next step to serve your frontend.

Now you are ready to add your first endpoint. The POST endpoint accepts a new task in the request body, saves it to the database, and returns the saved task with its auto-generated ID.

  • Add the following code at the bottom of main.py, below the app = FastAPI(lifespan=lifespan) line:
@app.post("/tasks/")
def create_task(task: Task, session: SessionDep) -> Task:
    # Add the task to the database session
    session.add(task)
    # Save changes to the database
    session.commit()
    # Reload the task to get the auto-generated id
    session.refresh(task)
    return task

What does this code do?

The @app.post("/tasks/") decorator tells FastAPI this function handles POST requests to /tasks/.

FastAPI automatically parses the incoming JSON body into a Task object because of the type hint. The session.refresh(task) call reloads the task from the database so you get the auto-generated id and created_at values in the response.

Next, you need an endpoint to retrieve all tasks. This endpoint supports pagination so clients can request results in batches.

  • Add this code directly below the create_task function:
@app.get("/tasks/")
def read_tasks(
    session: SessionDep,
    offset: int = 0,
    limit: Annotated[int, Query(le=100)] = 100,
) -> list[Task]:
    # Query the database for tasks with pagination
    tasks = session.exec(select(Task).offset(offset).limit(limit)).all()
    return tasks

What does this code do?

offset skips a number of results from the start, and limit caps how many tasks come back in one response. Together they let clients page through large task lists.

Query(le=100) means the maximum allowed value for limit is 100. If a client sends limit=500, FastAPI automatically returns a validation error.

  • Save main.py.
  • If your dev server is not running, start it by running:
fastapi dev main.py
  • Open http://127.0.0.1:8000/docs in your browser. You should see the new POST /tasks/ and GET /tasks/ endpoints listed in the Swagger UI.

Don't see the new endpoints?

Make sure you saved main.py. The dev server auto-reloads on save, but unsaved changes will not appear.

Check your terminal for error messages. A missing comma or typo in the function signature will prevent the server from reloading.

help me debug why my FastAPI endpoints aren't showing up in Swagger UI

Create the GET single, PATCH, and DELETE endpoints

You can now create and list tasks. The next three endpoints let you work with individual tasks by their ID.

  • Add the GET single-task endpoint below the read_tasks function:
@app.get("/tasks/{task_id}")
def read_task(task_id: int, session: SessionDep) -> Task:
    # Look up the task by its primary key
    task = session.get(Task, task_id)
    if not task:
        # Return a 404 error if the task doesn't exist
        raise HTTPException(status_code=404, detail="Task not found")
    return task

What does this code do?

session.get(Task, task_id) fetches a single task by its primary key. If no task exists with that ID, it returns None.

Raising an HTTPException with status_code=404 tells the client that the resource was not found. This is a standard REST pattern for missing resources.

The PATCH endpoint handles partial updates. It only modifies the fields the client sends, leaving everything else unchanged.

  • Add the PATCH endpoint below the read_task function:
@app.patch("/tasks/{task_id}")
def update_task(task_id: int, task_update: TaskUpdate, session: SessionDep) -> Task:
    # Fetch the existing task from the database
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    # Get only the fields that were actually sent in the request
    task_data = task_update.model_dump(exclude_unset=True)
    # Apply each provided field to the existing task
    for key, value in task_data.items():
        setattr(task, key, value)
    session.add(task)
    session.commit()
    session.refresh(task)
    return task

Why use model_dump(exclude_unset=True)?

When a client sends a PATCH request with only {"completed": true}, you want to update completed without touching title or description.

model_dump(exclude_unset=True) returns a dictionary containing only the fields the client actually included in the request body. Fields left out of the request are excluded from the dictionary entirely, so the setattr loop never overwrites them.

The final endpoint removes a task from the database permanently.

  • Add the DELETE endpoint below the update_task function:
@app.delete("/tasks/{task_id}")
def delete_task(task_id: int, session: SessionDep):
    # Fetch the task to make sure it exists
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    # Remove the task from the database
    session.delete(task)
    session.commit()
    return {"deleted": True}

What does this code do?

The delete endpoint follows the same 404-check pattern as GET and PATCH. If the task exists, session.delete(task) removes it from the database.

It returns a simple JSON confirmation {"deleted": True} so the client knows the operation succeeded.

  • Save main.py.
  • Refresh http://127.0.0.1:8000/docs in your browser. You should now see all five endpoints listed: POST, two GETs, PATCH, and DELETE.

Seeing an error in the terminal?

Check that each endpoint function has the correct decorator (@app.post, @app.get, @app.patch, @app.delete) and that the path strings match exactly, including the trailing slash.

Make sure HTTPException is included in your fastapi import line at the top of the file.

help me fix an error in my FastAPI CRUD endpoints

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py before moving on to testing.

ⓧ I'd like to double check the full code

from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Query
from sqlmodel import Field, Session, SQLModel, create_engine, select


class Task(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str = Field(index=True)
    description: str | None = None
    completed: bool = Field(default=False)
    created_at: str = Field(default_factory=lambda: datetime.now().isoformat())


class TaskUpdate(SQLModel):
    title: str | None = None
    description: str | None = None
    completed: bool | None = None


sqlite_url = "sqlite:///database.db"
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})


def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]


@asynccontextmanager
async def lifespan(app: FastAPI):
    SQLModel.metadata.create_all(engine)
    yield


app = FastAPI(lifespan=lifespan)


@app.post("/tasks/")
def create_task(task: Task, session: SessionDep) -> Task:
    session.add(task)
    session.commit()
    session.refresh(task)
    return task


@app.get("/tasks/")
def read_tasks(
    session: SessionDep,
    offset: int = 0,
    limit: Annotated[int, Query(le=100)] = 100,
) -> list[Task]:
    tasks = session.exec(select(Task).offset(offset).limit(limit)).all()
    return tasks


@app.get("/tasks/{task_id}")
def read_task(task_id: int, session: SessionDep) -> Task:
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    return task


@app.patch("/tasks/{task_id}")
def update_task(task_id: int, task_update: TaskUpdate, session: SessionDep) -> Task:
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    task_data = task_update.model_dump(exclude_unset=True)
    for key, value in task_data.items():
        setattr(task, key, value)
    session.add(task)
    session.commit()
    session.refresh(task)
    return task


@app.delete("/tasks/{task_id}")
def delete_task(task_id: int, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    session.delete(task)
    session.commit()
    return {"deleted": True}
Test all five endpoints in Swagger UI

FastAPI auto-generates interactive documentation at /docs where you can test every endpoint directly in the browser. No separate API client needed.

  • Open http://127.0.0.1:8000/docs in your browser.
  • Expand the POST /tasks/ section and click Try it out.
  • Replace the example JSON body with this:
{
  "title": "Learn FastAPI",
  "description": "Build a task tracker API"
}
  • Click Execute. You should see a response with an auto-generated id, completed set to false, and a created_at timestamp.
  • Create one or two more tasks using the same process so you have data to work with.
  • Expand GET /tasks/, click Try it out, then click Execute. You should see all your created tasks returned as a JSON array.
  • Expand GET /tasks/{task_id}, enter 1 in the task_id field, and click Execute. You should see the single task with ID 1.
  • Expand PATCH /tasks/{task_id}, enter 1 for the task ID, and use this JSON body:
{
  "completed": true
}
  • Click Execute. The response should show the task with completed now set to true, while title and description remain unchanged.
  • Expand DELETE /tasks/{task_id}, enter 1 for the task ID, and click Execute. You should see {"deleted": true} in the response.
  • Run GET /tasks/ one more time. The deleted task should no longer appear in the list.

What is Swagger UI?

Swagger UI is an interactive API documentation tool that FastAPI generates automatically from your code's type hints and decorators. It reads your endpoint definitions and builds a UI where you can send real requests and inspect responses.

Every time you add or change an endpoint, the docs at /docs update automatically. No manual documentation work required.

Your API is fully functional. Next up, you will build a frontend interface that calls these endpoints so you can manage tasks from a real browser UI instead of the docs page.

Connect a Frontend

Your API is fully functional, but right now the only way to use it is through the Swagger UI docs page. Real users need a visual interface they can interact with in their browser.

In this step, you will build an HTML frontend that talks to your API using JavaScript fetch() calls. You will also add a new endpoint in main.py that serves the HTML page at the root URL so everything runs from a single server.

In this step, get ready to:
  • Create an HTML page with a task input form and styled task list.
  • Add JavaScript functions that call your API to load, create, toggle, and delete tasks.
  • Serve the frontend from your FastAPI server and test the full flow.
Create the HTML file with form and styling

Your frontend lives in a separate folder called static/ inside your project directory. This keeps your HTML, CSS, and JavaScript separate from your Python backend code.

  • Create the static folder inside your project directory by running this command:
mkdir static
  • Create a new file called index.html inside the static/ folder.
  • In VS Code, right-click the static folder in the Explorer sidebar and select New File.
  • Name the file index.html.
  • Paste the following HTML and CSS into static/index.html to create the page structure and styling:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Task Tracker</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            max-width: 600px;
            margin: 40px auto;
            padding: 0 20px;
            background: #f5f5f5;
        }
        h1 { margin-bottom: 20px; color: #333; }
        .add-form { display: flex; gap: 8px; margin-bottom: 24px; }
        .add-form input {
            flex: 1;
            padding: 10px 12px;
            border: 1px solid #ddd;
            border-radius: 6px;
            font-size: 14px;
        }
        .add-form button {
            padding: 10px 20px;
            background: #2563eb;
            color: white;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 14px;
        }
        .add-form button:hover { background: #1d4ed8; }
        .task-list { list-style: none; }
        .task-item {
            background: white;
            padding: 14px 16px;
            margin-bottom: 8px;
            border-radius: 8px;
            display: flex;
            align-items: center;
            gap: 12px;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
        }
        .task-item.completed .task-title {
            text-decoration: line-through;
            color: #999;
        }
        .task-title { flex: 1; font-size: 15px; }
        .task-item input[type="checkbox"] {
            width: 18px;
            height: 18px;
            cursor: pointer;
        }
        .delete-btn {
            background: none;
            border: none;
            color: #ef4444;
            cursor: pointer;
            font-size: 18px;
            padding: 4px 8px;
        }
        .delete-btn:hover { background: #fee2e2; border-radius: 4px; }
        .empty { text-align: center; color: #999; padding: 40px; }
    </style>
</head>
<body>
    <h1>Task Tracker</h1>
    <div class="add-form">
        <input type="text" id="task-input" placeholder="Add a new task..." />
        <button onclick="addTask()">Add</button>
    </div>
    <ul class="task-list" id="task-list"></ul>

What does this code do?

The <style> block defines the visual design: a centered layout, card-style task items, a blue Add button, and a red delete button.

The <body> contains the interactive elements: a text input with an Add button (the .add-form), and an empty <ul> list that JavaScript will populate with tasks from your API.

Add JavaScript to call your API

The frontend needs to communicate with your API endpoints. JavaScript's fetch() function sends HTTP requests to the same server that served the page, so there is no need for CORS configuration.

  • Still in static/index.html, add the following <script> block directly below the <ul> element you just created:
    <script>
        const API = '/tasks/';

        async function loadTasks() {
            const response = await fetch(API);
            const tasks = await response.json();
            const list = document.getElementById('task-list');
            if (tasks.length === 0) {
                list.innerHTML = '<li class="empty">No tasks yet. Add one above!</li>';
                return;
            }
            list.innerHTML = tasks.map(task => `
                <li class="task-item ${task.completed ? 'completed' : ''}">
                    <input type="checkbox"
                        ${task.completed ? 'checked' : ''}
                        onchange="toggleTask(${task.id}, this.checked)" />
                    <span class="task-title">${task.title}</span>
                    <button class="delete-btn" onclick="deleteTask(${task.id})">&#x2715;</button>
                </li>
            `).join('');
        }

        async function addTask() {
            const input = document.getElementById('task-input');
            const title = input.value.trim();
            if (!title) return;
            await fetch(API, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ title })
            });
            input.value = '';
            loadTasks();
        }

        async function toggleTask(id, completed) {
            await fetch(`/tasks/${id}`, {
                method: 'PATCH',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ completed })
            });
            loadTasks();
        }

        async function deleteTask(id) {
            await fetch(`/tasks/${id}`, { method: 'DELETE' });
            loadTasks();
        }

        document.getElementById('task-input').addEventListener('keypress', (e) => {
            if (e.key === 'Enter') addTask();
        });

        loadTasks();
    </script>
</body>
</html>

What does this code do?

  • loadTasks() fetches all tasks from GET /tasks/ and renders them as list items with checkboxes and delete buttons.
  • addTask() sends a POST request with the input value as the title, then refreshes the list.
  • toggleTask() sends a PATCH request to flip the completed field, then refreshes the list.
  • deleteTask() sends a DELETE request to remove the task, then refreshes the list.
  • The addEventListener at the bottom lets the learner press Enter to add a task instead of clicking the button.
  • Save static/index.html.

File not saving?

Make sure the file is saved inside the static/ folder, not in the project root. Check the Explorer sidebar in VS Code to confirm index.html appears nested under static.

help me check my file structure

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

Here is the complete static/index.html file:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Task Tracker</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            max-width: 600px;
            margin: 40px auto;
            padding: 0 20px;
            background: #f5f5f5;
        }
        h1 { margin-bottom: 20px; color: #333; }
        .add-form { display: flex; gap: 8px; margin-bottom: 24px; }
        .add-form input {
            flex: 1;
            padding: 10px 12px;
            border: 1px solid #ddd;
            border-radius: 6px;
            font-size: 14px;
        }
        .add-form button {
            padding: 10px 20px;
            background: #2563eb;
            color: white;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 14px;
        }
        .add-form button:hover { background: #1d4ed8; }
        .task-list { list-style: none; }
        .task-item {
            background: white;
            padding: 14px 16px;
            margin-bottom: 8px;
            border-radius: 8px;
            display: flex;
            align-items: center;
            gap: 12px;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
        }
        .task-item.completed .task-title {
            text-decoration: line-through;
            color: #999;
        }
        .task-title { flex: 1; font-size: 15px; }
        .task-item input[type="checkbox"] {
            width: 18px;
            height: 18px;
            cursor: pointer;
        }
        .delete-btn {
            background: none;
            border: none;
            color: #ef4444;
            cursor: pointer;
            font-size: 18px;
            padding: 4px 8px;
        }
        .delete-btn:hover { background: #fee2e2; border-radius: 4px; }
        .empty { text-align: center; color: #999; padding: 40px; }
    </style>
</head>
<body>
    <h1>Task Tracker</h1>
    <div class="add-form">
        <input type="text" id="task-input" placeholder="Add a new task..." />
        <button onclick="addTask()">Add</button>
    </div>
    <ul class="task-list" id="task-list"></ul>

    <script>
        const API = '/tasks/';

        async function loadTasks() {
            const response = await fetch(API);
            const tasks = await response.json();
            const list = document.getElementById('task-list');
            if (tasks.length === 0) {
                list.innerHTML = '<li class="empty">No tasks yet. Add one above!</li>';
                return;
            }
            list.innerHTML = tasks.map(task => `
                <li class="task-item ${task.completed ? 'completed' : ''}">
                    <input type="checkbox"
                        ${task.completed ? 'checked' : ''}
                        onchange="toggleTask(${task.id}, this.checked)" />
                    <span class="task-title">${task.title}</span>
                    <button class="delete-btn" onclick="deleteTask(${task.id})">&#x2715;</button>
                </li>
            `).join('');
        }

        async function addTask() {
            const input = document.getElementById('task-input');
            const title = input.value.trim();
            if (!title) return;
            await fetch(API, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ title })
            });
            input.value = '';
            loadTasks();
        }

        async function toggleTask(id, completed) {
            await fetch(`/tasks/${id}`, {
                method: 'PATCH',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ completed })
            });
            loadTasks();
        }

        async function deleteTask(id) {
            await fetch(`/tasks/${id}`, { method: 'DELETE' });
            loadTasks();
        }

        document.getElementById('task-input').addEventListener('keypress', (e) => {
            if (e.key === 'Enter') addTask();
        });

        loadTasks();
    </script>
</body>
</html>
Serve the frontend from your FastAPI server

Your HTML file exists on disk, but your FastAPI server does not know about it yet. You need a new endpoint that reads the file and returns it as an HTML response when someone visits the root URL.

  • In main.py, find the line that says:
from fastapi import Depends, FastAPI, HTTPException, Query
  • Add this new import directly below it:
from fastapi.responses import HTMLResponse
  • Scroll to the very bottom of main.py, below the delete_task function.
  • Add this new endpoint that serves your HTML frontend:
@app.get("/", response_class=HTMLResponse)
def serve_frontend():
    return Path("static/index.html").read_text()

What does this code do?

The @app.get("/") decorator registers this function at the root URL. Setting response_class=HTMLResponse tells FastAPI to return the content with a text/html content type instead of JSON.

Path("static/index.html").read_text() uses Python's built-in pathlib module to read the entire HTML file into a string and return it to the browser.

  • Save main.py.

Why serve HTML this way?

By serving the HTML from the same FastAPI server, the frontend's fetch() calls go to the same origin. This means you avoid CORS configuration entirely. In production apps with separate frontend servers, CORS becomes a real concern.

  • Switch back to your terminal.
  • If the dev server is still running, stop it by pressing Ctrl+C.
  • Restart the server by running:
fastapi dev main.py
  • Open your browser and visit http://127.0.0.1:8000.

You should see the Task Tracker interface with a text input and an Add button.

  • Type a task name into the input field and click Add (or press Enter).
  • Check the checkbox next to a task to mark it complete.
  • Click the red ✕ button to delete a task.
  • Refresh the page to confirm tasks persist in the database.

Page not loading or showing errors?

  • If you see a JSON response instead of HTML, make sure you added response_class=HTMLResponse to the @app.get("/") decorator.
  • If you see a file-not-found error, confirm that static/index.html exists in the same directory where you run fastapi dev.
  • If tasks are not loading, open DevTools (F12) and check the Console tab for network errors.

help me debug my frontend not connecting to the API

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.py file:

from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse
from sqlmodel import Field, Session, SQLModel, create_engine, select


class Task(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str = Field(index=True)
    description: str | None = None
    completed: bool = Field(default=False)
    created_at: str = Field(default_factory=lambda: datetime.now().isoformat())


class TaskUpdate(SQLModel):
    title: str | None = None
    description: str | None = None
    completed: bool | None = None


sqlite_url = "sqlite:///database.db"
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})


def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]


@asynccontextmanager
async def lifespan(app: FastAPI):
    SQLModel.metadata.create_all(engine)
    yield


app = FastAPI(lifespan=lifespan)


@app.post("/tasks/")
def create_task(task: Task, session: SessionDep) -> Task:
    session.add(task)
    session.commit()
    session.refresh(task)
    return task


@app.get("/tasks/")
def read_tasks(
    session: SessionDep,
    offset: int = 0,
    limit: Annotated[int, Query(le=100)] = 100,
) -> list[Task]:
    tasks = session.exec(select(Task).offset(offset).limit(limit)).all()
    return tasks


@app.get("/tasks/{task_id}")
def read_task(task_id: int, session: SessionDep) -> Task:
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    return task


@app.patch("/tasks/{task_id}")
def update_task(task_id: int, task_update: TaskUpdate, session: SessionDep) -> Task:
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    task_data = task_update.model_dump(exclude_unset=True)
    for key, value in task_data.items():
        setattr(task, key, value)
    session.add(task)
    session.commit()
    session.refresh(task)
    return task


@app.delete("/tasks/{task_id}")
def delete_task(task_id: int, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    session.delete(task)
    session.commit()
    return {"deleted": True}


@app.get("/", response_class=HTMLResponse)
def serve_frontend():
    return Path("static/index.html").read_text()

Your full-stack app is live. You have a Python backend handling data persistence and a browser frontend letting users interact with it visually. Next up, take on the Secret Mission to add filtering and search to your task list.

Secret mission

Add Filtering and Search

Your task tracker works, but as the list grows, finding specific tasks gets harder. Real productivity apps let you filter by status and search by name. Can you extend your API and frontend to support both?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Project folder on your Desktop (containing main.py, static/index.html, and database.db).
  • Python virtual environment (inside your project folder).
  • FastAPI and SQLModel packages (installed inside the virtual environment).

Keep everything running

No action needed. Choose this if you're still actively building or want to keep experimenting with your API.

  • Your project files stay on your Desktop.
  • Your virtual environment and installed packages remain ready to use.
  • To restart the server later, activate the virtual environment and run fastapi dev again.

Pause - I'll come back to this later

Stop the running dev server to free up the terminal. Your files and virtual environment stay intact for when you return.

  • In the terminal where the FastAPI server is running, press Ctrl+C to stop the server.
  • Deactivate your virtual environment by running:
deactivate
  • When you come back, navigate to your project folder, activate the virtual environment, and run fastapi dev to pick up where you left off.

Delete - I don't want to use this again

Remove all project files, the virtual environment, and the database. This frees up disk space and leaves no trace of the project.

  • In the terminal where the FastAPI server is running, press Ctrl+C to stop the server.
  • Navigate to your Desktop and delete the project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf task-tracker-api
  • On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force task-tracker-api
  • This removes main.py, static/index.html, database.db, the virtual environment, and all installed packages in one step.

Nice Work!

Nice Work!

You just built a full-stack task tracker application from scratch using Python. Your FastAPI backend serves a REST API with full CRUD operations, persists data in a SQLite database, and powers a working frontend that creates, updates, and deletes tasks in real time.

You've learned how to:

  • Built a REST API with FastAPI that handles full CRUD operations across five endpoints.
  • Modeled data with SQLModel and persisted it in a SQLite database using Python type hints for automatic validation and documentation.
  • Connected a frontend to your backend using JavaScript fetch() calls and explored FastAPI's auto-generated Swagger UI for testing your API.
  • Secret Mission: Added filtering and search with conditional database queries using SQLModel's where() clause.

Ready to quiz yourself?