Volver a las notas

La API de TMDB en Next.js: el problema no es CORS, es el token

26 de agosto de 2026·
nextjsapicorsseguridad

Nota revisada y reescrita el 18 de octubre de 2026: la versión original tenía errores y datos sin fuente.

Es habitual leer que para usar la API de TMDB (The Movie Database) desde Next.js "hay que montar un proxy para evitar errores de CORS". Lo he comprobado y no es así: la API responde con access-control-allow-origin: *, y el preflight acepta la cabecera Authorization:

$ curl -s -X OPTIONS -D - -o /dev/null \
    -H "Origin: https://example.com" \
    -H "Access-Control-Request-Method: GET" \
    -H "Access-Control-Request-Headers: authorization" \
    https://api.themoviedb.org/3/configuration | grep -i access-control
access-control-allow-origin: *
access-control-allow-methods: GET,HEAD,PUT,POST,DELETE,OPTIONS
access-control-allow-headers: Authorization,Content-Type,...

El navegador puede llamar a TMDB directamente sin ningún error de CORS. Si te aparece uno, mira otra cosa: una URL mal escrita, una redirección o una extensión que bloquea la petición.

Entonces, ¿por qué pasar por tu servidor?

Porque para llamar desde el navegador tienes que poner tu credencial de TMDB en el JavaScript que descarga cualquiera. Con una variable NEXT_PUBLIC_TMDB_TOKEN, el token acaba dentro del bundle, y cualquiera puede copiarlo de las herramientas de desarrollo y usar tu cuota o hacer peticiones en tu nombre. Las razones de verdad para un proxy son:

  1. El token se queda en el servidor.
  2. Caché. Las listas de populares o los detalles de una película no cambian cada segundo, y no hace falta pedirlos a TMDB en cada visita.
  3. Respuesta recortada. Devuelves solo los campos que pinta tu interfaz.

Route Handler (App Router)

// app/api/movies/popular/route.js
export async function GET(request) {
  const page = Number(new URL(request.url).searchParams.get('page') ?? 1);
  if (!Number.isInteger(page) || page < 1 || page > 500) {
    return Response.json({ error: 'página no válida' }, { status: 400 });
  }

  const res = await fetch(
    `https://api.themoviedb.org/3/movie/popular?language=es-ES&page=${page}`,
    {
      headers: { Authorization: `Bearer ${process.env.TMDB_READ_TOKEN}` },
      next: { revalidate: 3600 }, // caché de una hora en el servidor
    }
  );
  if (!res.ok) return Response.json({ error: 'TMDB no responde' }, { status: 502 });

  const data = await res.json();
  return Response.json({
    page: data.page,
    results: data.results.map(({ id, title, poster_path, vote_average }) => ({
      id, title, poster_path, vote_average,
    })),
  });
}
  • TMDB_READ_TOKEN es el API Read Access Token de tu cuenta de TMDB (se envía como Bearer), sin el prefijo NEXT_PUBLIC_.
  • Valida lo que viene del cliente antes de meterlo en la URL de TMDB (aquí, que page sea un número razonable). Si concatenas un parámetro sin más, tu endpoint deja que cualquiera haga a TMDB las peticiones que quiera con tu token.
  • next: { revalidate: 3600 } guarda la respuesta en la caché de datos de Next.js durante una hora.

O sin endpoint: en un Server Component

Si la página se renderiza en el servidor, ni siquiera hace falta la ruta de API. El componente llama a TMDB directamente y el token nunca llega al navegador:

// app/peliculas/page.jsx (Server Component)
export default async function Peliculas() {
  const res = await fetch('https://api.themoviedb.org/3/movie/popular?language=es-ES', {
    headers: { Authorization: `Bearer ${process.env.TMDB_READ_TOKEN}` },
    next: { revalidate: 3600 },
  });
  const { results } = await res.json();
  return <ul>{results.map((m) => <li key={m.id}>{m.title}</li>)}</ul>;
}

En el Pages Router, lo equivalente es getServerSideProps o getStaticProps con revalidate.

Imágenes

Las rutas de los pósters (poster_path) se completan con la URL base de imágenes de TMDB (https://image.tmdb.org/t/p/w500/...). Si usas next/image, añade ese dominio a images.remotePatterns en next.config.js, o el componente se negará a cargarlas.

Fuentes