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.
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
// 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:
// 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:
// 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
// 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:
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:
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:
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:
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.