Tutorial de app de películas con Next.js

Este tutorial crea un sitio de películas de tres páginas con Next.js 15 y el App Router en JavaScript. Los componentes de servidor llaman a amodb con fetch y revalidate, la clave vive solo en una variable de entorno del servidor y next.config.js permite el host de pósteres img.amodb.dev. Lleva unos 25 minutos.

.jsx Next.js Actualizado el Ver en Markdown

Qué vas a construir

Vas a construir un sitio de películas pequeño con tres páginas: una lista de tendencias en la página de inicio, una página de búsqueda y una página de película con las puntuaciones de IMDb, Kinopoisk y TMDB. Usa Next.js 15 con el App Router, que requiere Node.js 18.18 o superior, y JavaScript puro. Los componentes de servidor llaman a amodb, así que la clave nunca llega al navegador. Calcula unos 25 minutos.

Paso 1: obtén una clave de API y guárdala en el servidor

Elige un plan en precios: 20, 40 o 120 solicitudes por minuto, con los mismos datos. Crea una clave en el panel de tu cuenta y colócala en .env.local en la raíz del proyecto:

No pongas el prefijo NEXT_PUBLIC_ al nombre. Next.js copia las variables con ese prefijo al paquete del navegador, mientras que un nombre sin prefijo se queda en el servidor. Reinicia el servidor de desarrollo después de cambiar el archivo. La página de autenticación describe las formas de la clave en el encabezado y en la consulta.

Shell
AMODB_KEY=YOUR_API_KEY

Paso 2: crea el proyecto

Crea package.json, instala los paquetes y añade el host de imágenes a next.config.js.

JSON
{
  "name": "movie-finder",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

Instala los paquetes:

Shell
npm install

Los pósteres están en img.amodb.dev, y next/image solo carga los hosts que enumeras. El patrón pathname lo limita a la ruta de los pósteres.

JavaScript
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'img.amodb.dev', pathname: '/t/p/**' },
    ],
  },
};

module.exports = nextConfig;

Paso 3: escribe la función auxiliar de fetch

Todas las páginas pasan por una sola función en lib/amodb.js. Añade el encabezado Bearer, un tiempo de espera de 10 segundos y la opción revalidate, que indica a Next.js cuánto tiempo puede reutilizar la respuesta. Next.js 15 no guarda fetch en caché por defecto, así que esa opción es lo que activa el caché.

La ruta empieza con una barra y se añade a la base, por lo que /3 permanece en la URL. Un 404 devuelve null, de modo que una página puede llamar a notFound(). El reintento espera como máximo 10 segundos porque un visitante está esperando la página.

JavaScript
const BASE = 'https://api.amodb.dev/3';

export async function amodb(path, { params = {}, revalidate = 21600 } = {}) {
  const key = process.env.AMODB_KEY;
  if (!key) throw new Error('AMODB_KEY is not set');

  const url = new URL(BASE + path);
  for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);

  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url.toString(), {
      headers: { Authorization: `Bearer ${key}` },
      next: { revalidate },
      signal: AbortSignal.timeout(10000),
    });

    if (res.status === 429 && attempt < 2) {
      const seconds = Math.min(Number(res.headers.get('retry-after')) || 5, 10);
      await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
      continue;
    }
    if (res.status === 401) throw new Error('amodb rejected the API key (401)');
    if (res.status === 404) return null;
    if (!res.ok) throw new Error(`amodb returned ${res.status}`);
    return res.json();
  }
}

Paso 4: crea las páginas

Empieza por el layout. Contiene el formulario de búsqueda, un formulario GET sencillo que envía q a /search.

JavaScript
import Link from 'next/link';

export const metadata = {
  title: 'Movie finder',
  description: 'Trending movies and search, powered by the amodb API.',
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>
        <header style={{ padding: 16, background: '#111', color: '#fff' }}>
          <Link href="/" style={{ color: '#fff', marginRight: 16 }}>Movie finder</Link>
          <form action="/search" style={{ display: 'inline' }}>
            <input name="q" placeholder="Search movies" />
            <button type="submit">Search</button>
          </form>
        </header>
        <main style={{ maxWidth: 960, margin: '0 auto', padding: 16 }}>{children}</main>
      </body>
    </html>
  );
}

La cuadrícula de pósteres de components/MovieGrid.js sirve a la página de inicio y a la de búsqueda. La URL de un póster es https://img.amodb.dev/t/p/w342 más el valor de poster_path. Consulta imágenes para ver los tamaños.

JavaScript
import Image from 'next/image';
import Link from 'next/link';

export default function MovieGrid({ movies }) {
  if (movies.length === 0) return <p>No movies found.</p>;
  return (
    <ul style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 16, padding: 0, listStyle: 'none' }}>
      {movies.map((movie) => (
        <li key={movie.id}>
          <Link href={`/movie/${movie.id}`}>
            {movie.poster_path && (
              <Image
                src={`https://img.amodb.dev/t/p/w342${movie.poster_path}`}
                alt={movie.title}
                width={342}
                height={513}
                style={{ width: '100%', height: 'auto' }}
              />
            )}
            <strong>{movie.title}</strong>
          </Link>
          <div>{(movie.release_date || '').slice(0, 4)}</div>
        </li>
      ))}
    </ul>
  );
}

La página de inicio en app/page.js es un componente de servidor asíncrono. Lee las películas en tendencia de esta semana, descritas en tendencias y listas.

JavaScript
import MovieGrid from '../components/MovieGrid';
import { amodb } from '../lib/amodb';

export default async function Home() {
  const data = await amodb('/trending/movie/week', {
    params: { language: 'en-US' },
    revalidate: 1800,
  });
  return (
    <section>
      <h1>Trending this week</h1>
      <MovieGrid movies={data.results.slice(0, 18)} />
    </section>
  );
}

En Next.js 15, searchParams y params son promesas, así que cada página las espera con await. La página de búsqueda en app/search/page.js usa el endpoint de búsqueda.

JavaScript
import MovieGrid from '../../components/MovieGrid';
import { amodb } from '../../lib/amodb';

export default async function SearchPage({ searchParams }) {
  const { q } = await searchParams;
  const query = (Array.isArray(q) ? q[0] : (q ?? '')).trim();
  if (!query) return <p>Type a title in the box above.</p>;

  const data = await amodb('/search/movie', {
    params: { query, language: 'en-US' },
    revalidate: 600,
  });
  return (
    <section>
      <h1>Results for {query}</h1>
      <MovieGrid movies={data.results} />
    </section>
  );
}

La página de película en app/movie/[id]/page.js solicita una película con append_to_response=ratings, de modo que los detalles y las puntuaciones llegan en una sola llamada. Cada fuente es { value, votes } o null. Una fuente null significa que la nota no se conoce, y un valor null en votes oculta el recuento de votos, así que la página dice «not known» o imprime solo el valor. Para la película 550 muestra 8.8 (2,669,989 votes) para IMDb, 8.7 para Kinopoisk y 8.437 (33,039 votes) para TMDB.

JavaScript
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { amodb } from '../../../lib/amodb';

function score(source) {
  if (!source) return 'not known';
  const votes = source.votes ? ` (${source.votes.toLocaleString('en-US')} votes)` : '';
  return `${source.value}${votes}`;
}

export default async function MoviePage({ params }) {
  const { id } = await params;
  if (!/^\d+$/.test(id)) notFound();

  const movie = await amodb(`/movie/${id}`, {
    params: { language: 'en-US', append_to_response: 'ratings' },
  });
  if (!movie) notFound();

  return (
    <article style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
      {movie.poster_path && (
        <Image src={`https://img.amodb.dev/t/p/w500${movie.poster_path}`} alt={movie.title} width={300} height={450} priority />
      )}
      <div style={{ flex: 1, minWidth: 260 }}>
        <h1>{movie.title} ({(movie.release_date || '').slice(0, 4)})</h1>
        {movie.tagline && <p><em>{movie.tagline}</em></p>}
        <p>{movie.overview}</p>
        <ul>
          <li>Runtime: {movie.runtime ? `${movie.runtime} min` : 'not known'}</li>
          <li>IMDb: {score(movie.ratings?.imdb)}</li>
          <li>Kinopoisk: {score(movie.ratings?.kinopoisk)}</li>
          <li>TMDB: {score(movie.ratings?.tmdb)}</li>
        </ul>
      </div>
    </article>
  );
}

Paso 5: gestiona errores y límites de solicitudes

La función auxiliar lanza un error ante los fallos, y Next.js muestra el archivo error.js más cercano.

EstadoSignificadoQué hace la app
401Clave ausente, incorrecta o suspendidaLa función auxiliar lanza un error, se muestra error.js y la causa queda en el registro del servidor.
402El periodo del plan terminóMismo camino. Renueva en precios.
404No hay registro para ese idLa función auxiliar devuelve null y la página llama a notFound().
429Superaste tus solicitudes por minutoEspera Retry-After segundos, con un tope de 10, reintenta dos veces y luego lanza un error.
500 o 504Fallo del lado del servidorLa función auxiliar lanza un error y se muestra error.js.

Un límite de error debe ser un componente de cliente. En producción Next.js oculta el mensaje de error al navegador y lo escribe en el registro del servidor, así que una clave rechazada aparece en tu terminal y no en la página. Crea app/error.js y app/not-found.js:

JavaScript
'use client';

export default function ErrorPage({ reset }) {
  return (
    <div>
      <h2>The movie service did not answer</h2>
      <p>Try again in a moment.</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

La página not-found es un único componente:

La página de errores enumera todos los códigos de estado, y límites de solicitudes explica los encabezados.

JavaScript
export default function NotFound() {
  return <p>That movie does not exist.</p>;
}

Paso 6: guarda en caché con revalidate

Los valores de revalidate copian la duración del caché perimetral, así que una página nunca se actualiza más rápido de lo que cambian los datos. Un acierto en el caché de datos de Next.js nunca llama a amodb, por lo que tampoco gasta solicitudes por minuto.

Páginarevalidate en segundosDuración del caché perimetral
Inicio, tendencias180030 minutos
Búsqueda60010 minutos
Detalles de la película216006 horas

La página de inicio no tiene entrada dinámica, así que next build la renderiza una vez y la actualiza en segundo plano cada 30 minutos. Esa compilación llama a amodb, de modo que AMODB_KEY también debe estar definida en el entorno de compilación.

Paso 7: ejecuta y comprueba

Abre la dirección local que imprime Next.js. La página de inicio muestra pósteres, el cuadro de búsqueda encuentra títulos y un póster abre la página de su película. Para una ejecución en producción usa npm run build y npm run start. El proyecto tiene ahora estos archivos:

ArchivoFunción
.env.localLa clave, solo en el servidor.
next.config.jsPermite img.amodb.dev para next/image.
lib/amodb.jsFunción auxiliar de fetch con tiempo de espera y gestión de 401, 404 y 429.
app/layout.jsEstructura de la página y formulario de búsqueda.
app/page.jsLista de tendencias.
app/search/page.jsResultados de búsqueda.
app/movie/[id]/page.jsDetalles de la película con puntuaciones.
app/error.js, app/not-found.jsPáginas de fallo y de id inexistente.
Shell
npm run dev

Próximos pasos

  • Añade una función generateMetadata a la página de película para que cada título tenga su propio título de página. Next.js reutiliza la misma llamada a fetch.
  • Muestra el reparto con append_to_response=credits, descrito en créditos.
  • Cambia language al idioma del visitante, usando los 14 idiomas base de idiomas.
  • Mira la misma tarea para un teléfono en el tutorial de Swift.