DEV Community

Didicodes
Didicodes

Posted on

Build a Movie/TV Show Tracker with MongoDB and TanStack Start

A few months ago, I found myself opening multiple different apps just to answer a simple question: "What episode was I on?" 

I'd start a show, get distracted by work, switch to another series for a few weeks, then come back and have no idea where I'd stopped. Sometimes I'd scroll through episode lists trying to remember the last thing I watched. Other times, I'd accidentally rewatch an episode because I couldn't remember whether I'd already seen it. After doing this one too many times, I decided to build a small tracker for myself.

The idea was simple. Search for a show, add it to a watch list, and tap a button whenever you finish an episode. Behind that simple experience, though, there are a few interesting decisions to make. How should progress be stored? Should the show information be fetched from an external API every time, or stored in MongoDB? And how do you update both progress and status without introducing race conditions?

This tutorial explains how to build a movie/TV show tracker using MongoDB, Tanstack Start, and TMDB. You can find the finished project here in this GitHub repository.

What you will learn

  • How to design a data model for a TV show tracking application
  • How MongoDB aggregation pipelines can power both progress updates and application statistics
  • How TanStack Start server functions make it easy to work with MongoDB from a full-stack React application

Prerequisites

Before you start, you should have:

  • Node.js 18+ installed (npm is included by default)
  • A MongoDB Atlas account or a local MongoDB server
  • A free TMDB account, which gives you an API token
  • An understanding of React, TypeScript, and TanStack Start.

Setting up the project

Let’s set up our project for the application using TanStack Start's start-basic example, then add MongoDB. To do this, run the commands below on your terminal:

npx gitpick TanStack/router/tree/main/examples/react/start-basic tanstack-start-mongodb-tvshow-tracker
cd tanstack-start-mongodb-tvshow-tracker
npm install
npm install mongodb
Enter fullscreen mode Exit fullscreen mode

gitpick copies only the start-basic folder from TanStack’s repo into a newly created directory named tanstack-start-mongodb-tvshow-tracker. npm install pulls down what the starter already uses (Zod is one of them), and npm install mongodb adds the official MongoDB nodejs driver.

At this point, the project has been created, but it still includes the files from the start-basic example. Before we start building the movie/TV show tracker, let's update the file structure to match the project. To make this easier, I've created a small setup script that removes the starter example files, updates the root route, and creates the folders and files we'll use throughout the rest of the tutorial. Run the command below from the root of your tanstack-start-mongodb-tvshow-tracker project:

curl -o setup.sh https://gist.githubusercontent.com/didicodes/e35a275795d3364ebb17e488724ca132/raw/7b403929ab93dd7f64ba2d701a0137277769f808/setup.sh && chmod +x setup.sh && ./setup.sh
Enter fullscreen mode Exit fullscreen mode

After running the script, your project should look like the image below. Run npm run dev to confirm the project runs successfully. If you'd rather make the changes yourself, you can follow along using the project structure shown below:

Project structure for the movie/TV show application

We'll build out these files as we go, but setting up the structure now makes it easier to see where each piece of the application belongs.

Getting your credentials

The app needs a MongoDB connection string and a TMDB token. For MongoDB, create a free cluster in MongoDB Atlas, then open Connect, choose Drivers, and copy the Node.js connection string. It looks like mongodb+srv://<username>:<password>@<cluster-url>.  For TMDB, sign in, open your API settings, request an API key if you don’t already have one, then copy the value labeled "API Read Access Token". Using your preferred IDE, open the tanstack-start-mongodb-tvshow-tracker project and add both credentials to the .env file as shown below

MONGODB_URI=your-mongodb-connection-string
TMDB_READ_ACCESS_TOKEN=your-tmdb-read-access-token
Enter fullscreen mode Exit fullscreen mode

TanStack Start reads .env in development so that both credentials will be available as process.env values in server functions. They are read only on the server, so neither one reaches the browser.

Designing the show document

Before writing any database code, I wanted to decide what a TV show document should look like. As I started sketching out the data model, I caught myself thinking about features the app didn't even need yet. Things like, "Maybe one day I should track individual episodes," or "It would be nice to keep a viewing history," or even "What if I added personal notes?" Eventually, I realized that none of those ideas solved the problem I was trying to fix today. The only question this app really needed to answer was: How far through this show am I?

Once I focused on that question, the data model became much simpler. Before writing any MongoDB code, I wanted to capture that model in TypeScript so the rest of the application could share the same definitions. In this project, those shared types will live in src/lib/types.ts, so go there and add the code below:

// src/lib/types.ts
export interface ShowDocument {
  tmdbId: number;
  title: string;
  posterPath: string | null;
  totalSeasons: number;
  totalEpisodes: number;
  episodesWatched: number;
  status: "plan_to_watch" | "watching" | "completed";
  rating: number | null;
  createdAt: Date;
  updatedAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

Defining the fields was only part of the design. The next question was how to represent progress. My first instinct was to store every episode a user watches. That would make it possible to answer questions like "Which episodes have I seen?" or "What did I watch last week?". After sketching it out, though, storing individual episodes started to feel like overkill. If I'm halfway through a season, I don't really care whether the app knows I watched episode 4 or episode 5. What I care about is seeing that I've watched 12 out of 24 episodes. Once I looked at it that way, a single episodesWatched field felt like the simpler choice.

Progress wasn't the only tradeoff. I also went back and forth on how much information should come from TMDB versus how much should live in MongoDB. One option was to store only the TMDB ID and fetch everything else on page load. I decided against that approach. Every visit to the watch list would trigger another API request, even though details like a TV show's title or poster rarely change. Instead, I store a snapshot of the information I need when a TV show is first added, then read that snapshot directly from MongoDB from that point on. The tradeoff is that some values can become outdated over time. For example, if a TV show is renewed and a new season is released, the stored episode count won't update automatically. For a watch tracker, that's a tradeoff I'm happy to make in exchange for faster reads and fewer external API requests.

Connecting to MongoDB

Now that we know what a show document looks like, we need somewhere to store it. The next step is setting up a MongoDB connection that the rest of the application can reuse. Before opening a connection, I like to pull reusable values into a small config file. For this app, that's the database name, collection name, and a couple of connection settings. Go to src/config/mongodb.ts and add the code below:

// src/config/mongodb.ts
export const DB_NAME = "episode-tracker";
export const COLLECTION_NAME = "shows";

export const MONGODB_CONNECTION_CONFIG = {
  minPoolSize: 1,
  maxIdleTimeMS: 60000,
} as const;
Enter fullscreen mode Exit fullscreen mode

The database and collection names are fairly straightforward. The connection settings are a little more interesting. Since TanStack Start server functions can run in short-lived environments, I don't want to create a new connection for every request. Keeping a connection around for a short period means later requests can reuse it instead of starting from scratch. 

In this case, minPoolSize:1 keeps one connection ready to use, while maxIdleTimeMS:60000 allows idle connections to be cleaned up after about a minute. With the configuration in place, we can create a reusable MongoDB client. To do this, add the code below to src/lib/mongodb.ts:

// src/lib/mongodb.ts
import { MongoClient, type Collection, type Db } from "mongodb";
import {
  COLLECTION_NAME,
  DB_NAME,
  MONGODB_CONNECTION_CONFIG,
} from "../config/mongodb";
import type { ShowDocument } from "./types";

const MONGODB_URI = process.env.MONGODB_URI;

interface CachedConnection {
  client: MongoClient | null;
  db: Db | null;
  promise: Promise<{ client: MongoClient; db: Db }> | null;
}

const cached: CachedConnection = {
  client: null,
  db: null,
  promise: null,
};

export async function connectToDatabase(): Promise<{
  client: MongoClient;
  db: Db;
}> {
  if (cached.client && cached.db) {
    return { client: cached.client, db: cached.db };
  }

  if (cached.promise) {
    return cached.promise;
  }

  if (!MONGODB_URI) {
    throw new Error(
      "Missing MONGODB_URI environment variable. Add it to your .env file."
    );
  }

  cached.promise = MongoClient.connect(MONGODB_URI, {
    appName: "devrel-github-tanstack-tvshow-tracker",
    ...MONGODB_CONNECTION_CONFIG,
  })
    .then((client) => {
      const db = client.db(DB_NAME);

      cached.client = client;
      cached.db = db;
      cached.promise = null;

      return { client, db };
    })
    .catch((error) => {
      cached.promise = null;
      throw error;
    });

  return cached.promise;
}

export async function getShowsCollection(): Promise<Collection<ShowDocument>> {
  const { db } = await connectToDatabase();
  const collection = db.collection<ShowDocument>(COLLECTION_NAME);
  await collection.createIndex({ tmdbId: 1 }, { unique: true });
  return collection;
}
Enter fullscreen mode Exit fullscreen mode

The goal of these helper functions is simple: avoid opening more MongoDB connections than necessary. Once a connection exists, we keep using it. If another request comes in while the connection is still being created, it waits for that same promise instead of starting a second connection attempt.

The tmdbId index is also created during this first connection step. That keeps getShowsCollection() focused on one job: returning the typed shows collection. It also means the server functions can call it without asking MongoDB to create the index each time.

The .catch() block clears the cached promise if the connection fails, so the next request can try again instead of reusing a failed connection attempt. From there, the rest of the app gets a typed collection that matches our ShowDocument shape.

Defining the application's data contract

Before moving on to the server functions, I wanted a single place to define the data shapes used throughout the application. By this point, we already know what a show document looks like in MongoDB. We also know that data will be flowing between the browser, our server functions, MongoDB, and TMDB. Rather than redefining those shapes in multiple places, we'll keep them together in src/lib/types.ts. This file will contain the application's statuses, Zod validation schemas, TypeScript interfaces, and a small helper for converting MongoDB documents into a browser-friendly format. Replace the code in the file with this:

// src/lib/types.ts
import { z } from "zod";
import type { ObjectId } from "mongodb";

export const WATCH_STATUSES = [
  "plan_to_watch",
  "watching",
  "completed",
] as const;

export type WatchStatus = (typeof WATCH_STATUSES)[number];

export const addShowSchema = z.object({
  tmdbId: z.number().int().positive(),
  status: z.enum(WATCH_STATUSES).default("plan_to_watch"),
});

export const updateShowSchema = z.object({
  id: z.string().min(1, "Show id is required"),
  status: z.enum(WATCH_STATUSES).optional(),
  rating: z.number().int().min(1).max(10).nullable().optional(),
  episodesWatched: z.number().int().min(0).optional(),
});

export const showIdSchema = z.object({
  id: z.string().min(1, "Show id is required"),
});

export const searchSchema = z.object({
  query: z.string().min(1, "Search query is required").max(120),
});

// How a show is stored in MongoDB
export interface ShowDocument {
  tmdbId: number;
  title: string;
  posterPath: string | null;
  totalSeasons: number;
  totalEpisodes: number;
  episodesWatched: number;
  status: WatchStatus;
  rating: number | null;
  createdAt: Date;
  updatedAt: Date;
}

// A stored show as it comes back from a query, including Mongo's _id
export interface ShowResponse extends ShowDocument {
  _id: ObjectId;
}

// How a show is sent to the browser. ObjectId and Date are not JSON-friendly, so this converts them to strings
export interface Show {
  id: string;
  tmdbId: number;
  title: string;
  posterPath: string | null;
  totalSeasons: number;
  totalEpisodes: number;
  episodesWatched: number;
  status: WatchStatus;
  rating: number | null;
  createdAt: string;
  updatedAt: string;
}

// Summary numbers for the stats bar
export interface LibraryStats {
  totalShows: number;
  episodesWatched: number;
  watching: number;
  completed: number;
  planToWatch: number;
}

export interface TmdbSearchResult {
  tmdbId: number;
  title: string;
  posterPath: string | null;
  year: string | null;
  overview: string;
}

export interface TmdbShowDetails {
  tmdbId: number;
  title: string;
  posterPath: string | null;
  totalSeasons: number;
  totalEpisodes: number;
}

export function documentToShow(doc: ShowResponse): Show {
  return {
    id: doc._id.toString(),
    tmdbId: doc.tmdbId,
    title: doc.title,
    posterPath: doc.posterPath,
    totalSeasons: doc.totalSeasons,
    totalEpisodes: doc.totalEpisodes,
    episodesWatched: doc.episodesWatched,
    status: doc.status,
    rating: doc.rating,
    createdAt: doc.createdAt.toISOString(),
    updatedAt: doc.updatedAt.toISOString(),
  };
}
Enter fullscreen mode Exit fullscreen mode

Two separate concerns are being handled in this file:

  • The first is validation. The Zod schemas make sure the server only accepts the inputs we expect. For example, when a show is added, the client sends a TMDB ID and an optional status. Everything else comes directly from TMDB, so there's no reason to trust those values from the browser.

  • The second is keeping the different data shapes organized. A show exists in several forms as it moves through the application. Before a document is stored, there is no _id, so we use ShowDocument. Once MongoDB returns that document from a query, it now includes an _id, which is represented by ShowResponse. Finally, when the data is sent to the browser, the ObjectId and date fields need to become strings, which is represented by Show. Rather than repeating those conversions throughout the codebase, the documentToShow() helper handles them in one place.

Fetching show details from TMDB

When someone adds a show to their watch list, they aren't entering the title, poster URL, season count, or episode count manually. The only information we really need from them is the TMDB ID; everything else can come from TMDB. To keep that logic in one place, we'll create a TMDB client in src/lib/tmdb.ts that handles authentication, show search, and fetching the additional details we need before saving a document to MongoDB. Add the following code to src/lib/tmdb.ts:

// src/lib/tmdb.ts
import type { TmdbSearchResult, TmdbShowDetails } from "./types";

const TMDB_BASE_URL = "https://api.themoviedb.org/3";
const TMDB_TOKEN = process.env.TMDB_READ_ACCESS_TOKEN;

// Small wrapper around the TMDB REST API
async function tmdbFetch<T>(path: string): Promise<T> {
  if (!TMDB_TOKEN) {
    throw new Error(
      "Missing TMDB_READ_ACCESS_TOKEN environment variable. Add it to your .env file."
    );
  }

  const res = await fetch(`${TMDB_BASE_URL}${path}`, {
    headers: {
      Authorization: `Bearer ${TMDB_TOKEN}`,
      accept: "application/json",
    },
  });

  if (!res.ok) {
    throw new Error(`TMDB request failed with status ${res.status}`);
  }

  return res.json() as Promise<T>;
}

interface TmdbSearchResponse {
  results: Array<{
    id: number;
    name: string;
    poster_path: string | null;
    first_air_date?: string;
    overview?: string;
  }>;
}

interface TmdbDetailsResponse {
  id: number;
  name: string;
  poster_path: string | null;
  number_of_seasons: number;
  number_of_episodes: number;
}

// Search TV shows by title. Returns a trimmed list shaped for the UI
export async function searchShows(query: string): Promise<TmdbSearchResult[]> {
  const data = await tmdbFetch<TmdbSearchResponse>(
    `/search/tv?query=${encodeURIComponent(query)}&include_adult=false&page=1`
  );

  return (data.results ?? []).slice(0, 8).map((show) => ({
    tmdbId: show.id,
    title: show.name,
    posterPath: show.poster_path,
    year: show.first_air_date ? show.first_air_date.slice(0, 4) : null,
    overview: show.overview ?? "",
  }));
}

//Fetch the season and episode counts for a single show
export async function getShowDetails(tmdbId: number): Promise<TmdbShowDetails> {
  const data = await tmdbFetch<TmdbDetailsResponse>(`/tv/${tmdbId}`);

  return {
    tmdbId: data.id,
    title: data.name,
    posterPath: data.poster_path,
    totalSeasons: data.number_of_seasons ?? 0,
    totalEpisodes: data.number_of_episodes ?? 0,
  };
}
Enter fullscreen mode Exit fullscreen mode

While building this, I ran into one small limitation of the TMDB API. The search endpoint is great for helping users find a TV show, but it doesn't include the season and episode totals our tracker needs. That's why there are two functions in this file: searchShows(), which returns the results displayed in the search UI, and getShowDetails(), which runs when a show is added to the library. That second request fetches the complete show details, including the season and episode counts we store in MongoDB. You'll also notice that the TMDB token never leaves the server, as it comes from an environment variable accessible only to the server-side code. The base URL for the poster image is different. Since it's just a constant rather than a secret, we can safely keep it in src/lib/constants.ts alongside the human-readable status labels:

//src/lib/constants.ts
import type { WatchStatus } from "./types";

// TMDB serves poster images from a few fixed sizes. w342 looks crisp on cards.
export const TMDB_POSTER_BASE = "https://image.tmdb.org/t/p/w342";

export const STATUS_LABELS: Record<WatchStatus, string> = {
  plan_to_watch: "Plan to watch",
  watching: "Watching",
  completed: "Completed",
};
Enter fullscreen mode Exit fullscreen mode

At this point, we can search for TV shows and fetch all the information needed to save them. The next step is wiring these functions into our server so the application can start reading from and writing to MongoDB.

Building the server functions

Now that we can read data from MongoDB and fetch metadata from TMDB, we can start wiring the application together. TanStack Start uses createServerFn to define server-side functions. Each function validates its input with Zod before executing the handler, so by the time our code runs, we already know the data is valid and correctly typed. We'll start with the TMDB search function. Add the following code to src/server/tmdb.ts:

// src/server/tmdb.ts
import { createServerFn } from "@tanstack/react-start";
import { searchSchema, type TmdbSearchResult } from "~/lib/types";
import { searchShows } from "~/lib/tmdb";

export const searchTmdb = createServerFn({ method: "GET" })
  .validator(searchSchema)
  .handler(async ({ data }): Promise<TmdbSearchResult[]> => {
    try {
      return await searchShows(data.query);
    } catch (error) {
      console.error("TMDB search failed:", error);
      throw new Error("Failed to search TMDB");
    }
  });
Enter fullscreen mode Exit fullscreen mode

Most of the application's behavior lives in shows.ts. Rather than pasting several hundred lines of code into this article, I've included the complete code in this repo at src/server/shows.ts, so add it to your project. Even though we aren't walking through every line, there are three functions worth understanding because they capture most of the application's behavior:

  • addShow checks whether a show with the same tmdbId already exists. If it doesn't, it fetches the show's details from TMDB, creates the MongoDB document, and returns the newly created show.

  • incrementProgress is where I ended up moving more logic into MongoDB. My first version updated the episode count in JavaScript after reading the document. Eventually, I realized the database already had everything it needed. The aggregation update increments the episode count, prevents it from going past the total number of episodes, and automatically updates the show's status when necessary.

  • getStats is where storing a dedicated status field starts paying off. Instead of loading every show into memory and counting them in JavaScript, MongoDB calculates the dashboard statistics with a single aggregation pipeline and returns only the numbers the UI needs.

With these server functions in place, the backend can search, store, update, and summarize data. The only missing piece is the user interface.

Building the user interface

Up to this point, everything we've built has lived on the server. The final step is putting a user interface on top of it. I kept the front end deliberately simple. The application lives in a single route that loads the watch list and statistics, renders them on the page, and refreshes itself whenever data changes. Instead of manually updating local state after every action, I simply invalidate the route so the loader fetches the latest data from MongoDB. 

The route shown below is a shortened version of the final implementation that shows the overall pattern. The complete version is available in the GitHub repository at src/routes/index.tsx.

// src/routes/index.tsx
export const Route = createFileRoute("/")({
  loader: async () => {
    const [shows, stats] = await Promise.all([getShows(), getStats()]);
    return { shows, stats };
  },
  component: Home,
});

function Home() {
  const { shows, stats } = Route.useLoaderData();
  const router = useRouter();
  const [busyId, setBusyId] = useState<string | null>(null);

  // Re-run the loader so that the shows and stats reflect the latest database state
  const refresh = () => router.invalidate();

  async function handleIncrement(id: string) {
    setBusyId(id);
    try {
      await incrementProgress({ data: { id } });
      await refresh();
    } finally {
      setBusyId(null);
    }
  }

  // ...search, add, status, rating, and delete handlers follow the same shape
  return <main className="page">{/* stats bar, search, and the show list */}</main>;
}
Enter fullscreen mode Exit fullscreen mode

I also moved the stylesheet to the repository (src/styles/app.css) to keep this article focused on the application architecture rather than on visual styling. Once those files are in place, the application is ready to run. Yay!

One lesson from building this project was that I didn't need much client-side state at all. Since the loader already fetches the latest data, I could treat the database as the source of truth and reload the route after each action. That approach kept the component small and avoided much of the bookkeeping in the browser.

Try it locally

Let’s test it out. To do this, run the npm run dev command on your terminal, then go to http://localhost:3000/, search for a show, and add it to your library. Try incrementing the episode count a few times and watch the progress update. The watch list, statistics, and progress tracking should all stay in sync as the route reloads after each change. At this point, the application is complete.

The completed movie/TV show application running locally

As you can see, on the app, you can now search for TV shows, add them to the library, track viewing progress, update ratings and status, and calculate dashboard statistics, all using TanStack Start server functions backed by MongoDB.

Final thoughts

This project started because I wanted a simple answer to a simple question: "How far through this show am I?"

Along the way, we built a small full-stack application using TanStack Start, MongoDB, TMDB, Zod, and server functions. The final result is intentionally simple. Search for a show, add it to your library, track your progress, and see a summary of your watching habits.

There are plenty of directions you could take this project next. User accounts, episode history, recommendations, and shared watch lists would all fit naturally on top of what we've built here. I stopped here because the app already does exactly what I wanted: it tells me where I am in a TV show without any extra complexity. If you'd like to keep building, the full source code is available in this repository.

Key Takeaways

  • Building a small TMDB client gave the application a single place to handle API requests, making future changes easier without affecting the rest of the codebase.

  • TanStack Start server functions, together with MongoDB, provide a clear separation between the user interface and the application's data and business logic.

  • MongoDB aggregation pipelines and atomic update operations simplify application code by moving data processing closer to where the data is stored.

Top comments (0)