Full-Stack Watchlist with React + FastAPI

Build a movie/book watchlist app with a React frontend and FastAPI backend.

Introduction

30 Second Summary

Everyone has a growing list of movies to watch and books to read. Keeping track of what you've finished, what you're in the middle of, and what's still on your list is a problem that a simple full-stack web app can solve.

In this project, you will build a personal watchlist application with a React frontend, a FastAPI backend, and a PostgreSQL database. You'll learn how modern web apps are structured by connecting a separate frontend and backend through a REST API.

What You'll Build

A full-stack Movie/Book Watchlist app where you can add items, track their status, and rate completed items with a star rating system.

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

  • A FastAPI REST API with full CRUD endpoints connected to PostgreSQL via SQLModel.
  • A React frontend built with Vite that displays and manages watchlist items through reusable components.
  • A star rating component for rating completed items, with all changes persisting through API calls.
  • Secret Mission: Add search filtering by title and a stats dashboard showing totals by status and type.

Are there any prerequisites?

This project assumes familiarity with FastAPI, PostgreSQL, and Python from prior projects. No prior React or JavaScript experience is required.

Set Up the Project

A full-stack app keeps its frontend and backend as separate codebases. Each side lives in its own folder with its own dependencies, language, and tooling.

In this step, you'll create the project folder, set up a Python environment for the backend, create the PostgreSQL database, and scaffold the React frontend with Vite.

In this step, get ready to:
  • Set up the backend folder with a Python virtual environment and installed dependencies.
  • Create the PostgreSQL database for storing watchlist items.
  • Scaffold the React frontend using Vite.
Create the project folder and backend environment

Your project needs a root folder containing two subfolders: backend/ for the Python API and frontend/ for the React app.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and the backend subfolder by running these commands:
mkdir watchlist-app
cd watchlist-app
mkdir backend
cd backend

Your terminal prompt now shows you're inside backend/. If the cd backend command succeeded without error, the folder was created correctly.

Next, you need a Python virtual environment to isolate this project's packages from your system Python.

  • Create a virtual environment by running:
python3 -m venv venv
  • Activate the virtual environment:

macOS

source venv/bin/activate

This command also works on Linux.

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?

Make sure you ran the activate command from inside the backend/ folder where the venv folder was created.

On Windows, if you get an execution policy error, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell first, then try activating again.

help me activate my Python virtual environment

Now create a requirements.txt file to pin the exact package versions your backend needs.

  • In VS Code, click File in the top menu bar.
  • Click New File.
  • Paste the following content into the file:
fastapi[standard]==0.136.3
sqlmodel==0.0.38
psycopg2-binary==2.9.12
  • Save the file by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • When prompted for the file name, type requirements.txt and make sure you save it inside the backend/ folder.

What does this file do?

A requirements.txt file locks your project to specific package versions. This ensures anyone who clones your project installs the exact same dependencies.

  • fastapi[standard] is the web framework for building your API, with built-in development server support.
  • sqlmodel combines database ORM and data validation into a single model class.
  • psycopg2-binary is the PostgreSQL adapter that lets Python talk to your database.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file inside backend/.

ⓧ I'd like to double check the full code

fastapi[standard]==0.136.3
sqlmodel==0.0.38
psycopg2-binary==2.9.12
  • Install the backend dependencies by running this command (make sure you're in backend/ with (venv) active):
pip install -r requirements.txt

You should see output showing each package downloading and installing. The final line confirms all three packages were successfully installed.

Seeing installation errors?

If you see a permissions error, make sure (venv) appears in your prompt. Installing without the virtual environment active tries to write to system directories.

If psycopg2-binary fails, check that PostgreSQL is installed on your machine. The binary package should not need a C compiler, but it does need PostgreSQL client libraries.

help me fix my pip install errors for the watchlist backend

Create the PostgreSQL database

Your backend needs a database to store watchlist items. You'll create a dedicated PostgreSQL database called watchlist.

  • Create the database by running this command:
createdb watchlist

If the command completes with no output, the database was created successfully. No news is good news.

Seeing a connection error?

The createdb command requires the PostgreSQL service to be running. On macOS with Homebrew, start it with:

brew services start postgresql@16

On Windows, open the Services app and check that the PostgreSQL service is running.

help me get my PostgreSQL service running so I can create a database

Scaffold the React frontend

The frontend uses Vite to scaffold a React project. Vite is a modern build tool that provides instant hot-reload during development.

  • Navigate back to the project root by running:
cd ..
  • Scaffold the React frontend by running:
npm create vite@latest frontend -- --template react

What will this command ask me?

You may see a prompt asking to install the create-vite package. Press y then Enter to confirm.

You may also see prompts to select a variant or enable experimental features. Select the default JavaScript option and answer No to any experimental feature prompts.

On Windows PowerShell, if you see a framework selection prompt instead of the project being created, try using three dashes instead: npm create vite@latest frontend --- --template react.

  • Move into the new frontend folder by running:
cd frontend
  • Install the frontend dependencies by running:
npm install

You should see output confirming packages were added. A node_modules folder and a package-lock.json file now exist inside frontend/.

  • Start the development server to verify everything works:
npm run dev

You should see output showing Local: http://localhost:5173. Open that URL in your browser.

You should see the Vite + React welcome page with spinning logos. This confirms your frontend is working.

Don't see the welcome page?

Make sure you ran npm install before npm run dev. Without the install step, the development server has no packages to work with.

If port 5173 is already in use, Vite picks the next available port. Check the terminal output for the correct URL.

help me get my Vite React dev server running

  • Stop the dev server by pressing Ctrl+C in the terminal. You'll start it again later when building the frontend.

Your project skeleton is ready. You have a Python backend with its packages installed, a PostgreSQL database waiting for data, and a React frontend that runs on its own dev server. Next up, you'll build the FastAPI backend that connects to your database.

Build the FastAPI Backend

Your environment is set up and your database is ready to go. Now it's time to build the API that your React frontend will talk to.

In this step, you'll create a FastAPI backend with a data model that maps to your PostgreSQL database, plus four CRUD endpoints for creating, reading, updating, and deleting watchlist items.

In this step, get ready to:
  • Define the data model and connect to PostgreSQL.
  • Build CRUD endpoints with CORS.
  • Test the API using FastAPI's interactive Swagger UI.
Define the data model and database connection

Every web app needs a clear definition of what data it stores. SQLModel lets you define Python classes that double as both database tables and API validation schemas.

  • In VS Code, create a new file called main.py inside your backend/ folder.
  • Add the following code to set up the app, connect to PostgreSQL, and define your main data model:
from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import Field, SQLModel, Session, create_engine, select
from typing import Annotated

# Create the FastAPI app instance
app = FastAPI()

# Connect to the PostgreSQL watchlist database
DATABASE_URL = "postgresql://localhost/watchlist"
engine = create_engine(DATABASE_URL)


# Define the main data model (maps to a database table)
class WatchlistItem(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str
    item_type: str  # "movie" or "book"
    status: str = "want"  # "want", "in_progress", or "completed"
    rating: int | None = None  # 1-5 stars, only for completed items
    notes: str | None = None

What does this code do?

The imports bring in everything you need from FastAPI and SQLModel. create_engine opens a connection pool to your PostgreSQL database using the URL you provide.

The WatchlistItem class with table=True tells SQLModel to create an actual database table from this class. Each field becomes a column. Fields with None defaults are optional.

  • Confirm the file was created by running:
ls main.py

You should see main.py listed in the output.

Don't see main.py?

Make sure your terminal is inside the backend/ folder. Run pwd to check your current location.

help me find my main.py file

  • Now add the validation schemas and database session management below the WatchlistItem class in main.py:
# Schema for creating new items (no id needed, database generates it)
class WatchlistItemCreate(SQLModel):
    title: str
    item_type: str
    status: str = "want"
    rating: int | None = None
    notes: str | None = None


# Schema for partial updates (all fields optional)
class WatchlistItemUpdate(SQLModel):
    title: str | None = None
    item_type: str | None = None
    status: str | None = None
    rating: int | None = None
    notes: str | None = None


# Create the database table on first run
def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


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


# Type alias for cleaner endpoint signatures
SessionDep = Annotated[Session, Depends(get_session)]


# Run table creation when the server starts
@app.on_event("startup")
def on_startup():
    create_db_and_tables()

What does this code do?

WatchlistItemCreate defines what fields are required when adding a new item. It has no id field because the database auto-generates that.

WatchlistItemUpdate makes every field optional so you can update just one field at a time without sending the entire object.

get_session provides a fresh database connection for each API request and automatically closes it when done. The on_startup event creates the database table the first time the server starts.

  • Save main.py.

You won't see a visible change yet. The next substep adds the API endpoints that bring this data model to life.

Add CORS and build the CRUD endpoints

Your React frontend will run on a different port than your backend. Browsers block requests between different origins by default for security. CORS middleware tells the browser that your frontend is allowed to talk to your backend.

  • In main.py, find this line:
app = FastAPI()
  • Add the CORS middleware directly below it, before the DATABASE_URL line:
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],
    allow_methods=["*"],
    allow_headers=["*"],
)

What does CORS do here?

allow_origins lists which URLs can make requests to your API. Since Vite runs your React app on http://localhost:5173, that's the origin you allow.

Without this middleware, your browser would silently block every request from your frontend to your backend.

  • Now add the four REST API endpoints at the very bottom of main.py, after the on_startup function:
# GET - retrieve all watchlist items
@app.get("/items", response_model=list[WatchlistItem])
def list_items(session: SessionDep):
    items = session.exec(select(WatchlistItem)).all()
    return items


# POST - add a new watchlist item
@app.post("/items", response_model=WatchlistItem)
def create_item(item: WatchlistItemCreate, session: SessionDep):
    db_item = WatchlistItem.model_validate(item)
    session.add(db_item)
    session.commit()
    session.refresh(db_item)
    return db_item


# PATCH - update specific fields on an existing item
@app.patch("/items/{item_id}", response_model=WatchlistItem)
def update_item(item_id: int, item: WatchlistItemUpdate, session: SessionDep):
    db_item = session.get(WatchlistItem, item_id)
    if not db_item:
        raise HTTPException(status_code=404, detail="Item not found")
    item_data = item.model_dump(exclude_unset=True)
    db_item.sqlmodel_update(item_data)
    session.add(db_item)
    session.commit()
    session.refresh(db_item)
    return db_item


# DELETE - remove an item from the watchlist
@app.delete("/items/{item_id}")
def delete_item(item_id: int, session: SessionDep):
    db_item = session.get(WatchlistItem, item_id)
    if not db_item:
        raise HTTPException(status_code=404, detail="Item not found")
    session.delete(db_item)
    session.commit()
    return {"detail": "Item deleted"}

What do these endpoints do?

GET /items returns every item in the database. POST /items creates a new item from the JSON body you send.

PATCH /items/{item_id} updates only the fields you include in the request. model_dump(exclude_unset=True) ensures fields you didn't send stay unchanged.

DELETE /items/{item_id} removes an item entirely. Both PATCH and DELETE return a 404 error if the item doesn't exist.

  • Save main.py.

Your API is fully written. Let's start the server and test it.

Start the server and test the API
  • Start the FastAPI development server by running:
fastapi dev

You should see output confirming the server is running at http://127.0.0.1:8000.

Seeing a connection error?

If you see an error about connecting to PostgreSQL, make sure your PostgreSQL service is running. On macOS, run brew services start postgresql@16 to start it.

Also confirm your virtual environment is active. You should see (venv) at the beginning of your terminal prompt.

help me fix my FastAPI server startup error

FastAPI automatically generates interactive documentation for your API. You'll use this to test every endpoint without writing any frontend code.

  • Open your browser and go to http://127.0.0.1:8000/docs.
  • You should see the Swagger UI with all four endpoints listed: GET, POST, PATCH, and DELETE.

What is Swagger UI?

Swagger UI is an interactive API browser that FastAPI generates automatically from your code. You can expand any endpoint, fill in parameters, and click Execute to send a real request to your server.

  • Click the POST /items endpoint to expand it.
  • Click Try it out.
  • Replace the request body with this JSON:
{
  "title": "Inception",
  "item_type": "movie",
  "status": "want",
  "notes": "Christopher Nolan film"
}
  • Click Execute.
  • You should see a 200 response with the item returned, including a generated id field.
  • Add a second item by clicking Try it out again and using this JSON:
{
  "title": "Dune",
  "item_type": "book",
  "status": "in_progress",
  "notes": "Frank Herbert classic"
}
  • Click Execute.
  • Now expand the GET /items endpoint, click Try it out, then Execute.
  • You should see both items returned in a JSON array.

Where is this data stored?

The items you just created are persisted in your PostgreSQL watchlist database. Even if you restart the server, the data stays.

  • Test the PATCH /items/{item_id} endpoint. Enter 1 for the item_id parameter and use this body:
{
  "status": "completed",
  "rating": 5
}
  • Click Execute. You should see the item returned with its status changed to completed and a rating of 5.
  • Test the DELETE /items/{item_id} endpoint with item_id of 1.
  • Click Execute. You should see {"detail": "Item deleted"} in the response.

Getting a 422 Validation Error?

A 422 error means the JSON body you sent doesn't match the expected schema. Double check that your JSON keys match exactly: title, item_type, status, rating, notes.

Make sure you're using double quotes for JSON strings, not single quotes.

help me fix my 422 validation error in FastAPI

  • Add one more item so you have sample data for the frontend. Use POST /items again with any movie or book you like.

✔️ Awesome, I've got everything!

Great. Make sure you've saved main.py and keep your FastAPI server running in the terminal.

ⓧ I'd like to double check the full code

Here's the complete backend/main.py file:

from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import Field, SQLModel, Session, create_engine, select
from typing import Annotated

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],
    allow_methods=["*"],
    allow_headers=["*"],
)

DATABASE_URL = "postgresql://localhost/watchlist"
engine = create_engine(DATABASE_URL)


class WatchlistItem(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str
    item_type: str
    status: str = "want"
    rating: int | None = None
    notes: str | None = None


class WatchlistItemCreate(SQLModel):
    title: str
    item_type: str
    status: str = "want"
    rating: int | None = None
    notes: str | None = None


class WatchlistItemUpdate(SQLModel):
    title: str | None = None
    item_type: str | None = None
    status: str | None = None
    rating: int | None = None
    notes: str | None = None


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


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


SessionDep = Annotated[Session, Depends(get_session)]


@app.on_event("startup")
def on_startup():
    create_db_and_tables()


@app.get("/items", response_model=list[WatchlistItem])
def list_items(session: SessionDep):
    items = session.exec(select(WatchlistItem)).all()
    return items


@app.post("/items", response_model=WatchlistItem)
def create_item(item: WatchlistItemCreate, session: SessionDep):
    db_item = WatchlistItem.model_validate(item)
    session.add(db_item)
    session.commit()
    session.refresh(db_item)
    return db_item


@app.patch("/items/{item_id}", response_model=WatchlistItem)
def update_item(item_id: int, item: WatchlistItemUpdate, session: SessionDep):
    db_item = session.get(WatchlistItem, item_id)
    if not db_item:
        raise HTTPException(status_code=404, detail="Item not found")
    item_data = item.model_dump(exclude_unset=True)
    db_item.sqlmodel_update(item_data)
    session.add(db_item)
    session.commit()
    session.refresh(db_item)
    return db_item


@app.delete("/items/{item_id}")
def delete_item(item_id: int, session: SessionDep):
    db_item = session.get(WatchlistItem, item_id)
    if not db_item:
        raise HTTPException(status_code=404, detail="Item not found")
    session.delete(db_item)
    session.commit()
    return {"detail": "Item deleted"}

Your backend is live and serving data from PostgreSQL. Next up, you'll build the React frontend that displays these items in a real UI.

Build the React Watchlist Display

Your backend API is live and serving data from PostgreSQL. Now it's time to build the frontend that actually displays that data to users.

In this step, you'll write your first React components. A component is a reusable piece of UI that manages its own display and behavior. You'll connect these components to your FastAPI backend so your watchlist items appear in the browser.

In this step, get ready to:
  • Replace the default Vite app with your watchlist structure and fetch data from the API.
  • Create reusable components to display each watchlist item.
  • Style the app with a dark theme using CSS.
Replace App.jsx with your watchlist structure

React apps start with a single root component called App. Right now, your App.jsx file contains the default Vite welcome page. You'll replace it with your watchlist app that fetches items from the API.

Before you edit App.jsx, you need to create a placeholder for the AddItemForm component that App.jsx imports. You'll build the full form in the next step.

  • Create a components folder inside frontend/src/ by running this command from the frontend directory:
mkdir src/components
  • Create the placeholder AddItemForm component by creating a new file at src/components/AddItemForm.jsx and pasting this code:
// Placeholder - we'll build the full form in the next step
function AddItemForm({ onAdd }) {
  return null;
}

export default AddItemForm;
  • Now open src/App.jsx in VS Code. Select all the existing code and replace it with the following:
import { useState, useEffect } from "react";
import WatchlistCard from "./components/WatchlistCard";
import AddItemForm from "./components/AddItemForm";
import "./App.css";

const API_URL = "http://127.0.0.1:8000";

function App() {
  const [items, setItems] = useState([]);

  async function fetchItems() {
    const response = await fetch(`${API_URL}/items`);
    const data = await response.json();
    setItems(data);
  }

  useEffect(() => {
    fetchItems();
  }, []);

  async function handleAdd(newItem) {
    const response = await fetch(`${API_URL}/items`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(newItem),
    });
    if (response.ok) {
      fetchItems();
    }
  }

  async function handleDelete(id) {
    const response = await fetch(`${API_URL}/items/${id}`, {
      method: "DELETE",
    });
    if (response.ok) {
      fetchItems();
    }
  }

  async function handleUpdate(id, updates) {
    const response = await fetch(`${API_URL}/items/${id}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(updates),
    });
    if (response.ok) {
      fetchItems();
    }
  }

  return (
    <div className="app">
      <h1>My Watchlist</h1>
      <AddItemForm onAdd={handleAdd} />
      <div className="watchlist">
        {items.map((item) => (
          <WatchlistCard
            key={item.id}
            item={item}
            onDelete={handleDelete}
            onUpdate={handleUpdate}
          />
        ))}
      </div>
    </div>
  );
}

export default App;

What does this code do?

This file is the heart of your React app. Here's what each piece does:

  • useState([]) creates a state variable called items that holds your watchlist data. When items changes, React automatically re-renders the page.
  • fetchItems() calls your FastAPI backend at /items and stores the response in state.
  • useEffect(() => { fetchItems(); }, []) runs fetchItems once when the page first loads. The empty [] means "run this only on mount."
  • handleAdd, handleDelete, and handleUpdate send POST, DELETE, and PATCH requests to your API, then re-fetch the list to show the latest data.

What is JSX?

The HTML-like code inside the return statement is called JSX. It lets you describe your UI using familiar HTML syntax, but it compiles to JavaScript function calls behind the scenes.

Notice className instead of class. Since JSX is JavaScript, and class is a reserved keyword in JavaScript, React uses className for CSS classes.

The {items.map(...)} syntax loops over your items array and renders a WatchlistCard for each one.

  • Save App.jsx.
  • Your browser will show an error because WatchlistCard doesn't exist yet. You'll create it next.
Create the WatchlistCard and RatingStars components

React components are like building blocks. Each one handles a single piece of UI. You'll create two components: RatingStars (a row of clickable stars) and WatchlistCard (a card displaying one item).

  • Create a new file at src/components/RatingStars.jsx and paste the following code:
function RatingStars({ rating, onRate }) {
  return (
    <div className="stars">
      {[1, 2, 3, 4, 5].map((star) => (
        <span
          key={star}
          className={star <= rating ? "star filled" : "star"}
          onClick={() => onRate(star)}
        >
          {star <= rating ? "\u2605" : "\u2606"}
        </span>
      ))}
    </div>
  );
}

export default RatingStars;

What does this component do?

RatingStars receives two props (inputs from a parent component): rating (current star count) and onRate (a function to call when a star is clicked).

It renders 5 star spans. Stars at or below the current rating show a filled star (\u2605). Stars above the rating show an empty star (\u2606). Clicking any star calls onRate with that star's number.

  • Save RatingStars.jsx.
  • Create another new file at src/components/WatchlistCard.jsx and paste the following code:
import RatingStars from "./RatingStars";

function WatchlistCard({ item, onDelete, onUpdate }) {
  const statusLabels = {
    want: "Want to Watch/Read",
    in_progress: "In Progress",
    completed: "Completed",
  };

  const nextStatus = {
    want: "in_progress",
    in_progress: "completed",
    completed: "want",
  };

  return (
    <div className={`card card-${item.status}`}>
      <div className="card-header">
        <span className="type-badge">{item.item_type}</span>
        <button className="delete-btn" onClick={() => onDelete(item.id)}>
          X
        </button>
      </div>
      <h3>{item.title}</h3>
      <p className="status">{statusLabels[item.status]}</p>
      {item.notes && <p className="notes">{item.notes}</p>}
      {item.status === "completed" && (
        <RatingStars
          rating={item.rating || 0}
          onRate={(rating) => onUpdate(item.id, { rating })}
        />
      )}
      <button
        className="status-btn"
        onClick={() => onUpdate(item.id, { status: nextStatus[item.status] })}
      >
        Move to: {statusLabels[nextStatus[item.status]]}
      </button>
    </div>
  );
}

export default WatchlistCard;

What does WatchlistCard do?

Each WatchlistCard receives an item object (one watchlist entry) plus callback functions for deleting and updating. It displays:

  • A type badge showing "movie" or "book".
  • A delete button (the red X).
  • The item title and a human-readable status label.
  • Star ratings (only visible for completed items).
  • A status-change button that cycles through want, in_progress, and completed.

The statusLabels and nextStatus objects are lookup maps. They convert the raw database values into display text and determine which status comes next.

  • Save WatchlistCard.jsx.
  • Check your browser at http://localhost:5173. You should see your sample items listed (from the data you added via the Swagger UI in the previous step). They won't have any styling yet.

Not seeing your items?

  • Make sure your FastAPI backend is still running at http://127.0.0.1:8000. If you stopped it, restart with fastapi dev from the backend folder.
  • Check the browser console (F12 then Console tab) for CORS errors. If you see one, confirm allow_origins in backend/main.py includes http://localhost:5173.
  • Confirm you added sample items in Step 2. If the database is empty, the page will be blank but error-free.

Help me debug why my React app isn't fetching items from my FastAPI backend.

Style the app with CSS

Your items are rendering but they look plain. You'll replace the default Vite CSS with a dark-themed stylesheet that gives each card a colored left border based on its status.

  • Open src/App.css in VS Code. Select all the existing Vite styles and replace them with the following:
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #1a1a2e;
  color: #eee;
  padding: 2rem;
}

.app {
  max-width: 800px;
  margin: 0 auto;
}

h1 {
  text-align: center;
  margin-bottom: 1.5rem;
  font-size: 2rem;
}

.add-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 2rem;
  flex-wrap: wrap;
}

.add-form input,
.add-form select {
  padding: 0.6rem;
  border: 1px solid #333;
  border-radius: 6px;
  background: #16213e;
  color: #eee;
  flex: 1;
  min-width: 120px;
}

.add-form button {
  padding: 0.6rem 1.2rem;
  background: #0f3460;
  color: #eee;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-weight: bold;
}

.add-form button:hover {
  background: #1a5276;
}

.watchlist {
  display: grid;
  gap: 1rem;
}

.card {
  background: #16213e;
  border-radius: 10px;
  padding: 1.2rem;
  border-left: 4px solid #555;
}

.card-want {
  border-left-color: #f39c12;
}

.card-in_progress {
  border-left-color: #3498db;
}

.card-completed {
  border-left-color: #2ecc71;
}

.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 0.5rem;
}

.type-badge {
  background: #0f3460;
  padding: 0.2rem 0.6rem;
  border-radius: 12px;
  font-size: 0.75rem;
  text-transform: uppercase;
}

.delete-btn {
  background: transparent;
  border: none;
  color: #e74c3c;
  cursor: pointer;
  font-weight: bold;
  font-size: 1rem;
}

h3 {
  margin-bottom: 0.4rem;
}

.status {
  color: #aaa;
  font-size: 0.85rem;
  margin-bottom: 0.4rem;
}

.notes {
  color: #888;
  font-size: 0.8rem;
  font-style: italic;
  margin-bottom: 0.5rem;
}

.stars {
  margin: 0.5rem 0;
}

.star {
  font-size: 1.4rem;
  cursor: pointer;
  color: #555;
}

.star.filled {
  color: #f1c40f;
}

.status-btn {
  margin-top: 0.5rem;
  padding: 0.4rem 0.8rem;
  background: #0f3460;
  color: #eee;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.8rem;
}

.status-btn:hover {
  background: #1a5276;
}

How does the styling work?

  • Each card gets a colored left border based on its status class: orange for "want", blue for "in progress", green for "completed".
  • The .type-badge is a small uppercase pill showing "MOVIE" or "BOOK".
  • The .star.filled class turns stars gold when they represent the current rating.
  • The .add-form styles are ready for the form you'll build in the next step.
  • Save App.css.
  • Check your browser. You should see dark-themed cards with colored left borders. Each card displays the item title, a type badge, and the current status.

Cards still unstyled?

  • Make sure you replaced ALL the existing CSS in App.css. Leftover Vite styles can conflict with your new theme.
  • Also delete the src/index.css file if it exists. Vite's default template includes it, and its styles may override yours.
  • Confirm App.jsx imports "./App.css" at the top of the file.

Help me figure out why my CSS styles aren't applying to my React watchlist cards.

✔️ Awesome, I've got everything!

Great. Double check you've saved all four files: App.jsx, App.css, WatchlistCard.jsx, and RatingStars.jsx.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point:

frontend/src/App.jsx

import { useState, useEffect } from "react";
import WatchlistCard from "./components/WatchlistCard";
import AddItemForm from "./components/AddItemForm";
import "./App.css";

const API_URL = "http://127.0.0.1:8000";

function App() {
  const [items, setItems] = useState([]);

  async function fetchItems() {
    const response = await fetch(`${API_URL}/items`);
    const data = await response.json();
    setItems(data);
  }

  useEffect(() => {
    fetchItems();
  }, []);

  async function handleAdd(newItem) {
    const response = await fetch(`${API_URL}/items`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(newItem),
    });
    if (response.ok) {
      fetchItems();
    }
  }

  async function handleDelete(id) {
    const response = await fetch(`${API_URL}/items/${id}`, {
      method: "DELETE",
    });
    if (response.ok) {
      fetchItems();
    }
  }

  async function handleUpdate(id, updates) {
    const response = await fetch(`${API_URL}/items/${id}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(updates),
    });
    if (response.ok) {
      fetchItems();
    }
  }

  return (
    <div className="app">
      <h1>My Watchlist</h1>
      <AddItemForm onAdd={handleAdd} />
      <div className="watchlist">
        {items.map((item) => (
          <WatchlistCard
            key={item.id}
            item={item}
            onDelete={handleDelete}
            onUpdate={handleUpdate}
          />
        ))}
      </div>
    </div>
  );
}

export default App;

frontend/src/components/WatchlistCard.jsx

import RatingStars from "./RatingStars";

function WatchlistCard({ item, onDelete, onUpdate }) {
  const statusLabels = {
    want: "Want to Watch/Read",
    in_progress: "In Progress",
    completed: "Completed",
  };

  const nextStatus = {
    want: "in_progress",
    in_progress: "completed",
    completed: "want",
  };

  return (
    <div className={`card card-${item.status}`}>
      <div className="card-header">
        <span className="type-badge">{item.item_type}</span>
        <button className="delete-btn" onClick={() => onDelete(item.id)}>
          X
        </button>
      </div>
      <h3>{item.title}</h3>
      <p className="status">{statusLabels[item.status]}</p>
      {item.notes && <p className="notes">{item.notes}</p>}
      {item.status === "completed" && (
        <RatingStars
          rating={item.rating || 0}
          onRate={(rating) => onUpdate(item.id, { rating })}
        />
      )}
      <button
        className="status-btn"
        onClick={() => onUpdate(item.id, { status: nextStatus[item.status] })}
      >
        Move to: {statusLabels[nextStatus[item.status]]}
      </button>
    </div>
  );
}

export default WatchlistCard;

frontend/src/components/RatingStars.jsx

function RatingStars({ rating, onRate }) {
  return (
    <div className="stars">
      {[1, 2, 3, 4, 5].map((star) => (
        <span
          key={star}
          className={star <= rating ? "star filled" : "star"}
          onClick={() => onRate(star)}
        >
          {star <= rating ? "\u2605" : "\u2606"}
        </span>
      ))}
    </div>
  );
}

export default RatingStars;

frontend/src/App.css

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

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #1a1a2e;
  color: #eee;
  padding: 2rem;
}

.app {
  max-width: 800px;
  margin: 0 auto;
}

h1 {
  text-align: center;
  margin-bottom: 1.5rem;
  font-size: 2rem;
}

.add-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 2rem;
  flex-wrap: wrap;
}

.add-form input,
.add-form select {
  padding: 0.6rem;
  border: 1px solid #333;
  border-radius: 6px;
  background: #16213e;
  color: #eee;
  flex: 1;
  min-width: 120px;
}

.add-form button {
  padding: 0.6rem 1.2rem;
  background: #0f3460;
  color: #eee;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-weight: bold;
}

.add-form button:hover {
  background: #1a5276;
}

.watchlist {
  display: grid;
  gap: 1rem;
}

.card {
  background: #16213e;
  border-radius: 10px;
  padding: 1.2rem;
  border-left: 4px solid #555;
}

.card-want {
  border-left-color: #f39c12;
}

.card-in_progress {
  border-left-color: #3498db;
}

.card-completed {
  border-left-color: #2ecc71;
}

.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 0.5rem;
}

.type-badge {
  background: #0f3460;
  padding: 0.2rem 0.6rem;
  border-radius: 12px;
  font-size: 0.75rem;
  text-transform: uppercase;
}

.delete-btn {
  background: transparent;
  border: none;
  color: #e74c3c;
  cursor: pointer;
  font-weight: bold;
  font-size: 1rem;
}

h3 {
  margin-bottom: 0.4rem;
}

.status {
  color: #aaa;
  font-size: 0.85rem;
  margin-bottom: 0.4rem;
}

.notes {
  color: #888;
  font-size: 0.8rem;
  font-style: italic;
  margin-bottom: 0.5rem;
}

.stars {
  margin: 0.5rem 0;
}

.star {
  font-size: 1.4rem;
  cursor: pointer;
  color: #555;
}

.star.filled {
  color: #f1c40f;
}

.status-btn {
  margin-top: 0.5rem;
  padding: 0.4rem 0.8rem;
  background: #0f3460;
  color: #eee;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.8rem;
}

.status-btn:hover {
  background: #1a5276;
}

Your watchlist is now displaying live data from the backend. Next up, you'll build the form that lets you add new items directly from the React UI.

Build the Add Item Form and Actions

In Step 3, you built the display layer for your watchlist. Your app can show items from the database, but right now the only way to add new items is through the Swagger UI at http://127.0.0.1:8000/docs.

In this step, you'll create the AddItemForm component so users can add movies and books directly from the React app. Once this piece is in place, your full-stack app will be completely functional.

In this step, get ready to:
  • Create the AddItemForm component with controlled inputs and form submission.
  • Test the full app by adding, deleting, updating, and rating items.
Create the AddItemForm component

Your App.jsx already imports AddItemForm and passes it the handleAdd function. The form component needs to collect user input and call that function when submitted.

What are controlled inputs?

In React, a controlled input is a form field whose value is driven by component state. Every time the user types, React updates the state, and the state flows back to the input's value prop.

This gives you full control over what's in the form at any time. You can validate, reset, or read the values without reaching into the DOM directly.

  • Create the AddItemForm component by creating a new file at frontend/src/components/AddItemForm.jsx and pasting the following code:
import { useState } from "react";

function AddItemForm({ onAdd }) {
  // State for each form field (controlled inputs)
  const [title, setTitle] = useState("");
  const [itemType, setItemType] = useState("movie");
  const [notes, setNotes] = useState("");

  function handleSubmit(e) {
    // Prevent the browser from refreshing the page
    e.preventDefault();
    // Don't submit if title is empty
    if (!title.trim()) return;
    // Call the parent's onAdd with the form data
    onAdd({ title, item_type: itemType, notes });
    // Reset the form fields
    setTitle("");
    setNotes("");
  }

  return (
    <form className="add-form" onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />
      <select value={itemType} onChange={(e) => setItemType(e.target.value)}>
        <option value="movie">Movie</option>
        <option value="book">Book</option>
      </select>
      <input
        type="text"
        placeholder="Notes (optional)"
        value={notes}
        onChange={(e) => setNotes(e.target.value)}
      />
      <button type="submit">Add</button>
    </form>
  );
}

export default AddItemForm;

What does this code do?

  • Three useState hooks create controlled state for the title, item type, and notes fields.
  • handleSubmit prevents the default form refresh, validates that the title isn't empty, then passes the data up to the parent via onAdd.
  • After submitting, the title and notes fields reset to empty strings. The type dropdown keeps its last value since you'll often add multiple items of the same type.
  • The key item_type in the object matches the field name your FastAPI backend expects.
  • Save AddItemForm.jsx.
  • Switch back to your browser at http://localhost:5173. You should see a form row above your watchlist cards with a text input, a dropdown, another text input, and an Add button.

Don't see the form?

  • Check that your file is saved at exactly frontend/src/components/AddItemForm.jsx. The import in App.jsx expects that path.
  • Make sure the function name is AddItemForm and the export default AddItemForm line is at the bottom.
  • Check your terminal running the React dev server for any red error messages pointing to the file or line number.

Why isn't my AddItemForm showing up in the browser?

Test the full app end-to-end

With the form in place, every piece of your full-stack app is connected. The React frontend can now create, read, update, and delete items through your FastAPI backend.

  • Type a title like The Matrix in the first input field.
  • Leave the dropdown set to Movie.
  • Optionally type a note like Sci-fi classic in the notes field.
  • Click the Add button.

A new card appears at the bottom of your watchlist with an orange left border (the "want" status color).

  • Click the Move to: In Progress button on the new card. The border color changes to blue.
  • Click Move to: Completed. The border turns green and star ratings appear.
  • Click the third star to give it a 3-star rating. The first three stars fill in with gold.
  • Click the X button on a card to delete it. The card disappears.
  • Refresh the page in your browser. All your changes should still be there because they're stored in PostgreSQL.

Why do changes persist after refresh?

Every action (add, delete, status change, rating) sends a request to your FastAPI backend, which writes the change to PostgreSQL. When the page reloads, useEffect fires again and fetches the latest data from the database.

This is the core pattern of full-stack apps: the frontend is a view layer that reads from and writes to a persistent backend. Closing the browser tab doesn't lose any data.

Items not saving after refresh?

  • Check that your FastAPI dev server is still running in its terminal. If you see an error or the process stopped, restart it with fastapi dev.
  • Open your browser's developer console (press Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). Look for red network errors that indicate the backend isn't responding.
  • Make sure PostgreSQL is still running. If it stopped, restart it with brew services start postgresql@16.

My watchlist items disappear after I refresh the page. Help me debug the connection.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

Here's the complete code for AddItemForm.jsx:

import { useState } from "react";

function AddItemForm({ onAdd }) {
  const [title, setTitle] = useState("");
  const [itemType, setItemType] = useState("movie");
  const [notes, setNotes] = useState("");

  function handleSubmit(e) {
    e.preventDefault();
    if (!title.trim()) return;
    onAdd({ title, item_type: itemType, notes });
    setTitle("");
    setNotes("");
  }

  return (
    <form className="add-form" onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />
      <select value={itemType} onChange={(e) => setItemType(e.target.value)}>
        <option value="movie">Movie</option>
        <option value="book">Book</option>
      </select>
      <input
        type="text"
        placeholder="Notes (optional)"
        value={notes}
        onChange={(e) => setNotes(e.target.value)}
      />
      <button type="submit">Add</button>
    </form>
  );
}

export default AddItemForm;

Your full-stack watchlist app is fully functional. You can add items, track their status through the want-to-watch pipeline, and rate them when you're done. Next up, the Secret Mission challenges you to add search filtering and a stats dashboard.

Secret mission

Add Search and Stats Dashboard

Your watchlist works great, but finding a specific item means scrolling through everything. Can you add real-time search filtering and a stats dashboard that summarizes your collection at a glance?

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • PostgreSQL database watchlist.
  • Python virtual environment and installed packages in backend/.
  • React frontend with node modules in frontend/.
  • FastAPI dev server and React dev server (running processes).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with your watchlist app.

  • Your FastAPI server remains running at http://127.0.0.1:8000.
  • Your React dev server remains running at http://localhost:5173.
  • Your PostgreSQL database watchlist keeps all your saved items.

Pause - I'll come back to this later

Shut down the running servers to free up memory and terminal space. Your files and database stay intact for when you return.

  • Switch to the terminal running your FastAPI server and press Ctrl+C to stop it.
  • Switch to the terminal running your React dev server and press Ctrl+C to stop it.
  • Your project files and database remain on your machine. To resume, reactivate your virtual environment, run fastapi dev in the backend/ folder, and run npm run dev in the frontend/ folder.

Delete - I don't want to use this again

Remove all project files and the database completely. This cannot be undone.

  • Stop both running servers by pressing Ctrl+C in each terminal.
  • Drop the PostgreSQL database by running:
dropdb watchlist
  • Delete the project folder from your Desktop. On macOS/Linux, run:
cd ~/Desktop
rm -rf backend frontend

On Windows, run:

cd ~\Desktop
rmdir /s /q backend
rmdir /s /q frontend
  • Close the VS Code window for this project.

That's a Wrap!

That's a Wrap!

Nice work! You just built a complete full-stack web application with a React frontend, a FastAPI backend, and a PostgreSQL database all working together.

You've learned how to:

  • Built a complete FastAPI REST API with full CRUD endpoints connected to PostgreSQL using SQLModel for data modeling and validation.
  • Built a React frontend from scratch using Vite with reusable components, state management via useState and useEffect, and live API integration.
  • Connected your frontend and backend via CORS, understanding how modern full-stack applications communicate through a REST API.
  • Secret Mission: Added real-time search filtering and a stats dashboard with derived state that updates dynamically as items change.

Ready to quiz yourself?