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: 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.
AMODB_KEY=YOUR_API_KEYPaso 2: crea el proyecto
Crea package.json, instala los paquetes y añade el host de imágenes a next.config.js.
{
"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:
npm installLos 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.
/** @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.
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.
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/ sirve a la página de inicio y a la de búsqueda. La URL de un póster es https:/ más el valor de poster_path. Consulta imágenes para ver los tamaños.
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.
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.
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_, 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.
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. |
| 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:
'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.
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á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
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. |
npm run devPróximos pasos
- Añade una función
generateMetadataa 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_, descrito en créditos.to_ response= credits - Cambia
languageal idioma del visitante, usando los 14 idiomas base de idiomas. - Mira la misma tarea para un teléfono en el tutorial de Swift.