Build a Scalable Task Board with FastAPI
Build a modular FastAPI task board with SQLite and a browser interface.
Introduction
30 Second Summary
Most task lists feel trustworthy until a restart wipes away the work you just entered. A growing app also becomes harder to change when every responsibility is packed into one place.
In this project, you will build a local Task Board powered by a FastAPI modular monolith. You will replace fragile memory with SQLite persistence before connecting the browser interface.
What You'll Build
Your finished Task Board lets you add a task in Microsoft Edge that remains visible after the server restarts.
By the end of this project, you'll have:
- An interactive task API where you submit a task to see its validated JSON response.
- A persistent task history that stays intact after a server restart.
- A styled browser Task Board that exposes the complete request path from form submission to saved database row.
- Secret Mission: An end-to-end delete flow that removes a task through the API and browser interface.
Are there any prerequisites?
You should be comfortable with Python backend development. No prior SQLModel or frontend framework experience is required.
Before We Start
Before the hands-on work begins, this is your chance to lock in what your local Task Board needs to prove. You are committing to a project that creates and lists tasks while showing why durable data and clear backend boundaries matter.
Set Up and Launch a Starter API
Your Task Board needs a predictable local base before its routes grow. An isolated virtual environment keeps this project's packages away from your other Python work.
You'll confirm Python 3.14.7. You'll prepare the scalable-task-board workspace in Visual Studio Code.
You'll install FastAPI inside that environment. A starter route proves your local API can answer requests before you add task data.
In this step, get ready to:
- Confirm the required Python runtime.
- Prepare an isolated project environment.
- Launch the starter API documentation.
Check Python and Microsoft Edge
The Python Install Manager supplies the runtime that creates your project environment. Checking the version now prevents package compatibility surprises later.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Click View in the top menu.
- Click Terminal to open the integrated terminal.
- Check the available Python version by running this command:
python -V
What does this command do?
The -V option prints the Python version. If no runtime exists yet, Python Install Manager starts installing the current stable release.
✔️ I see Python 3.14.7
Your runtime is ready. Python 3.14.7 can create the isolated environment for this project.
ⓧ I see an older version
Your existing runtime stays available while Python Install Manager adds the current Python 3.14 release.
- Install the current Python 3.14 runtime by running this command:
py install 3.14
What does this command do?
Python Install Manager downloads the latest available runtime in the Python 3.14 series. The current stable release is Python 3.14.7.
- Run the version check again with this command:
python -V
What does this command do?
This prints the runtime selected as your default Python installation. You should now see Python 3.14.7.
ⓧ Python starts an installation
Expect the automatic download to take a few minutes. Python Install Manager is adding the current stable runtime because none was installed.
- Wait for Python Install Manager to finish downloading the runtime.
- Approve the Windows installation confirmation if it appears.
- Run the version check again with this command:
python -V
What does this command do?
This confirms that the automatic installation completed. You should now see Python 3.14.7.
You'll use Microsoft Edge to view the interactive documentation generated by the starter API.
- Press the Windows key to open Windows search.
- Type Microsoft Edge into the search field.
- Press Enter.
✔️ Microsoft Edge opens
Your browser is ready for the final check. Leave Microsoft Edge open for later in this step.
ⓧ Microsoft Edge is missing
Installing the browser can trigger a Windows confirmation prompt. Allow the installer so Microsoft Edge can finish setting itself up.
- Visit the official Microsoft Edge download page.
- Select Download Edge for Windows.
- Open the downloaded installer.
- Allow the Windows confirmation prompt if it appears.
- Follow the installer prompts to complete the setup.
- Press the Windows key to open Windows search.
- Type Microsoft Edge into the search field.
- Press Enter to open Microsoft Edge.
Having trouble installing Edge?
Check that the installer finished downloading before you open it. A managed Windows device may require administrator approval.
Get help with installing Microsoft Edge on Windows.
A Visual Studio Code workspace keeps every project file under one named folder. Creating it on your Desktop makes the project easy to find.
- Switch back to Visual Studio Code.
- Click File in the top menu.
- Select Open Folder....
- Choose Desktop in the Windows folder picker.
- Select New Folder.
- Enter scalable-task-board as the folder name.
- Select the scalable-task-board folder.
- Click Select Folder.
You'll see scalable-task-board at the top of the Explorer sidebar. Visual Studio Code now treats this folder as your workspace.
- Confirm that you trust the folder if a workspace trust prompt appears.
Create the isolated environment
The dependency file pins every package to a known version. The virtual environment installs those packages only inside scalable-task-board.
- Click Explorer in the Visual Studio Code Activity Bar.
- Click New File... in the Explorer sidebar.
- Enter requirements.txt as the file name.
- Add the pinned dependency list by pasting this content:
fastapi[standard]==0.141.1
sqlmodel==0.0.42
SQLAlchemy==2.0.52
Why pin these packages?
- FastAPI 0.141.1 includes the framework plus its standard development tools.
- SQLModel 0.0.42 provides the models and database session tools used later.
- SQLAlchemy 2.0.52 pins the underlying database toolkit to a compatible release.
- Save requirements.txt.
You'll see three dependency lines in requirements.txt. The saved file gives every future installation the same package versions.
- Click View in the top menu.
- Click Terminal to return to the integrated terminal.
- Create the .venv environment by running this command:
py -m venv .venv
What does this command do?
The venv module creates an isolated Python environment inside .venv. Packages installed after activation stay scoped to this project.
- Check the Explorer sidebar for the new .venv directory.
You'll see .venv inside scalable-task-board. That folder is the visible boundary around this project's Python environment.
- Activate the virtual environment by running this command:
.\.venv\Scripts\Activate
What does this command do?
The activation script points Python commands at the interpreter inside .venv. Your terminal prompt identifies the active environment with (.venv).
You'll see (.venv) at the beginning of the terminal prompt. Your project is now isolated from other Python installations.
Package installation can take a minute while the pinned files download. Keep the terminal open until the command finishes.
- Install the project dependencies by running this command:
py -m pip install -r requirements.txt
What does this command do?
The command asks pip to read requirements.txt. It installs each pinned package into the active .venv environment.
The installation finishes without an error. Your environment now contains the command needed to run the starter app.
Dependency installation failed?
Confirm that (.venv) appears at the start of the terminal prompt. Check that requirements.txt contains all three pinned lines.
Get help with fixing the package installation.
Launch the starter API
A FastAPI path operation connects an HTTP method to a Python function. Your first operation answers requests sent to the root path.
- Click Explorer in the Visual Studio Code Activity Bar.
- Click New File... in the Explorer sidebar.
- Enter main.py as the file name.
- Create the starter application by pasting this code:
from fastapi import FastAPI
app = FastAPI(title="Task Board API")
@app.get("/")
def read_root():
return {"message": "Task Board API is running"}
What does this code do?
- The import makes the FastAPI application class available.
- The app object represents your backend. Its title appears in the interactive documentation.
- The @app.get("/") decorator connects root-path requests to read_root().
- The read_root() function returns a JSON message that proves the API is running.
- Save main.py.
✔️ Awesome, I've got everything!
Both project files are ready. Your saved code can now be launched from the active virtual environment.
ⓧ I'd like to double check the full code
Compare your dependency file with this complete version.
fastapi[standard]==0.141.1
sqlmodel==0.0.42
SQLAlchemy==2.0.52
What should this file contain?
The file contains the exact FastAPI, SQLModel, and SQLAlchemy versions used by this project. Each package occupies one line.
Compare your starter application with this complete version.
from fastapi import FastAPI
app = FastAPI(title="Task Board API")
@app.get("/")
def read_root():
return {"message": "Task Board API is running"}
What should this file contain?
The file defines the app object plus the read_root() path-operation function. These identifiers must match so FastAPI can discover the application.
Before you run the server, predict whether the documentation will show the root route:
- Start the development server by running this command:
fastapi dev
What does this command do?
The development command reads main.py and detects the FastAPI app object. It starts a local server with automatic reload enabled.
The terminal stays occupied while the server runs. Changes to the Python file trigger a reload during development.
- Leave the server terminal running during the browser check.
- Switch back to Microsoft Edge from earlier.
- Click the browser address bar.
- Enter http://127.0.0.1:8000/docs.
- Press Enter.
You'll see interactive documentation titled Task Board API. The page lists the root GET operation at /.
Your local API is live. FastAPI has discovered your app and generated browser-based documentation from the route.
Documentation page not loading?
Confirm that the terminal running fastapi dev remains open. Check that main.py is saved inside scalable-task-board.
Get help with finding why the starter API is not loading.
Your isolated environment and starter API are working. Next, you'll turn this route into an in-memory task API and expose its first architectural weakness.
Build the In-Memory API
Your starter FastAPI app already proves that the local request loop works. The Task Board now needs a predictable task shape plus routes for task creation and task listing.
A Python list gives you the fastest working storage baseline. You can use that baseline to test what happens when the development server starts a fresh process.
In this step, get ready to:
- Define the task data contract with SQLModel.
- Expose the task collection through POST plus GET routes.
- Restart the server to test whether process memory keeps the saved task.
Define the task data contract
An API data contract fixes the fields accepted from a caller. It also fixes the fields returned in a response.
SQLModel turns those contracts into validated Python models. Invalid request bodies are rejected before they reach the task list.
- In main.py, replace everything above @app.get("/") by copying the code below:
from fastapi import FastAPI
from sqlmodel import SQLModel
app = FastAPI(title="Task Board API")
tasks = []
next_task_id = 1
class TaskCreate(SQLModel):
title: str
class TaskPublic(TaskCreate):
id: int
completed: bool
How Do the Models Shape Data?
- The tasks list holds task objects inside the current server process.
- The next_task_id counter supplies a unique number for each new task.
- The TaskCreate model describes the title accepted from a request.
- The TaskPublic model adds the generated ID plus completion state returned to the caller.
- Save main.py.
- Switch back to the Microsoft Edge tab from earlier.
- Refresh the interactive documentation.
- Confirm the starter root route remains visible.
The starter route still appears. This confirms that the new models load without stopping the app.
Does the Documentation Stop Loading?
- Confirm that from sqlmodel import SQLModel appears directly below the FastAPI import.
- Check that the attributes inside TaskCreate plus TaskPublic are indented by four spaces.
Help me fix the task model code in my FastAPI app.
Create and test the task routes
Each route connects an HTTP method to one Python function. Incoming JSON is checked against TaskCreate before the function stores anything.
- In main.py, add the following routes below read_root by copying this code:
@app.post("/api/v1/tasks/", response_model=TaskPublic)
def create_task_route(task: TaskCreate):
global next_task_id
saved_task = TaskPublic(id=next_task_id, title=task.title, completed=False)
tasks.append(saved_task)
next_task_id += 1
return saved_task
@app.get("/api/v1/tasks/", response_model=list[TaskPublic])
def read_tasks():
return tasks
How Do These Routes Work?
- The POST route receives a validated TaskCreate object.
- The global next_task_id line lets the function update the shared counter.
- The function builds a TaskPublic object before appending it to tasks.
- The GET route returns every object currently held in the shared list.
- Save main.py.
- Switch back to the interactive documentation in Microsoft Edge.
- Refresh the page.
- Confirm that POST /api/v1/tasks/ plus GET /api/v1/tasks/ are displayed.
The request editor is a little fiddly because it displays the complete JSON object. You only need to change the example title value.
- Expand POST /api/v1/tasks/.
- Click Try it out.
- Enter Write project notes as the title value.
- Click Execute.
That request worked. Your API returns the title with an id of 1 plus a completed value of false.
- Expand GET /api/v1/tasks/.
- Click Try it out.
- Click Execute.
You'll see the same task inside the returned list. Both routes now use the same in-memory collection.
Are the Task Routes Missing?
- Confirm that main.py is saved.
- Refresh the documentation after the server finishes reloading.
- Check that the request body contains a title field if the request is rejected.
Help me troubleshoot my task routes.
Your main.py now contains both task models plus both task routes.
✔️ Awesome, I've got everything!
Your task models plus routes are saved. Continue to the restart test.
ⓧ I'd like to double check the full code
from fastapi import FastAPI
from sqlmodel import SQLModel
app = FastAPI(title="Task Board API")
tasks = []
next_task_id = 1
class TaskCreate(SQLModel):
title: str
class TaskPublic(TaskCreate):
id: int
completed: bool
@app.get("/")
def read_root():
return {"message": "Task Board API is running"}
@app.post("/api/v1/tasks/", response_model=TaskPublic)
def create_task_route(task: TaskCreate):
global next_task_id
saved_task = TaskPublic(id=next_task_id, title=task.title, completed=False)
tasks.append(saved_task)
next_task_id += 1
return saved_task
@app.get("/api/v1/tasks/", response_model=list[TaskPublic])
def read_tasks():
return tasks
What Should Match?
The file contains one shared task list plus one ID counter. The root route remains above the two task routes.
Test the restart boundary
The API works while the current server process is running. A restart now tests whether that process can act as the Task Board's source of truth.
- Switch back to the terminal panel from earlier.
- Stop the current development server by pressing CTRL+C.
- Start a fresh development server process by running:
fastapi dev
What Does This Command Do?
The fastapi dev command reads main.py. It starts a fresh development process with automatic reload enabled.
Does the Server Fail to Restart?
- Confirm that the terminal prompt still begins with (.venv).
- Confirm that the terminal is still using the scalable-task-board folder.
Help me restart my local FastAPI development server.
Before you repeat the list request, do you think the task from the previous process is still available? The next request tests your prediction.
- Switch back to the interactive documentation in Microsoft Edge.
- Refresh the page.
- Expand GET /api/v1/tasks/.
- Click Try it out.
- Click Execute.
You'll see an empty list in the response. This shortfall is intentional.
Why Did the Task Disappear?
The tasks list belongs to one running Python process. Stopping that process discards the list from memory.
The new process runs tasks = [] plus next_task_id = 1 again. Process memory cannot preserve the Task Board's history across restarts.
You've proved that the API contract works while exposing the exact weakness in temporary storage. Next, you'll give the Task Board durable storage plus clearer backend boundaries.
Add Durable Storage and Module Boundaries
Your FastAPI API created tasks in Step 2. It also listed them through versioned routes.
A restart erased every task because the Python list existed only inside the running process. That temporary memory cannot preserve the Task Board's history.
A SQLite database stores tasks in a local file. A modular monolith architecture gives routing, data contracts, session management, and persistence their own boundaries.
In this step, get ready to:
- Replace the one-file starter with an application package and shared data contracts.
- Persist tasks in SQLite through request-scoped sessions.
- Reconnect the HTTP routes to prove tasks survive a restart.
Create the package and data contracts
The old main.py owns every responsibility. The new app package gives each boundary a dedicated file.
Why use a modular monolith?
A modular monolith keeps one application process while separating its responsibilities. You gain clear boundaries without operating several services.
Those boundaries make later infrastructure changes smaller. The HTTP layer can stay stable when the database layer changes.
Removing main.py can feel risky. The new package replaces its temporary logic with the durable version.
- Switch back to the terminal running the development server from earlier.
- Stop the development server with the terminal's keyboard interrupt.
You should see the terminal return to its normal prompt.
- In Visual Studio Code, use the file sidebar's context menu to delete main.py from scalable-task-board.
- Use the file sidebar's folder control to create an app folder inside scalable-task-board.
The file sidebar should show app in place of the deleted one-file application.
- Create a routers folder inside app using the file sidebar's folder control.
You should now see the nested path app/routers in the file sidebar.
Package marker files let Python treat these folders as importable application packages.
- Create app/__init__.py by adding this package marker:
"""Task Board application package."""
What does this file do?
The docstring identifies app as the Task Board application package. Other modules can now import code through paths that begin with app.
- Save app/__init__.py.
- Confirm that app/__init__.py appears beneath the app folder.
- Create app/routers/__init__.py by adding this package marker:
"""API router package."""
What does this file do?
This marker makes app.routers an importable package. Route modules can live together without moving database logic into the HTTP layer.
- Save app/routers/__init__.py.
- Confirm that app/routers/__init__.py appears beneath the routers folder.
The development command needs a new entrypoint after the root main.py file is removed. The project configuration points it to the app object inside app/main.py.
- Create pyproject.toml inside scalable-task-board by adding this entrypoint configuration:
[tool.fastapi]
entrypoint = "app.main:app"
What does this configuration do?
The entrypoint value tells the development command where the application lives. It imports the app object from app/main.py.
- Save pyproject.toml.
- Confirm that pyproject.toml appears beside requirements.txt.
The data contracts now need a database table model. SQLModel lets the shared title field support input validation, database rows, and public responses.
- Create app/models.py by adding the Task Board models below:
from sqlmodel import Field, SQLModel
class TaskBase(SQLModel):
title: str = Field(index=True)
class Task(TaskBase, table=True):
id: int | None = Field(default=None, primary_key=True)
completed: bool = False
class TaskCreate(TaskBase):
pass
class TaskPublic(TaskBase):
id: int
completed: bool
What do these models do?
- TaskBase holds the title field shared across every task shape.
- Task maps tasks to a database table. Its primary key generates an ID for each row.
- TaskCreate validates the incoming request body.
- TaskPublic defines the task data returned through the API.
- Save app/models.py.
- Confirm that app/models.py appears beneath the app folder.
Model file missing or incomplete?
Check that models.py sits directly inside app. Compare each class name with the code block above.
Make sure the Task class includes table=True. That setting makes it the database table model.
Help me check my Task Board SQLModel classes.
Build the persistence boundary
The database boundary owns one engine plus the lifecycle of each database session. Dependency injection supplies a fresh session to each request.
- Create app/database.py by adding the engine and session dependency below:
from typing import Annotated
from fastapi import Depends
from sqlmodel import Session, create_engine
sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)
def get_session():
with Session(engine) as session:
yield session
SessionDep = Annotated[Session, Depends(get_session)]
What does the database boundary do?
- engine connects the application to the local database.db file.
- connect_args allows the SQLite connection to support requests handled across threads.
- get_session() opens a session for one request. The context manager closes it afterward.
- SessionDep packages the session type with the FastAPI dependency.
- Save app/database.py.
- Confirm that app/database.py appears beneath the app folder.
Database file in the wrong location?
Keep sqlite_file_name set to database.db. The relative URL creates the file inside scalable-task-board when the application starts.
Help me check my SQLite engine and session dependency.
A repository keeps persistence operations behind a small interface. Route functions can ask for tasks without knowing how SQL queries or commits work.
- Create app/repository.py by adding the list and create operations below:
from sqlmodel import Session, select
from app.models import Task, TaskCreate
def list_tasks(session: Session) -> list[Task]:
return list(session.exec(select(Task)).all())
def create_task(session: Session, task_data: TaskCreate) -> Task:
task = Task.model_validate(task_data)
session.add(task)
session.commit()
session.refresh(task)
return task
What does the repository do?
- list_tasks() selects every saved Task row through the supplied session.
- create_task() converts validated input into a table model.
- session.commit() writes the new row to SQLite.
- session.refresh(task) reloads the saved row so the generated ID is available.
- Save app/repository.py.
- Confirm that app/repository.py appears beneath the app folder.
Repository code not lining up?
Check that both functions accept session as their first parameter. The router supplies that request-scoped session later.
Confirm that create_task() commits before it refreshes the saved task.
Help me check the repository boundary.
Connect the HTTP boundary
An API router groups the task endpoints behind one URL prefix. Each route receives SessionDep and delegates persistence to the repository.
- Create app/routers/tasks.py by adding the task router below:
from fastapi import APIRouter
from app.database import SessionDep
from app.models import TaskCreate, TaskPublic
from app.repository import create_task, list_tasks
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
@router.get("/", response_model=list[TaskPublic])
def read_tasks(session: SessionDep):
return list_tasks(session)
@router.post("/", response_model=TaskPublic)
def create_task_route(task: TaskCreate, session: SessionDep):
return create_task(session, task)
What does the router do?
- router applies the /api/v1/tasks prefix to both operations.
- read_tasks() asks the repository for saved rows.
- create_task_route() passes validated input to the repository.
- SessionDep gives each route its request-scoped session.
- Save app/routers/tasks.py.
- Confirm that app/routers/tasks.py appears beneath the routers folder.
The application entrypoint composes the router and database engine. Its lifespan handler creates registered tables before the first request arrives.
- Create app/main.py by adding the application composition below:
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI
from sqlmodel import SQLModel
from app.database import engine
from app.routers import tasks
@asynccontextmanager
async def lifespan(app: FastAPI):
SQLModel.metadata.create_all(engine)
yield
app = FastAPI(title="Task Board API", lifespan=lifespan)
app.include_router(tasks.router)
frontend_router = APIRouter()
frontend_router.frontend("/", directory="dist")
app.include_router(frontend_router)
What does the entrypoint do?
- lifespan() creates the registered SQLite tables during application startup.
- app.include_router(tasks.router) attaches the versioned task routes.
- frontend_router reserves low-priority routes for the browser interface built in the next step.
- app.include_router(frontend_router) places those frontend routes after the API routes.
- Save app/main.py.
- Confirm that app/main.py appears beneath the app folder.
Entrypoint files not matching?
Keep pyproject.toml beside the app folder. Keep main.py inside that folder.
Check that the entrypoint reads app.main:app. Its first app names the package.
Help me check the new FastAPI entrypoint.
✔️ Awesome, I've got everything!
Your application boundaries are ready for the persistence test.
- Save every open file in scalable-task-board.
ⓧ I'd like to double check the full code
"""Task Board application package."""
Package reference
This is the complete application package marker.
from typing import Annotated
from fastapi import Depends
from sqlmodel import Session, create_engine
sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)
def get_session():
with Session(engine) as session:
yield session
SessionDep = Annotated[Session, Depends(get_session)]
Database reference
This is the complete engine and session dependency file.
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI
from sqlmodel import SQLModel
from app.database import engine
from app.routers import tasks
@asynccontextmanager
async def lifespan(app: FastAPI):
SQLModel.metadata.create_all(engine)
yield
app = FastAPI(title="Task Board API", lifespan=lifespan)
app.include_router(tasks.router)
frontend_router = APIRouter()
frontend_router.frontend("/", directory="dist")
app.include_router(frontend_router)
Entrypoint reference
This is the complete application composition file.
from sqlmodel import Field, SQLModel
class TaskBase(SQLModel):
title: str = Field(index=True)
class Task(TaskBase, table=True):
id: int | None = Field(default=None, primary_key=True)
completed: bool = False
class TaskCreate(TaskBase):
pass
class TaskPublic(TaskBase):
id: int
completed: bool
Model reference
This is the complete set of task data contracts.
from sqlmodel import Session, select
from app.models import Task, TaskCreate
def list_tasks(session: Session) -> list[Task]:
return list(session.exec(select(Task)).all())
def create_task(session: Session, task_data: TaskCreate) -> Task:
task = Task.model_validate(task_data)
session.add(task)
session.commit()
session.refresh(task)
return task
Repository reference
This is the complete persistence boundary.
"""API router package."""
Router package reference
This is the complete router package marker.
from fastapi import APIRouter
from app.database import SessionDep
from app.models import TaskCreate, TaskPublic
from app.repository import create_task, list_tasks
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
@router.get("/", response_model=list[TaskPublic])
def read_tasks(session: SessionDep):
return list_tasks(session)
@router.post("/", response_model=TaskPublic)
def create_task_route(task: TaskCreate, session: SessionDep):
return create_task(session, task)
Task router reference
This is the complete HTTP boundary for task creation and listing.
[tool.fastapi]
entrypoint = "app.main:app"
Project configuration reference
This is the complete FastAPI entrypoint configuration.
fastapi[standard]==0.141.1
sqlmodel==0.0.42
SQLAlchemy==2.0.52
Dependency reference
This reference confirms that the existing package pins remain unchanged.
The new boundaries are connected. Before you launch the application, do you expect the database file to exist before startup or appear during startup?
- Start the modular application by running this command in the activated terminal:
fastapi dev
What does this command do now?
The command reads pyproject.toml and imports the configured application. The lifespan handler creates the Task table before requests begin.
You should see the development server start successfully. The application should also create database.db inside scalable-task-board.
- Confirm that database.db now appears beside pyproject.toml in the file sidebar.
Server not starting?
- Confirm that the terminal still shows the activated .venv environment.
- Check that pyproject.toml sits inside scalable-task-board.
- Compare every import path with the full-code reference above.
Help me troubleshoot the modular Task Board startup.
- Switch back to Microsoft Edge from earlier.
- Refresh the interactive documentation.
- Expand POST /api/v1/tasks/.
- Click Try it out.
- Replace the example title value with Persist this task.
- Click Execute.
You should see a validated task response with a generated numeric ID. The response should show that the new task is incomplete.
- Record the generated ID here: your generated task ID.
- Expand GET /api/v1/tasks/.
- Click Try it out.
- Click Execute.
You should see Persist this task in the returned task list. Its ID should match the value you recorded.
- Switch back to the development server terminal.
- Stop the development server with the terminal's keyboard interrupt.
- Restart the application by running this command:
fastapi dev
What does the restart test isolate?
The old Python process is gone after the restart. Any task returned by the new process must have come from persistent storage.
Before you repeat the GET request, do you think the task will keep the same generated ID?
- Return to the interactive documentation in Microsoft Edge.
- Refresh the page.
- Expand GET /api/v1/tasks/.
- Click Try it out.
- Click Execute.
You should see Persist this task with ID your generated task ID. The row survived because SQLite stored it in database.db.
You closed the data-loss gap from Step 2. Your Task Board now preserves validated tasks across development server restarts.
Your backend also has clear model, database, repository, and routing boundaries. Next, you will connect a browser interface to this persistent API.
Connect the Browser Frontend
Your modular FastAPI backend now stores tasks in SQLite. The API works through the interactive documentation, but people still need a page where they can manage tasks directly.
This step adds an HTML interface with CSS styling. Browser-side JavaScript sends requests to the API. Serving everything from the same origin keeps the request flow focused by avoiding a second development server or CORS configuration.
In this step, get ready to:
- Build a styled task form with a status region and saved-task list.
- Connect the form to the versioned API with browser requests.
- Submit a task from Microsoft Edge and confirm that it survives a refresh.
Build the visible task board
The frontend needs three files inside a new dist directory. The page defines the interface while the stylesheet controls its layout and appearance.
- Return to the Visual Studio Code window from earlier.
- Use the folder creation control in the Explorer sidebar to create dist inside scalable-task-board.
- Select the new dist directory in the Explorer sidebar.
- Use the file creation control in the Explorer sidebar to create index.html.
You should now see dist/index.html in the Explorer sidebar.
- Select the dist directory again.
- Use the file creation control to create styles.css.
- Use the file creation control to create app.js.
The dist directory should now contain all three frontend files.
- Fill dist/index.html with the page structure by copying this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Task Board</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="task-board">
<p class="eyebrow">FastAPI and SQLite</p>
<h1>Task Board</h1>
<form id="task-form">
<label for="task-title">New task</label>
<div class="form-row">
<input id="task-title" name="title" required autocomplete="off">
<button type="submit">Add task</button>
</div>
</form>
<p id="status" aria-live="polite"></p>
<ul id="task-list"></ul>
</main>
<script src="/app.js" defer></script>
</body>
</html>
What does this page define?
- The task-form groups the task input with the submission button.
- The status region gives the learner feedback while a task is being saved.
- The task-list provides an empty container that JavaScript can redraw from API data.
- The stylesheet and script paths begin at the same server origin as the page.
- Save dist/index.html.
- Return to the activated terminal from earlier.
- Start the development server by running this command:
fastapi dev
What does this command do?
The FastAPI development command uses the entrypoint in pyproject.toml. It starts the local server with automatic reload enabled.
- Switch back to Microsoft Edge.
- Enter http://127.0.0.1:8000 in the address bar.
You should see the Task Board heading with a task field and an Add task button. The layout is plain because the empty stylesheet has not added its visual rules.
Page not loading?
Confirm that the terminal still shows the development server running. Check that index.html is inside dist.
If the API documentation loads but the board does not, compare the frontend directory name with the directory="dist" setting in app/main.py.
Help me diagnose why my FastAPI frontend is not loading from the dist directory.
✔️ Awesome, I've got everything!
Your page structure is saved. Keep the server running while you style each visible part.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Task Board</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="task-board">
<p class="eyebrow">FastAPI and SQLite</p>
<h1>Task Board</h1>
<form id="task-form">
<label for="task-title">New task</label>
<div class="form-row">
<input id="task-title" name="title" required autocomplete="off">
<button type="submit">Add task</button>
</div>
</form>
<p id="status" aria-live="polite"></p>
<ul id="task-list"></ul>
</main>
<script src="/app.js" defer></script>
</body>
</html>
CSS selectors attach visual rules to matching elements in the page. Building the stylesheet in small sections lets you see the interface change after every save.
- Add the page foundation to dist/styles.css by copying this code:
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
background: #eef2ff;
color: #172033;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
}
.task-board {
width: min(680px, calc(100% - 32px));
padding: 32px;
border-radius: 18px;
background: white;
box-shadow: 0 20px 50px rgba(23, 32, 51, 0.14);
}
How does the layout work?
- The root rules set the page colors and font family.
- The body uses a grid to place the board in the center of the viewport.
- The .task-board selector turns the main content into a white card with responsive width.
- Save dist/styles.css.
- Refresh the Task Board in Microsoft Edge.
You should see a centered white card on a pale background. The rounded corners and shadow now separate the board from the page.
Board still looks plain?
Confirm that styles.css is inside dist. Check that the stylesheet path in index.html is /styles.css.
Help me find why the first styles in dist/styles.css are not appearing on my Task Board.
- Add the heading and form layout below the existing rules in dist/styles.css by copying this code:
.eyebrow {
margin: 0;
color: #4f46e5;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
h1 {
margin-top: 8px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 700;
}
.form-row {
display: flex;
gap: 10px;
}
What changes in this section?
- The eyebrow becomes a compact purple label above the main heading.
- The heading and form label gain spacing that separates their roles.
- The form row places the input beside the button with a consistent gap.
- Save dist/styles.css.
- Refresh the Task Board in Microsoft Edge.
You should see the purple FastAPI and SQLite eyebrow above the heading. The task field and button now sit on one row.
Form still stacked vertically?
Check that .form-row includes display: flex. Confirm that the input and button remain inside the matching form-row element in index.html.
Help me compare my form-row HTML with the matching CSS selector.
- Add the controls and list foundation below the existing rules in dist/styles.css by copying this code:
input {
flex: 1;
padding: 12px;
border: 1px solid #c7d2fe;
border-radius: 10px;
font: inherit;
}
button {
padding: 12px 18px;
border: 0;
border-radius: 10px;
background: #4f46e5;
color: white;
font: inherit;
font-weight: 700;
cursor: pointer;
}
#status {
min-height: 24px;
color: #475569;
}
#task-list {
padding: 0;
list-style: none;
}
How are the controls styled?
- The input expands to use the remaining width in the form row.
- The button receives the board's purple accent color and a pointer cursor.
- The status region keeps enough height for feedback without shifting the layout.
- The task list removes its default bullets and padding.
- Save dist/styles.css.
- Refresh the Task Board in Microsoft Edge.
You should see a bordered input that fills the available space. The Add task button should appear purple with white text.
Controls not picking up their styles?
Check the closing brace for each selector above the new rules. One missing brace can prevent the browser from reading the selectors that follow.
Help me inspect the input and button sections in my Task Board stylesheet.
Connect the page to the task API
The browser needs references to the form elements before it can respond to submissions. It also needs small functions for rendering data and sending HTTP requests.
- Add the API path and page references to dist/app.js by copying this code:
const apiUrl = "/api/v1/tasks/";
const taskForm = document.querySelector("#task-form");
const taskTitle = document.querySelector("#task-title");
const taskList = document.querySelector("#task-list");
const statusMessage = document.querySelector("#status");
What do these references hold?
- The apiUrl value points to the versioned task collection on the same origin.
- The remaining constants hold the form elements that the script reads or updates.
- Save dist/app.js.
- Refresh the Task Board in Microsoft Edge.
- Press Ctrl+Shift+J to open the Console tool.
- Check the API path by running this expression in the Console:
apiUrl
What should the Console show?
The Console should return /api/v1/tasks/. This confirms that the browser loaded the script and created its first runtime value.
API path not defined?
Confirm that you saved app.js. Check that the script path at the bottom of index.html is /app.js.
Help me determine why apiUrl is not available in the Microsoft Edge Console.
- Add the error display and task rendering functions below the constants in dist/app.js by copying this code:
function showError(error) {
statusMessage.textContent = error.message;
}
function renderTasks(tasks) {
taskList.replaceChildren();
for (const task of tasks) {
const item = document.createElement("li");
const label = document.createElement("span");
label.textContent = `${task.completed ? "Done" : "Open"}: ${task.title}`;
item.append(label);
taskList.append(item);
}
}
How does rendering work?
- The showError function places a readable failure message in the page's status region.
- The renderTasks function clears stale list items before drawing the latest API result.
- Each task becomes a list item whose label shows its completion state and title.
- Save dist/app.js.
- Refresh the Task Board in Microsoft Edge.
- Confirm that the renderer loaded by running this expression in the Console:
renderTasks
What does this check prove?
The Console should display the function definition. This proves that the browser parsed the rendering function without stopping at a syntax error.
Renderer missing from the Console?
Look for an error in the Console that points to a specific line in app.js. Compare the quotes and braces around renderTasks with the code block.
Help me fix the syntax error preventing renderTasks from loading.
- Add the task loading function below renderTasks in dist/app.js by copying this code:
async function loadTasks() {
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error("Could not load tasks.");
}
const tasks = await response.json();
renderTasks(tasks);
}
How are saved tasks loaded?
- The browser sends a GET request to apiUrl.
- A failed response becomes an error that the page can display.
- A successful JSON response is passed to renderTasks.
- Save dist/app.js.
- Refresh the Task Board in Microsoft Edge.
- Load the existing SQLite-backed tasks by running this expression in the Console:
loadTasks()
What should happen now?
The task list should display the tasks you created through the API documentation. This is the first browser-to-API round trip in the interface.
Saved tasks not appearing?
Confirm that the API documentation still returns tasks from GET /api/v1/tasks/. Check the Console for a request or JSON parsing failure.
Help me trace why loadTasks does not render my SQLite-backed task list.
- Add the task creation function below loadTasks in dist/app.js by copying this code:
async function createTask(title) {
const response = await fetch(apiUrl, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({title}),
});
if (!response.ok) {
throw new Error("Could not save the task.");
}
}
How is a task created?
- The request uses POST because it creates a new task resource.
- The content type tells FastAPI to validate the body as JSON.
- The task title is converted into the request body expected by TaskCreate.
- Save dist/app.js.
- Refresh the Task Board in Microsoft Edge.
- Confirm that the creation function loaded by running this expression in the Console:
createTask
What should the Console show?
The Console should display the function definition. The browser now has the request function that the form submission handler needs.
Creation function not loading?
Check the Console for a syntax error near the request options. Confirm that the parentheses and braces close in the same order shown above.
Help me fix the createTask function in dist/app.js.
- Add the form submission handler at the bottom of dist/app.js by copying this code:
taskForm.addEventListener("submit", async (event) => {
event.preventDefault();
const title = taskTitle.value.trim();
if (!title) {
return;
}
statusMessage.textContent = "Saving...";
try {
await createTask(title);
taskTitle.value = "";
await loadTasks();
statusMessage.textContent = "Task saved.";
} catch (error) {
showError(error);
}
});
loadTasks().catch(showError);
How does the form complete the loop?
- The submit listener keeps the browser on the Task Board page.
- A blank title stops before any request is sent.
- A valid title passes through createTask before loadTasks redraws the list.
- The final line loads saved tasks whenever the page opens.
- Save dist/app.js.
- Refresh the Task Board in Microsoft Edge.
- Enter Connect the browser frontend in the New task field.
- Click Add task.
You should see Task saved. below the form. The new task should appear in the list with an Open label.
Task not saving from the form?
Check the Console for the first reported error. Confirm that every selector in the constants matches the IDs in index.html.
If the request reaches the API but fails, compare the JSON body with the title field expected by the task input model.
Help me trace why submitting the Task Board form does not save a task.
✔️ Awesome, I've got everything!
Your browser can now load existing tasks and create new ones through the API.
ⓧ I'd like to double check the full code
const apiUrl = "/api/v1/tasks/";
const taskForm = document.querySelector("#task-form");
const taskTitle = document.querySelector("#task-title");
const taskList = document.querySelector("#task-list");
const statusMessage = document.querySelector("#status");
function showError(error) {
statusMessage.textContent = error.message;
}
function renderTasks(tasks) {
taskList.replaceChildren();
for (const task of tasks) {
const item = document.createElement("li");
const label = document.createElement("span");
label.textContent = `${task.completed ? "Done" : "Open"}: ${task.title}`;
item.append(label);
taskList.append(item);
}
}
async function loadTasks() {
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error("Could not load tasks.");
}
const tasks = await response.json();
renderTasks(tasks);
}
async function createTask(title) {
const response = await fetch(apiUrl, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({title}),
});
if (!response.ok) {
throw new Error("Could not save the task.");
}
}
taskForm.addEventListener("submit", async (event) => {
event.preventDefault();
const title = taskTitle.value.trim();
if (!title) {
return;
}
statusMessage.textContent = "Saving...";
try {
await createTask(title);
taskTitle.value = "";
await loadTasks();
statusMessage.textContent = "Task saved.";
} catch (error) {
showError(error);
}
});
loadTasks().catch(showError);
- Finish the task row styling at the bottom of dist/styles.css by copying this code:
#task-list li {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
padding: 14px;
border-radius: 10px;
background: #f8fafc;
}
How are task rows finished?
Each rendered task becomes a pale row with spacing and rounded corners. The flexible layout also leaves room for the Delete button you can add in the Secret Mission.
- Save dist/styles.css.
- Refresh the Task Board in Microsoft Edge.
Your saved task should now sit inside a pale rounded row. The list reads like part of the board instead of plain text.
Task row still looks like plain text?
Confirm that the page currently displays at least one saved task. Check that the selector is exactly #task-list li.
Help me determine why the task list item styles are not appearing.
✔️ Awesome, I've got everything!
The entire Task Board stylesheet is saved. Your form and rendered task rows now share one visual design.
ⓧ I'd like to double check the full code
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
background: #eef2ff;
color: #172033;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
}
.task-board {
width: min(680px, calc(100% - 32px));
padding: 32px;
border-radius: 18px;
background: white;
box-shadow: 0 20px 50px rgba(23, 32, 51, 0.14);
}
.eyebrow {
margin: 0;
color: #4f46e5;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
h1 {
margin-top: 8px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 700;
}
.form-row {
display: flex;
gap: 10px;
}
input {
flex: 1;
padding: 12px;
border: 1px solid #c7d2fe;
border-radius: 10px;
font: inherit;
}
button {
padding: 12px 18px;
border: 0;
border-radius: 10px;
background: #4f46e5;
color: white;
font: inherit;
font-weight: 700;
cursor: pointer;
}
#status {
min-height: 24px;
color: #475569;
}
#task-list {
padding: 0;
list-style: none;
}
#task-list li {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
padding: 14px;
border-radius: 10px;
background: #f8fafc;
}
Trace the same-origin request flow
A same-origin frontend uses the same protocol, host, and port as its API. Your existing frontend router serves dist after the versioned API router, so the API routes keep priority.
- Return to app/main.py in Visual Studio Code.
- Confirm that the existing frontend router still matches this code:
frontend_router = APIRouter()
frontend_router.frontend("/", directory="dist")
app.include_router(frontend_router)
Why does this stay at the bottom?
The frontend router uses low-priority routes for files in dist. Including it after the task router leaves /api/v1/tasks/ available to GET and POST requests.
Before you submit the final test, do you expect the task to remain after the browser reloads?
- Switch back to the Task Board in Microsoft Edge.
- Enter Verify persistent browser task in the New task field.
- Click Add task.
You should see the new task immediately with an Open label. The status region should show Task saved..
- Refresh the page in Microsoft Edge.
The task should remain in the list after the refresh. That confirms the browser reloaded it from the SQLite-backed API instead of keeping it only in the page.
Trace one complete submission
- The form listener reads the title before calling createTask.
- The POST request reaches create_task_route through the versioned task router.
- The repository writes the task through the request-scoped SessionDep session.
- The browser calls loadTasks to fetch the committed SQLite rows and redraw the list.
Task disappears after refresh?
Check the API documentation to confirm that GET still returns the task. If GET is empty, confirm that the modular app is using database.db through the engine in app/database.py.
If GET returns the task, focus on the browser path through loadTasks and renderTasks.
Help me trace why my browser task disappears after a page refresh.
That closes the full request loop. Your browser now creates a task through FastAPI and redraws the durable database result without leaving the page.
Secret mission
Secret Mission: Delete Tasks End to End
Extend your Task Board with a delete operation that crosses every architectural boundary. A single button will remove a task from SQLite through the versioned API.
Clean Up Your Resources
Clean Up Your Resources
Your Task Board runs entirely on this Windows machine with no ongoing cost. Choose whether to keep it available, pause its server, or remove its local files.
Resources you used:
- The local scalable-task-board project folder.
- The .venv virtual environment containing the installed requirements.
- The app package containing the modular backend.
- The dist folder containing the browser interface.
- The pyproject.toml FastAPI entrypoint configuration.
- The requirements.txt dependency list.
- The database.db file containing the persistent SQLite task data.
Keep everything running
No action is needed. Choose this option if you want your Task Board code and saved task history ready for more development.
- Leave the scalable-task-board folder in its current location.
- Keep database.db inside the project folder to preserve your task history.
Pause - I'll come back to this later
Stop the local development server to release its port. Your project files and saved tasks remain unchanged.
- Switch back to the terminal panel in Visual Studio Code.
- Stop the development server by pressing CTRL+C.
You'll see the terminal return to a command prompt. The Task Board stops serving local requests until you start the development workflow again.
Delete - I don't want to use this again
Remove only the saved task data, or remove the entire local project. Removing the project folder leaves your installed development tools available.
Deleting these files can remove work you may want later. Use the data-only reset if you want to keep your source code.
Reset only saved task data:
- Switch back to the terminal panel from earlier.
- Stop the development server by pressing CTRL+C.
- Use the Windows file manager to locate database.db inside scalable-task-board.
- Delete database.db.
You should no longer see database.db in the project folder. The next server start creates a fresh database with an empty task list.
Delete the entire project:
- Use the Windows file manager to locate the scalable-task-board folder from Step 1.
- Delete the scalable-task-board folder.
You should no longer see scalable-task-board in its previous location. This removes the source files, frontend files, virtual environment, installed project packages, and saved task data.
Nice Work!
Nice Work!
You did it! Your local Task Board now sends browser actions through a modular FastAPI backend. SQLite keeps its task data available across server restarts and page refreshes.
You've learned how to:
- Build a versioned task API with validated request models. Test its JSON responses through interactive documentation.
- Replace temporary process memory with durable persistence in SQLite. Keep database access behind a repository plus request-scoped SQLModel sessions.
- Connect a browser interface to FastAPI through same-origin HTTP requests. Trace each task from its browser request to the database before rendering the result as HTML.
- Complete the optional Secret Mission by adding end-to-end task deletion. Keep deleted tasks absent after a page refresh.
Ready to quiz yourself?