Tutorial de app de filmes com Next.js
Este tutorial cria um site de filmes com três páginas usando Next.js 15 e App Router em JavaScript puro. Os server components chamam o amodb com fetch e revalidate, a chave fica só em uma variável de ambiente do servidor, e o next.config.js libera o host de pôsteres img.amodb.dev. Leva cerca de 25 minutos.
O que você vai construir
Você vai criar um site de filmes pequeno com três páginas: uma lista de filmes em alta na página inicial, uma página de busca e uma página do filme com notas do IMDb, do Kinopoisk e do TMDB. Ele usa Next.js 15 com o App Router, que exige Node.js 18.18 ou mais novo, e JavaScript puro. Os server components chamam o amodb, então a chave nunca chega ao navegador. Reserve cerca de 25 minutos.
Passo 1: obtenha uma chave de API e mantenha-a no servidor
Escolha um plano em preços: 20, 40 ou 120 requisições por minuto, com os mesmos dados. Crie uma chave no painel da sua conta e coloque-a em .env.local na raiz do projeto:
Não use o prefixo NEXT_PUBLIC_ no nome. O Next.js copia variáveis com esse prefixo para o bundle do navegador, e um nome simples fica no servidor. Reinicie o servidor de desenvolvimento depois de alterar o arquivo. A página de autenticação descreve as formas da chave no cabeçalho e na consulta.
AMODB_KEY=YOUR_API_KEYPasso 2: crie o projeto
Crie o package.json, instale os pacotes e adicione o host das imagens ao 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"
}
}Instale os pacotes:
npm installOs pôsteres ficam em img.amodb.dev, e o next/image só carrega hosts que você listar. O padrão pathname limita o acesso ao caminho dos pôsteres.
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'img.amodb.dev', pathname: '/t/p/**' },
],
},
};
module.exports = nextConfig;Passo 3: escreva o helper de fetch
Toda página passa por uma função em lib/amodb.js. Ela adiciona o cabeçalho Bearer, um timeout de 10 segundos e a opção revalidate, que diz ao Next.js por quanto tempo ele pode reutilizar a resposta. O Next.js 15 não faz cache de fetch por padrão, então essa opção é o que liga o cache.
O caminho começa com uma barra e é somado à base, então /3 permanece na URL. Um 404 retorna null, e assim a página pode chamar notFound(). A nova tentativa espera no máximo 10 segundos, porque um visitante está esperando a 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();
}
}Passo 4: monte as páginas
Comece pelo layout. Ele contém o formulário de busca, um formulário GET simples que envia q para /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>
);
}A grade de pôsteres em components/ atende a página inicial e a página de busca. A URL de um pôster é https:/ mais o valor de poster_path. Veja imagens para os tamanhos.
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>
);
}A página inicial em app/page.js é um server component assíncrono. Ela lê os filmes em alta da semana, descritos em em alta e 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>
);
}No Next.js 15, searchParams e params são promises, então cada página usa await nelas. A página de busca em app/search/page.js usa o endpoint de busca.
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>
);
}A página do filme em app/movie/[id]/page.js busca um filme com append_, então detalhes e notas chegam em uma única chamada. Cada fonte é { value, votes } ou null. Uma fonte null significa que a nota não é conhecida, e um null em votes oculta a contagem de votos, então a página mostra "not known" ou imprime só o valor. Para o filme 550 ela mostra 8.8 (2,669,989 votes) para o IMDb, 8.7 para o Kinopoisk e 8.437 (33,039 votes) para o 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>
);
}Passo 5: trate erros e limites de requisições
O helper lança uma exceção nas falhas, e o Next.js mostra o arquivo error.js mais próximo.
| Status | Significado | O que o app faz |
|---|---|---|
| 401 | Chave ausente, incorreta ou suspensa | O helper lança uma exceção, o error.js é renderizado e a causa fica no log do servidor. |
| 402 | O período do plano terminou | Mesmo caminho. Renove em preços. |
| 404 | Nenhum registro para esse id | O helper retorna null e a página chama notFound(). |
| 429 | Acima das suas requisições por minuto | Espera Retry-After segundos, com limite de 10, tenta de novo duas vezes e depois lança uma exceção. |
| 500 ou 504 | Falha no lado do servidor | O helper lança uma exceção e o error.js é renderizado. |
Um error boundary precisa ser um client component. Em produção, o Next.js oculta a mensagem de erro do navegador e a imprime no log do servidor, então uma chave rejeitada aparece no seu terminal e não na página. Crie app/error.js e 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>
);
}A página de não encontrado é um único componente:
A página de erros lista todos os códigos de status, e limites de requisições explica os cabeçalhos.
export default function NotFound() {
return <p>That movie does not exist.</p>;
}Passo 6: faça cache com revalidate
Os valores de revalidate copiam a duração do cache de borda, então a página nunca atualiza mais rápido do que os dados mudam. Um acerto no cache de dados do Next.js nunca chama o amodb, então também não consome requisições por minuto.
| Página | revalidate em segundos | Duração no cache de borda |
|---|---|---|
| Início, em alta | 1800 | 30 minutos |
| Busca | 600 | 10 minutos |
| Detalhes do filme | 21600 | 6 horas |
A página inicial não tem entrada dinâmica, então next build a renderiza uma vez e a atualiza em segundo plano a cada 30 minutos. Esse build chama o amodb, então AMODB_KEY também precisa estar definida no ambiente de build.
Passo 7: execute e confira
Abra o endereço local que o Next.js imprime. A página inicial mostra pôsteres, a caixa de busca encontra títulos e um pôster abre a página do filme. Para rodar em produção, use npm run build e npm run start. O projeto agora tem estes arquivos:
| Arquivo | Função |
|---|---|
.env.local | A chave, só no servidor. |
next.config.js | Libera img.amodb.dev para o next/image. |
lib/amodb.js | Helper de fetch com timeout e tratamento de 401, 404 e 429. |
app/layout.js | Estrutura da página e formulário de busca. |
app/page.js | Lista de filmes em alta. |
app/search/page.js | Resultados da busca. |
app/movie/[id]/page.js | Detalhes do filme com avaliações. |
app/error.js, app/not-found.js | Páginas de falha e de id inexistente. |
npm run devPróximos passos
- Adicione uma função
generateMetadataà página do filme para que cada título tenha seu próprio título de página. O Next.js reutiliza a mesma chamada de fetch. - Mostre o elenco com
append_, descrito em créditos.to_ response= credits - Troque
languagepelo idioma do visitante, usando os 14 idiomas base de idiomas. - Veja a mesma tarefa para celular no tutorial de Swift.