NBA Player Stats Dashboard

Build a Python FastAPI web app that serves a styled NBA stats dashboard.

Introduction

30 Second Summary

Every time you check a sports stats website, you're looking at data pulled from a database and displayed in a neatly styled page. The connection between that database and what you see in your browser is the core skill behind all web development.

In this project, you will build a local NBA Player Stats Dashboard that queries a database and renders player statistics in a styled HTML page with a working search form.

What You'll Build

A Python-powered web application that serves an NBA stats dashboard from a local FastAPI server, complete with database queries, HTML templates, and CSS styling.

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

  • A FastAPI server that queries a SQLite database and serves dynamic HTML pages.
  • A styled HTML dashboard with a data table, search form, and responsive layout using CSS flexbox.
  • A clear understanding of the HTTP request/response cycle that connects your Python backend to the browser.
  • Secret Mission: A "Top Scorer" highlight feature that visually marks the highest-scoring player with a gold background.

Are there any prerequisites?

You should have basic Python knowledge (variables, functions, lists) and familiarity with HTML tags like <h1>, <p>, and attributes. No web framework experience is needed.

Before We Start

Before you can build your NBA stats dashboard, you need a workspace for your code and the right tools installed.

In this step, you'll create a project folder, set up a Python virtual environment, and install FastAPI, the Python framework that will power your dashboard.

In this step, get ready to:
  • Create a project folder with templates and static subdirectories.
  • Set up a Python virtual environment.
  • Install FastAPI and verify it works.
Create the project folder structure

Your project needs a clear folder structure to keep things organized. You'll create a main project folder, plus two subdirectories: one for HTML templates and one for static files like CSS.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (this shortcut works on both macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir nba-dashboard
cd nba-dashboard
  • Create the two subdirectories your project needs by running:
mkdir templates static

What are these folders for?

  • templates/ will hold your HTML files. FastAPI uses a templating engine to fill them with data.
  • static/ will hold your CSS stylesheet. These files are served directly to the browser without any processing.
  • Confirm the folders exist by running:
ls

You should see templates and static in the output.

Don't see both folders?

Make sure you ran cd nba-dashboard before running mkdir templates static. If you created them in the wrong place, delete them with rm -rf templates static and try again from inside the nba-dashboard folder.

Help me fix my project folder structure.

Create a virtual environment and install FastAPI

A virtual environment is an isolated space where Python packages live for just this project. It prevents conflicts with other Python projects on your machine.

  • Create a virtual environment by running this command:

macOS

python3 -m venv venv

Windows

python -m venv venv

What is a virtual environment?

Think of it like a clean toolbox for this project only. Packages you install here won't affect anything else on your computer.

The second venv in the command is the folder name where the environment lives. You'll see a new venv/ folder appear in your project.

  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned and then try the activate command again.

On macOS, make sure you're using the correct path. The command is source venv/bin/activate (note the forward slashes).

Help me activate my Python virtual environment.

  • Install FastAPI and all its standard dependencies by running:
pip install "fastapi[standard]"

What does fastapi[standard] include?

The [standard] extra bundles everything you need in one install command:

  • uvicorn to run your web server.
  • jinja2 to render HTML templates with Python data.
  • python-multipart to handle form submissions.

You should see a stream of download and install messages ending with a success confirmation.

Seeing a permission error?

Make sure (venv) is showing in your terminal prompt. If it's not, you're installing into your system Python instead of the virtual environment. Re-run the activate command from the previous step.

Help me fix my pip install error.

Verify the installation
  • Confirm FastAPI installed correctly by running:
python -c "import fastapi; print(fastapi.__version__)"

✔️ I see a version number

You should see a version number like 0.136.3 printed in the terminal. The exact number may differ slightly, and that's fine. FastAPI is installed and ready to go.

ⓧ I see an error

If you see a ModuleNotFoundError, the install didn't complete or your virtual environment isn't active.

  • Check that (venv) appears in your terminal prompt.
  • If it doesn't, re-run the activate command for your platform.
  • Run pip install "fastapi[standard]" again.
  • Try the verify command once more.

Help me fix my FastAPI installation.

  • Open your project folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the nba-dashboard folder, and click Open.

You should see templates, static, and venv listed in the VS Code Explorer sidebar on the left.

Your workspace is ready. Next up, you'll create a database full of NBA player stats for your dashboard to display.

Build Your SQLite Database

Your Python environment is ready with FastAPI installed. Now it's time to give your dashboard some data to display.

Every web application needs a data source. In this step, you'll create a SQLite database and fill it with NBA player statistics that your dashboard will query later.

In this step, get ready to:
  • Create a Python script that connects to a SQLite database.
  • Define a players table with columns for stats like points, assists, and rebounds.
  • Seed the database with 10 NBA players and their season stats.
Create the database connection and table schema

Your dashboard needs a structured place to store player data. SQLite is a lightweight database that lives in a single file, so there's no separate server to install or configure.

  • In VS Code, right-click inside the Explorer sidebar (the file panel on the left).
  • Select New File.
  • Name the file database.py and press Enter.
  • Add the database connection function and table creation logic by pasting this code into database.py:
import sqlite3


def get_db_connection():
    # Connect to the SQLite database file
    conn = sqlite3.connect("nba_stats.db")
    # Allow accessing columns by name (e.g., player["name"])
    conn.row_factory = sqlite3.Row
    return conn


def init_db():
    conn = get_db_connection()
    cursor = conn.cursor()

    # Create the players table if it doesn't already exist
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS players (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL,
            team TEXT NOT NULL,
            ppg REAL NOT NULL,
            apg REAL NOT NULL,
            rpg REAL NOT NULL
        )
    """)

What does this code do?

You've defined two functions so far:

  • get_db_connection() creates a connection to a file called nba_stats.db. Setting row_factory to sqlite3.Row lets you access columns by name instead of index number.
  • init_db() uses a SQL CREATE TABLE IF NOT EXISTS statement to set up the players table with columns for each stat (points, assists, rebounds per game).

You won't see a change yet. The next chunk completes this function with player data.

Seed the database with NBA player stats

A table without data is like an empty spreadsheet. You need to fill it with real player records so your dashboard has something to display.

  • In database.py, add the following code directly below the cursor.execute block you just wrote (still inside the init_db() function):
    # Only insert data if the table is empty (prevents duplicates)
    cursor.execute("SELECT COUNT(*) FROM players")
    if cursor.fetchone()[0] == 0:
        players = [
            ("Luka Doncic", "Dallas Mavericks", 33.9, 9.8, 9.2),
            ("Shai Gilgeous-Alexander", "Oklahoma City Thunder", 31.1, 6.2, 5.5),
            ("Giannis Antetokounmpo", "Milwaukee Bucks", 30.4, 6.5, 11.5),
            ("Jayson Tatum", "Boston Celtics", 26.9, 4.9, 8.1),
            ("Anthony Edwards", "Minnesota Timberwolves", 25.9, 5.1, 5.4),
            ("Kevin Durant", "Phoenix Suns", 27.1, 5.0, 6.6),
            ("LeBron James", "Los Angeles Lakers", 25.7, 8.3, 7.3),
            ("Nikola Jokic", "Denver Nuggets", 26.4, 9.0, 12.4),
            ("Donovan Mitchell", "Cleveland Cavaliers", 26.6, 5.1, 5.1),
            ("De'Aaron Fox", "Sacramento Kings", 26.6, 5.6, 4.6),
        ]
        cursor.executemany(
            "INSERT INTO players (name, team, ppg, apg, rpg) VALUES (?, ?, ?, ?, ?)",
            players,
        )

    # Save changes and close the connection
    conn.commit()
    conn.close()

What does this code do?

  • SELECT COUNT(*) checks how many rows already exist. The if cursor.fetchone()[0] == 0 condition ensures data is only inserted once, even if you run the script multiple times.
  • executemany() inserts all 10 player records in a single batch. The ? placeholders protect against SQL injection by separating data from the query.
  • conn.commit() saves the changes to disk. Without it, your inserts would be lost when the connection closes.

You won't see a change yet. The next chunk adds the entry point so you can run this script.

Run the script and verify your data

Python scripts need an entry point to know what to execute when you run them directly. The if __name__ == "__main__" pattern provides exactly that.

  • At the very bottom of database.py (outside and below the init_db() function), add the script entry point:
if __name__ == "__main__":
    init_db()
    print("Database initialized with NBA player stats!")

What does this code do?

The if __name__ == "__main__" block only runs when you execute the file directly (e.g., python database.py). It won't run when another file imports functions from this module. This lets your server import get_db_connection() later without re-initializing the database every time.

  • Save database.py.
  • Run the script in your terminal by running this command:
python database.py

You should see this output in your terminal:

Database initialized with NBA player stats!
  • Verify the data was inserted correctly by running this command:
python -c "import sqlite3; conn = sqlite3.connect('nba_stats.db'); print(conn.execute('SELECT COUNT(*) FROM players').fetchone()[0], 'players found')"

You should see 10 players found in your terminal output.

Seeing an error or 0 players found?

  • Check that your virtual environment is still active. You should see (venv) or similar at the start of your terminal prompt.
  • Make sure the indentation inside init_db() is consistent. All code inside the function must be indented by 4 spaces.
  • Confirm that conn.commit() is inside init_db() but outside the if cursor.fetchone()[0] == 0: block. It should be at the same indent level as cursor.execute.

Why isn't my database.py script creating the database file?

✔️ Awesome, I've got everything!

Great. Double check you've saved database.py and that your terminal shows 10 players found.

ⓧ I'd like to double check the full code

Here's the complete database.py file. Compare it with yours line by line:

import sqlite3


def get_db_connection():
    conn = sqlite3.connect("nba_stats.db")
    conn.row_factory = sqlite3.Row
    return conn


def init_db():
    conn = get_db_connection()
    cursor = conn.cursor()

    cursor.execute("""
        CREATE TABLE IF NOT EXISTS players (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL,
            team TEXT NOT NULL,
            ppg REAL NOT NULL,
            apg REAL NOT NULL,
            rpg REAL NOT NULL
        )
    """)

    cursor.execute("SELECT COUNT(*) FROM players")
    if cursor.fetchone()[0] == 0:
        players = [
            ("Luka Doncic", "Dallas Mavericks", 33.9, 9.8, 9.2),
            ("Shai Gilgeous-Alexander", "Oklahoma City Thunder", 31.1, 6.2, 5.5),
            ("Giannis Antetokounmpo", "Milwaukee Bucks", 30.4, 6.5, 11.5),
            ("Jayson Tatum", "Boston Celtics", 26.9, 4.9, 8.1),
            ("Anthony Edwards", "Minnesota Timberwolves", 25.9, 5.1, 5.4),
            ("Kevin Durant", "Phoenix Suns", 27.1, 5.0, 6.6),
            ("LeBron James", "Los Angeles Lakers", 25.7, 8.3, 7.3),
            ("Nikola Jokic", "Denver Nuggets", 26.4, 9.0, 12.4),
            ("Donovan Mitchell", "Cleveland Cavaliers", 26.6, 5.1, 5.1),
            ("De'Aaron Fox", "Sacramento Kings", 26.6, 5.6, 4.6),
        ]
        cursor.executemany(
            "INSERT INTO players (name, team, ppg, apg, rpg) VALUES (?, ?, ?, ?, ?)",
            players,
        )

    conn.commit()
    conn.close()


if __name__ == "__main__":
    init_db()
    print("Database initialized with NBA player stats!")

Your database is loaded with 10 NBA players and their stats. Next up, you'll build the FastAPI server that reads this data and sends it to your HTML template.

Create Your FastAPI Server

Your database is loaded with NBA player stats and ready to go. Now you need a Python server that can pull that data out of SQLite and send it to a web browser.

FastAPI is a Python web framework that handles HTTP requests from browsers and returns responses. In this step, you'll create a server file that connects your database to an HTML template.

In this step, get ready to:
  • Set up your FastAPI app with static file and template directories.
  • Create a route that queries player data from your database.
  • Add search filtering so visitors can find players by team name.
Set up main.py with FastAPI configuration

FastAPI needs to know where your HTML templates and CSS files live. You'll configure these paths when creating the app so it can find them at runtime.

  • Create a new file called main.py in the root of your project folder (the same folder where database.py lives).
  • Add the following setup code to main.py:
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from database import get_db_connection, init_db

app = FastAPI()

app.mount("/static", StaticFiles(directory="static"), name="static")

templates = Jinja2Templates(directory="templates")

init_db()

What does this code do?

  • The first four imports bring in FastAPI's core pieces: the app class, request handling, static file serving, and template rendering.
  • from database import get_db_connection, init_db pulls in the functions you wrote in the previous step.
  • app.mount("/static", ...) tells FastAPI to serve any file inside your static/ folder when the browser requests a URL starting with /static.
  • Jinja2Templates(directory="templates") points FastAPI at your templates/ folder so it can find HTML files to render.
  • init_db() ensures the database and player data exist every time the server starts.
  • Save main.py.
  • You won't see a change yet because the route and template haven't been created. The next chunk adds the route.

Seeing a red underline on the imports?

Make sure your virtual environment is active in VS Code. Check that the terminal prompt shows your environment name in parentheses.

If the import for database is underlined, confirm that database.py is in the same folder as main.py.

Help me fix my FastAPI import errors

Add a route to query and display players

When someone visits your dashboard in a browser, the server needs to know what to do with that request. A route maps a URL path to a Python function that generates a response.

  • Add the following route function below the init_db() line in main.py:
@app.get("/", response_class=HTMLResponse)
async def home(request: Request, team: str = ""):
    conn = get_db_connection()

    if team:
        players = conn.execute(
            "SELECT * FROM players WHERE team LIKE ?", (f"%{team}%",)
        ).fetchall()
    else:
        players = conn.execute("SELECT * FROM players").fetchall()

    conn.close()

    return templates.TemplateResponse(
        request=request,
        name="dashboard.html",
        context={"players": players, "search_team": team},
    )

What does this route do?

  • @app.get("/") registers this function to handle GET requests to the home page (/).
  • request: Request captures the incoming HTTP request object. FastAPI passes this automatically.
  • team: str = "" captures an optional query parameter from the URL (like ?team=Lakers). It defaults to an empty string.
  • The function opens a database connection, runs a query, then closes the connection.
  • templates.TemplateResponse(...) renders an HTML template and sends it back to the browser as the response.
  • Save main.py.
  • You won't be able to test this yet because dashboard.html doesn't exist. You'll create it in the next step.

Seeing indentation errors?

Python is strict about indentation. Make sure the if team: block and everything inside the function uses exactly 4 spaces per indent level.

Also check that the @app.get decorator sits directly above async def home with no blank lines between them.

Help me fix my indentation in main.py

Understand the team search filter

The route you just wrote handles two scenarios in a single function. The if team: conditional decides which database query to run based on whether the visitor searched for a team.

How does the search filtering work?

When a visitor first loads the page, the team parameter is an empty string. The else branch runs and fetches every player from the database.

When a visitor submits the search form (you'll build this in the next step), the browser sends a request like /?team=Lakers. FastAPI automatically pulls Lakers out of the URL and passes it as the team argument.

The LIKE query with % wildcards means partial matches work. Typing Laker still finds the Los Angeles Lakers.

What is a query parameter?

A query parameter is a key-value pair appended to a URL after a ? character. For example, in http://127.0.0.1:8000/?team=Celtics, the key is team and the value is Celtics.

FastAPI reads these automatically when you add a matching parameter name to your function signature.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py and move on to creating the HTML template.

ⓧ I'd like to double check the full code

Here's the complete main.py file. Make sure yours matches exactly:

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from database import get_db_connection, init_db

app = FastAPI()

app.mount("/static", StaticFiles(directory="static"), name="static")

templates = Jinja2Templates(directory="templates")

init_db()


@app.get("/", response_class=HTMLResponse)
async def home(request: Request, team: str = ""):
    conn = get_db_connection()

    if team:
        players = conn.execute(
            "SELECT * FROM players WHERE team LIKE ?", (f"%{team}%",)
        ).fetchall()
    else:
        players = conn.execute("SELECT * FROM players").fetchall()

    conn.close()

    return templates.TemplateResponse(
        request=request,
        name="dashboard.html",
        context={"players": players, "search_team": team},
    )

Your server is ready to serve data. Next up, you'll build the HTML template that turns those player rows into a visible dashboard.

Build the HTML Dashboard Template

Your FastAPI server is ready to query the database and pass player data to a template. The missing piece is the template itself.

In this step, you will create the HTML file that your server renders. It will include semantic elements for structure, a search form for filtering players, and a data table powered by Jinja2 template loops.

In this step, get ready to:
  • Create the HTML document structure with semantic elements.
  • Add a search form that sends GET requests to filter players by team.
  • Build a data table that renders player rows dynamically with Jinja2 loops.
Create the HTML document structure

Every web page starts with an HTML document skeleton. Semantic elements like <header>, <main>, and <footer> give both the browser and the developer a clear sense of what each section does.

  • In the Explorer sidebar of VS Code, open the templates folder.
  • Create a new file inside it called dashboard.html.
  • Paste the following code into dashboard.html:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>NBA Player Stats Dashboard</title>
    <!-- Link to CSS stylesheet using Jinja2's url_for helper -->
    <link href="{{ url_for('static', path='/styles.css') }}" rel="stylesheet">
</head>
<body>
    <!-- Page header with title and subtitle -->
    <header>
        <h1>🏀 NBA Player Stats Dashboard</h1>
        <p>Player statistics powered by FastAPI + SQLite</p>
    </header>

    <!-- Main content area (form and table go here next) -->
    <main>
    </main>

    <!-- Page footer -->
    <footer>
        <p>Built with FastAPI, Jinja2, and SQLite</p>
    </footer>
</body>
</html>

What does this code do?

  • <!DOCTYPE html> tells the browser this is a modern HTML5 document.
  • The <meta> tags set the character encoding and make the page scale properly on mobile screens.
  • {{ url_for('static', path='/styles.css') }} is Jinja2 syntax that generates the correct URL to your stylesheet at runtime.
  • <header>, <main>, and <footer> are semantic elements that describe the purpose of each section, not just its position.

What is a semantic element?

A semantic element communicates meaning to both the browser and anyone reading your code. For example, <header> says "this is introductory content" while a plain <div> says nothing about its role.

Screen readers and search engines use these hints to understand your page structure.

  • Save dashboard.html.

You won't see a change in the browser yet because the server isn't running. The next two chunks fill in the <main> section with your search form and data table.

File not appearing in the templates folder?

  • Make sure you created the file inside the templates/ directory, not in the project root. The server looks specifically in that folder.
  • Double-check the filename is exactly dashboard.html (all lowercase, no spaces).

Why isn't my dashboard.html file showing up in the templates folder?

Add the search form

The search form lets visitors filter the player table by team name. It sends a GET request back to the same page with a team query parameter that your FastAPI route already knows how to read.

  • In dashboard.html, find the empty <main> tags.
  • Paste the following code between <main> and </main>:
        <section class="search-section">
            <form action="/" method="get" class="search-form">
                <!-- Text input for team name, pre-filled with the current search -->
                <input
                    type="text"
                    name="team"
                    placeholder="Search by team name..."
                    value="{{ search_team }}"
                >
                <button type="submit">Search</button>
            </form>
        </section>

What does this code do?

  • action="/" sends the form submission back to the home route. method="get" appends the input value as a URL query parameter (e.g. /?team=Lakers).
  • name="team" on the input matches the team parameter in your FastAPI route (team: str = "").
  • value="{{ search_team }}" pre-fills the input with whatever the user last searched. Jinja2 replaces {{ search_team }} with the Python variable your server passed in the template context.

Why does the form use GET instead of POST?

GET is the correct HTTP method for read-only operations like searching. It puts the search term in the URL, which means the results page is bookmarkable and shareable.

POST is reserved for actions that change data on the server (like submitting a new player). Your search only reads existing data, so GET fits perfectly.

  • Save dashboard.html.

You won't see a visual result yet. The next chunk adds the data table that displays the player rows.

Not sure where to paste the form code?

  • The code goes directly between the opening <main> tag and the closing </main> tag. There should be no other content between them yet.
  • Make sure you did not accidentally paste inside the <header> or <footer> sections.

Help me find where to put the search form in my HTML file.

Add the player stats table with Jinja2 loops

The table is where your database data becomes visible. Jinja2's {% for %} loop creates one table row for every player your server passes to the template.

  • In dashboard.html, find the closing </section> tag of the search section you just added.
  • Paste the following code directly below that closing tag, but still inside <main>:
        <section class="stats-section">
            {% if players %}
            <table>
                <thead>
                    <tr>
                        <th>Player</th>
                        <th>Team</th>
                        <th>PPG</th>
                        <th>APG</th>
                        <th>RPG</th>
                    </tr>
                </thead>
                <tbody>
                    <!-- Loop through each player passed from the server -->
                    {% for player in players %}
                    <tr>
                        <td>{{ player["name"] }}</td>
                        <td>{{ player["team"] }}</td>
                        <td>{{ player["ppg"] }}</td>
                        <td>{{ player["apg"] }}</td>
                        <td>{{ player["rpg"] }}</td>
                    </tr>
                    {% endfor %}
                </tbody>
            </table>
            {% else %}
            <!-- Fallback message when no players match the search -->
            <p class="no-results">No players found. Try a different team name.</p>
            {% endif %}
        </section>

What does this code do?

  • {% if players %} checks whether the server found any matching players. If the list is empty, it shows a friendly "No players found" message instead of a blank table.
  • <thead> defines the column headers (Player, Team, PPG, APG, RPG). This row stays fixed at the top of the table.
  • {% for player in players %} loops through every player object. Each iteration creates a new <tr> (table row) filled with that player's data.
  • {{ player["name"] }} outputs the value of the name column. This works because your database connection uses row_factory = sqlite3.Row, which lets you access columns by name.
  • Save dashboard.html.
  • Confirm the file is saved by checking for a plain filename tab in VS Code (no dot or circle indicating unsaved changes).

Your template is complete. You will see it in action once the server runs in a later step.

Seeing a Jinja2 syntax error later when you run the server?

  • Check that every {% for %} has a matching {% endfor %} and every {% if %} has a matching {% endif %}.
  • Make sure you used double curly braces {{ }} for outputting values and curly-brace-percent {% %} for logic (if/for). Mixing them up causes template errors.
  • Verify that the quotes around column names match: player["name"] uses double quotes inside the brackets.

Help me debug a Jinja2 template syntax error in my dashboard.

✔️ Awesome, I've got everything!

Great. Double check you have saved dashboard.html inside the templates/ folder.

ⓧ I'd like to double check the full code

Here is the complete templates/dashboard.html file. Compare yours against it:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>NBA Player Stats Dashboard</title>
    <link href="{{ url_for('static', path='/styles.css') }}" rel="stylesheet">
</head>
<body>
    <header>
        <h1>🏀 NBA Player Stats Dashboard</h1>
        <p>Player statistics powered by FastAPI + SQLite</p>
    </header>

    <main>
        <section class="search-section">
            <form action="/" method="get" class="search-form">
                <input
                    type="text"
                    name="team"
                    placeholder="Search by team name..."
                    value="{{ search_team }}"
                >
                <button type="submit">Search</button>
            </form>
        </section>

        <section class="stats-section">
            {% if players %}
            <table>
                <thead>
                    <tr>
                        <th>Player</th>
                        <th>Team</th>
                        <th>PPG</th>
                        <th>APG</th>
                        <th>RPG</th>
                    </tr>
                </thead>
                <tbody>
                    {% for player in players %}
                    <tr>
                        <td>{{ player["name"] }}</td>
                        <td>{{ player["team"] }}</td>
                        <td>{{ player["ppg"] }}</td>
                        <td>{{ player["apg"] }}</td>
                        <td>{{ player["rpg"] }}</td>
                    </tr>
                    {% endfor %}
                </tbody>
            </table>
            {% else %}
            <p class="no-results">No players found. Try a different team name.</p>
            {% endif %}
        </section>
    </main>

    <footer>
        <p>Built with FastAPI, Jinja2, and SQLite</p>
    </footer>
</body>
</html>

Your HTML template is ready to render player data. Next up, you will style the page with CSS to turn this raw structure into a polished dashboard.

Style Your Dashboard with CSS

Your HTML template is complete and your server knows how to serve it. Right now, though, the page would look like plain unstyled text with no layout or color.

In this step, you'll create a CSS stylesheet that transforms the raw HTML into a polished dashboard with a dark header, a flexbox search bar, and a striped data table.

In this step, get ready to:
  • Set base styles and a dark header for the page.
  • Style the search form with flexbox layout.
  • Style the data table with alternating row colors and hover effects.
Create base styles and header styling

Every browser applies its own default margins and padding to HTML elements. A CSS reset removes these inconsistencies so your layout looks the same everywhere.

  • In the Explorer sidebar of VS Code, click on the static folder.
  • Create a new file called styles.css inside the static folder.
  • Add the CSS reset, body styles, and header styles by pasting this code:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
    background-color: #f0f2f5;
    color: #1a1a2e;
    line-height: 1.6;
}

header {
    background-color: #1a1a2e;
    color: #ffffff;
    padding: 2rem;
    text-align: center;
}

header h1 {
    font-size: 2rem;
    margin-bottom: 0.5rem;
}

header p {
    color: #a0a0b0;
    font-size: 0.95rem;
}

main {
    max-width: 900px;
    margin: 2rem auto;
    padding: 0 1rem;
}

What does this code do?

The universal selector * strips all default browser margins and padding. box-sizing: border-box makes padding count inside an element's width, which prevents layout surprises.

The body block sets a light gray background and a system font stack that looks clean on all operating systems. The header gets a dark navy background with centered white text to anchor the top of the page.

The main element uses margin: 2rem auto to center itself horizontally with a max width of 900 pixels. This keeps the content readable on wide screens.

  • Save styles.css.
  • Confirm the file exists by running this in your terminal:
ls static/

You should see styles.css listed in the output.

Don't see styles.css?

Make sure you created the file inside the static/ folder, not in the project root. The path should be static/styles.css.

help me find where my styles.css file ended up

Style the search form with flexbox

Flexbox is a CSS layout mode that arranges items in a row or column without manual positioning. It handles spacing and alignment for you.

  • In static/styles.css, add the search form styles below the main block:
.search-section {
    margin-bottom: 2rem;
}

.search-form {
    display: flex;
    gap: 0.5rem;
}

.search-form input {
    flex: 1;
    padding: 0.75rem 1rem;
    border: 2px solid #ddd;
    border-radius: 6px;
    font-size: 1rem;
}

.search-form input:focus {
    outline: none;
    border-color: #4a90d9;
}

.search-form button {
    padding: 0.75rem 1.5rem;
    background-color: #4a90d9;
    color: #ffffff;
    border: none;
    border-radius: 6px;
    font-size: 1rem;
    cursor: pointer;
}

.search-form button:hover {
    background-color: #357abd;
}

What does this code do?

The display: flex on .search-form places the input and button side by side in a row. The gap: 0.5rem adds consistent spacing between them without manual margins.

The input uses flex: 1 to stretch and fill all remaining horizontal space. The button stays at its natural width.

The :focus and :hover pseudo-classes add visual feedback. The input border turns blue when clicked, and the button darkens when hovered.

  • Save styles.css.

You won't see these styles applied yet. The next step starts the server so you can view the dashboard in your browser.

Seeing a syntax error highlight in VS Code?

Check that every opening curly brace { has a matching closing brace }. A missing brace causes all styles below it to break.

help me fix my CSS syntax error

Style the table with alternating rows and hover effects

Data tables become hard to read when every row looks the same. Alternating background colors and hover highlights help users track across columns.

  • In static/styles.css, add the table, no-results, and footer styles below the .search-form button:hover block:
.stats-section {
    background-color: #ffffff;
    border-radius: 8px;
    overflow: hidden;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

table {
    width: 100%;
    border-collapse: collapse;
}

thead {
    background-color: #1a1a2e;
    color: #ffffff;
}

th {
    padding: 1rem;
    text-align: left;
    font-weight: 600;
}

td {
    padding: 0.85rem 1rem;
    border-bottom: 1px solid #eee;
}

tbody tr:hover {
    background-color: #f0f7ff;
}

tbody tr:nth-child(even) {
    background-color: #f9f9f9;
}

tbody tr:nth-child(even):hover {
    background-color: #f0f7ff;
}

.no-results {
    padding: 2rem;
    text-align: center;
    color: #666;
}

footer {
    text-align: center;
    padding: 2rem;
    color: #666;
    font-size: 0.9rem;
}

What does this code do?

The .stats-section wrapper gives the table a white card with rounded corners and a subtle shadow. The overflow: hidden clips the table corners to match the card's rounded shape.

The border-collapse: collapse removes the gaps between table cells so borders merge cleanly.

The tr:nth-child(even) selector targets every second row and gives it a light gray background. Combined with tr:hover, this creates a zebra-striped table that highlights the row under your cursor.

  • Save styles.css.

Your stylesheet is complete. You'll see all these styles come to life in the next step when you start the server.

Worried you missed something?

Use the full code tab below to compare your file against the complete stylesheet. Every selector, property, and value should match exactly.

help me compare my CSS to the expected code

✔️ Awesome, I've got everything!

Great. Double check that you've saved static/styles.css before moving on.

ⓧ I'd like to double check the full code

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
    background-color: #f0f2f5;
    color: #1a1a2e;
    line-height: 1.6;
}

header {
    background-color: #1a1a2e;
    color: #ffffff;
    padding: 2rem;
    text-align: center;
}

header h1 {
    font-size: 2rem;
    margin-bottom: 0.5rem;
}

header p {
    color: #a0a0b0;
    font-size: 0.95rem;
}

main {
    max-width: 900px;
    margin: 2rem auto;
    padding: 0 1rem;
}

.search-section {
    margin-bottom: 2rem;
}

.search-form {
    display: flex;
    gap: 0.5rem;
}

.search-form input {
    flex: 1;
    padding: 0.75rem 1rem;
    border: 2px solid #ddd;
    border-radius: 6px;
    font-size: 1rem;
}

.search-form input:focus {
    outline: none;
    border-color: #4a90d9;
}

.search-form button {
    padding: 0.75rem 1.5rem;
    background-color: #4a90d9;
    color: #ffffff;
    border: none;
    border-radius: 6px;
    font-size: 1rem;
    cursor: pointer;
}

.search-form button:hover {
    background-color: #357abd;
}

.stats-section {
    background-color: #ffffff;
    border-radius: 8px;
    overflow: hidden;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

table {
    width: 100%;
    border-collapse: collapse;
}

thead {
    background-color: #1a1a2e;
    color: #ffffff;
}

th {
    padding: 1rem;
    text-align: left;
    font-weight: 600;
}

td {
    padding: 0.85rem 1rem;
    border-bottom: 1px solid #eee;
}

tbody tr:hover {
    background-color: #f0f7ff;
}

tbody tr:nth-child(even) {
    background-color: #f9f9f9;
}

tbody tr:nth-child(even):hover {
    background-color: #f0f7ff;
}

.no-results {
    padding: 2rem;
    text-align: center;
    color: #666;
}

footer {
    text-align: center;
    padding: 2rem;
    color: #666;
    font-size: 0.9rem;
}

Your stylesheet is locked in. Next up, you'll start the server and watch these styles transform your raw HTML into a polished dashboard.

Run and Test Your Dashboard

You have built every piece of the dashboard: the database, the server, the HTML template, and the stylesheet. Now it is time to bring them all together and see the app live in your browser.

In this step, you will start the FastAPI development server, confirm your dashboard renders correctly, then use browser DevTools to inspect how your HTML and CSS work together under the hood.

In this step, get ready to:
  • Start the FastAPI development server and view the dashboard in your browser.
  • Test the search form to filter players by team name.
  • Inspect your page structure and styles with browser DevTools.
Start the FastAPI development server

Your server file main.py is ready to serve the dashboard. The fastapi dev command launches a development server with auto-reload enabled, so any code changes you save automatically restart the server.

  • Start the development server by running this command in your terminal:
fastapi dev main.py
  • You should see output ending with a line that says Application startup complete. and the server URL http://127.0.0.1:8000.
  • Open your browser (Chrome or Edge) and navigate to http://127.0.0.1:8000.

You should see the NBA Player Stats Dashboard with a dark header, a search bar, and a table showing all 10 players with their stats.

What is happening behind the scenes?

When you visit http://127.0.0.1:8000, your browser sends an HTTP GET request to the FastAPI server.

The server runs the home function in main.py, which queries all players from SQLite, passes them to the Jinja2 template, and returns the rendered HTML back to the browser.

Seeing an error in the terminal?

If you see ModuleNotFoundError, make sure your virtual environment is activated. You should see a (venv) or similar prefix in your terminal prompt.

If the terminal says the port is already in use, another process is running on port 8000. Stop it with Ctrl+C and try the command again.

help me debug my FastAPI server startup error

Test the search form

The search form at the top of the page sends a GET request with a team query parameter. Your server route uses that parameter to filter the SQLite query with a LIKE clause.

  • Click inside the search bar at the top of the page.
  • Type Lakers into the search field.
  • Click the Search button.

The table should now show only LeBron James (Los Angeles Lakers). Notice the URL bar has changed to http://127.0.0.1:8000/?team=Lakers.

  • Clear the search field and click Search again to bring back all 10 players.
  • Try searching for Celtics to confirm Jayson Tatum appears on his own.

Why does the URL change?

The form uses method="get", which appends form data as a query string in the URL. This is the correct approach for read-only searches because the results are bookmarkable and shareable.

Inspect the page with browser DevTools

Browser DevTools let you see the raw HTML your server sent and the CSS styles applied to each element. This is how professional developers debug layout and styling issues.

  • Press F12 on your keyboard (or Cmd+Option+I on macOS) to open DevTools.
  • Click the Elements tab at the top of the DevTools panel.
  • Expand the <body> element, then expand <main>.
  • Click on the <table> element. Look at the right-side panel to see your CSS styles applied from styles.css.

What should I see in DevTools?

In the Elements tab, you can see the full HTML tree your server generated. Each <tr> inside <tbody> represents one player row. The Jinja2 loop produced these from your Python data.

In the Styles panel on the right, you can see which CSS rules are applied to the selected element. Hover over a table row in the page to see the hover effect highlight in real time.

  • Hover over different table rows in the browser to see the hover effect (light blue background) you styled earlier.
  • Click on the <form> element in DevTools and notice the display: flex rule from your .search-form class in the Styles panel.

Keep DevTools open while developing

Whenever something looks off in your page, DevTools is the first place to check. You can see exactly which styles are being applied, overridden, or missing.

Your NBA Player Stats Dashboard is fully running. You have a Python backend serving dynamic HTML, a styled frontend with flexbox and hover effects, and a working search feature powered by SQL queries.

Secret mission

Top Scorer Highlight

Your dashboard shows all players equally right now. Can you make the highest scorer stand out with a gold highlight and trophy emoji? This mission touches all three layers of your app: Python logic, HTML templating, and CSS styling.

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 containing main.py, database.py, nba_stats.db, templates/, and static/ directories.
  • Python virtual environment inside the project folder.
  • FastAPI dev server running in your terminal.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your dashboard.

  • Your FastAPI server continues running at http://127.0.0.1:8000.
  • Your virtual environment stays active in the terminal.
  • All project files remain on your Desktop.

Pause - I'll come back to this later

Stop the running server to free up your terminal. Your files stay on disk so you can pick up where you left off.

  • 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, reactivate the virtual environment and restart the server.

Delete - I don't want to use this again

Remove all project files and start fresh. This permanently deletes your code, database, and virtual environment.

  • In the terminal where the FastAPI server is running, press Ctrl+C to stop the server.
  • Deactivate your virtual environment by running:
deactivate
  • Navigate to your Desktop and delete the project folder.

macOS

cd ~/Desktop
rm -rf nba-dashboard

Windows

cd ~\Desktop
rmdir /s /q nba-dashboard
  • Confirm the folder is gone by running:
ls

You should no longer see your project folder in the output.

That's a Wrap!

That's a Wrap!

Nice work! You just built a full web application from scratch using Python and HTML/CSS.

What you learned:

  • Created a FastAPI server that queries a SQLite database and serves dynamic HTML using Jinja2 templating.
  • Wrote semantic HTML with tables, forms, and proper document structure, then styled the page with CSS flexbox, hover effects, and alternating row colors.
  • Understood the HTTP request/response cycle: your browser sends a GET request, your server queries data, renders a template, and returns HTML.
  • Added conditional styling logic to highlight the top scorer dynamically using Jinja2 conditionals and a custom CSS class.

Ready to quiz yourself?