# 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.

## 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](https://amodb.dev/es/pricing/): 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:

```bash
AMODB_KEY=YOUR_API_KEY
```

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](https://amodb.dev/es/authentication/) describe las formas de la clave en el encabezado y en la consulta.

## 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:

```bash
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.

```js
/** @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é.

```js
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();
  }
}
```

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.

## 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`.

```js
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](https://amodb.dev/es/images-api/) para ver los tamaños.

```js
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](https://amodb.dev/es/trending-lists/).

```js
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](https://amodb.dev/es/search/).

```js
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.

```js
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.

| Estado    | Significado                            | Qué hace la app                                                                                         |
| --------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| 401       | Clave ausente, incorrecta o suspendida | La función auxiliar lanza un error, se muestra `error.js` y la causa queda en el registro del servidor. |
| 402       | El periodo del plan terminó            | Mismo camino. Renueva en [precios](https://amodb.dev/es/pricing/).                                      |
| 404       | No hay registro para ese id            | La función auxiliar devuelve `null` y la página llama a `notFound()`.                                   |
| 429       | Superaste tus solicitudes por minuto   | Espera `Retry-After` segundos, con un tope de 10, reintenta dos veces y luego lanza un error.           |
| 500 o 504 | Fallo del lado del servidor            | La 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`:

```js
'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:

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

La página de [errores](https://amodb.dev/es/errors/) enumera todos los códigos de estado, y [límites de solicitudes](https://amodb.dev/es/rate-limits/) explica los encabezados.

## 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ágina                  | `revalidate` en segundos | Duración del caché perimetral |
| ----------------------- | ------------------------ | ----------------------------- |
| Inicio, tendencias      | 1800                     | 30 minutos                    |
| Búsqueda                | 600                      | 10 minutos                    |
| Detalles de la película | 21600                    | 6 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

```bash
npm run dev
```

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:

| Archivo                            | Función                                                                     |
| ---------------------------------- | --------------------------------------------------------------------------- |
| `.env.local`                       | La clave, solo en el servidor.                                              |
| `next.config.js`                   | Permite `img.amodb.dev` para `next/image`.                                  |
| `lib/amodb.js`                     | Función auxiliar de fetch con tiempo de espera y gestión de 401, 404 y 429. |
| `app/layout.js`                    | Estructura de la página y formulario de búsqueda.                           |
| `app/page.js`                      | Lista de tendencias.                                                        |
| `app/search/page.js`               | Resultados de búsqueda.                                                     |
| `app/movie/[id]/page.js`           | Detalles de la película con puntuaciones.                                   |
| `app/error.js`, `app/not-found.js` | Páginas de fallo y de id inexistente.                                       |

## 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](https://amodb.dev/es/credits/).
- Cambia `language` al idioma del visitante, usando los 14 idiomas base de [idiomas](https://amodb.dev/es/languages/).
- Mira la misma tarea para un teléfono en el [tutorial de Swift](https://amodb.dev/es/swift-movie-api/).

## Preguntas y respuestas

### ¿Puede filtrarse la clave de API al navegador en Next.js?

Solo si la expones. Mantén AMODB_KEY sin el prefijo NEXT_PUBLIC_ y llama a amodb desde componentes de servidor, route handlers o server actions. No importes nunca la función auxiliar de fetch en un archivo que empiece con use client.

### ¿Por qué next/image necesita remotePatterns para los pósteres?

El componente Image solo optimiza imágenes de los hosts que permites. Añade img.amodb.dev con la ruta /t/p/** a remotePatterns en next.config.js, o el componente lanza un error con cada URL de póster.

### ¿Cuánto debe durar revalidate en cada página?

Iguala el caché perimetral: 1800 segundos para las listas de tendencias, 600 para la búsqueda y 21600 para los detalles de una película. Un valor menor solo añade llamadas que devuelven los mismos datos y gastan solicitudes por minuto.

### ¿Qué pasa cuando amodb responde 429?

La función auxiliar lee Retry-After, espera hasta 10 segundos y reintenta dos veces. Si el límite sigue activo, lanza un error y error.js muestra una página con un botón de reintento en lugar de una lista rota.

### ¿Necesito TypeScript para esta app?

No. Todos los archivos son JavaScript con JSX en archivos .js. Las funciones del App Router funcionan igual, y puedes convertir los archivos a TypeScript más adelante sin cambiar ninguna llamada a amodb.

## Páginas relacionadas

- [Tutorial de API de películas en Swift con SwiftUI](https://amodb.dev/es/swift-movie-api/): Crea una app de SwiftUI para iOS 16 con películas en tendencia y pósteres usando URLSession con async/await y AsyncImage, y mantén la clave en un servidor.
- [Tutorial de API de películas en JavaScript](https://amodb.dev/es/javascript-movie-api/): Crea un script de Node.js que carga detalles, créditos y puntuaciones con una solicitud por película, caché TTL, reintento ante 429 y sin dependencias.
- [API de búsqueda](https://amodb.dev/es/search/): API de búsqueda con rutas y JSON de TMDB v3. Encuentra películas, series, personas, colecciones, productoras y palabras clave por texto, con filtros de año.
- [Endpoints de tendencias y listas](https://amodb.dev/es/trending-lists/): API de películas en tendencia con rutas TMDB v3: rankings diarios y semanales, más cartelera, populares, mejor valoradas y estrenos. Caché de 30 minutos.
- [API de imágenes y host de imágenes](https://amodb.dev/es/images-api/): API de imágenes de películas con rutas TMDB v3: lista pósters, fondos, logos y fotogramas por título y cárgalos desde img.amodb.dev en 10 tamaños sin clave.
- [Autenticación](https://amodb.dev/es/authentication/): Cómo enviar tu clave de amodb: api_key en la consulta, un encabezado Bearer o apikey en llamadas OMDb. Formato, almacenamiento, rotación y errores 401.

Actualizado: 2026-10-18. Source: https://amodb.dev/es/nextjs-movie-app/
