Back to Blog
Apr 1, 20264 min readOnuzulike Anthony

Integrating the TMDB API: Type-Safe Wrappers and Caching

Rate limiting, type-safe fetch wrappers, image URL construction, ISR caching, and handling TMDB's pagination.

Full-StackTMDBAPINext.js

The Movie Database (TMDB) API is the backbone of NodWatch's content data. It's well-designed but has quirks — image URL construction, nested language objects, inconsistent typing between movie and TV endpoints. Here's a type-safe wrapper that handles all of it.

Base Client

ts
// lib/tmdb/client.ts
const TMDB_BASE = "https://api.themoviedb.org/3";
const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p";
 
async function tmdbFetch<T>(
  path: string,
  params: Record<string, string | number> = {},
  cacheOptions?: RequestInit["next"]
): Promise<T> {
  const url = new URL(`${TMDB_BASE}${path}`);
  url.searchParams.set("language", "en-US");
 
  for (const [key, val] of Object.entries(params)) {
    url.searchParams.set(key, String(val));
  }
 
  const res = await fetch(url.toString(), {
    headers: { Authorization: `Bearer ${process.env.TMDB_ACCESS_TOKEN}` },
    next: cacheOptions,
  });
 
  if (!res.ok) {
    throw new Error(`TMDB ${res.status}: ${path}`);
  }
 
  return res.json();
}

The next field on the fetch options is Next.js-specific — it controls ISR caching per-request.

Types

TMDB returns movies and TV shows from the same search endpoint but with different shapes. Model them carefully:

ts
// lib/tmdb/types.ts
export type MediaType = "movie" | "tv";
 
interface BaseMedia {
  id: number;
  poster_path: string | null;
  backdrop_path: string | null;
  vote_average: number;
  vote_count: number;
  overview: string;
  genre_ids: number[];
}
 
export interface Movie extends BaseMedia {
  media_type: "movie";
  title: string;
  release_date: string;
  original_title: string;
}
 
export interface TvShow extends BaseMedia {
  media_type: "tv";
  name: string;
  first_air_date: string;
  original_name: string;
  number_of_seasons?: number;
}
 
export type Media = Movie | TvShow;
 
export interface TmdbPage<T> {
  page: number;
  results: T[];
  total_pages: number;
  total_results: number;
}

Image URLs

TMDB image paths are relative. Build full URLs with the correct size:

ts
// lib/tmdb/images.ts
type PosterSize = "w92" | "w154" | "w185" | "w342" | "w500" | "w780" | "original";
type BackdropSize = "w300" | "w780" | "w1280" | "original";
 
export function posterUrl(path: string | null, size: PosterSize = "w342"): string {
  if (!path) return "/placeholder-poster.png";
  return `https://image.tmdb.org/t/p/${size}${path}`;
}
 
export function backdropUrl(path: string | null, size: BackdropSize = "w1280"): string {
  if (!path) return "/placeholder-backdrop.png";
  return `https://image.tmdb.org/t/p/${size}${path}`;
}

Endpoint Wrappers

ts
// lib/tmdb/movies.ts
export async function getMovieDetails(id: number) {
  return tmdbFetch<Movie & { runtime: number; genres: { id: number; name: string }[] }>(
    `/movie/${id}`,
    {},
    { revalidate: 60 * 60 * 24 } // ISR: revalidate every 24 hours
  );
}
 
export async function getTrending(mediaType: MediaType = "movie", timeWindow: "day" | "week" = "week") {
  return tmdbFetch<TmdbPage<Media>>(
    `/trending/${mediaType}/${timeWindow}`,
    {},
    { revalidate: 60 * 60 } // 1 hour
  );
}
 
export async function searchMulti(query: string, page = 1) {
  return tmdbFetch<TmdbPage<Media & { media_type: MediaType }>>(
    "/search/multi",
    { query, page, include_adult: false },
    { revalidate: 60 * 10 } // 10 minutes — search results change
  );
}
 
export async function getTvSeason(showId: number, season: number) {
  return tmdbFetch<{
    episodes: {
      id: number;
      episode_number: number;
      name: string;
      overview: string;
      still_path: string | null;
      air_date: string;
      runtime: number | null;
    }[];
  }>(
    `/tv/${showId}/season/${season}`,
    {},
    { revalidate: 60 * 60 * 12 } // 12 hours
  );
}

Handling Pagination

TMDB caps results at 500 pages (10,000 results). For infinite scroll:

ts
export async function getPopularPage(mediaType: MediaType, page: number) {
  return tmdbFetch<TmdbPage<Media>>(
    `/${mediaType}/popular`,
    { page: Math.min(page, 500) },
    { revalidate: 60 * 30 }
  );
}

On the client with TanStack Query:

ts
export function useInfinitePopular(mediaType: MediaType) {
  return useInfiniteQuery({
    queryKey: ["popular", mediaType],
    queryFn: ({ pageParam = 1 }) => getPopularPage(mediaType, pageParam),
    getNextPageParam: (last) =>
      last.page < Math.min(last.total_pages, 500) ? last.page + 1 : undefined,
    initialPageParam: 1,
  });
}

Rate Limiting

TMDB's free tier allows 50 requests per second. On the server, requests are server-side and shared across users — ISR caching means you rarely hit limits. If you do, respect the Retry-After header:

ts
if (res.status === 429) {
  const retryAfter = res.headers.get("Retry-After");
  throw new Error(`Rate limited. Retry after ${retryAfter}s`);
}

For client-side search, debounce the query (300ms minimum) to avoid hammering the endpoint on every keystroke.

Helper: Normalize Movie/TV

Both types have name/title and first_air_date/release_date — normalize them for shared UI:

ts
export function normalizeMedia(media: Media) {
  return {
    id: media.id,
    title: "title" in media ? media.title : media.name,
    date: "release_date" in media ? media.release_date : media.first_air_date,
    mediaType: media.media_type,
    poster: posterUrl(media.poster_path),
    backdrop: backdropUrl(media.backdrop_path),
    rating: media.vote_average,
  };
}

One normalized type, one component — no if movie else if tv branches in UI code.