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

```bash
AMODB_KEY=YOUR_API_KEY
```

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](https://amodb.dev/pt/authentication/) descreve as formas da chave no cabeçalho e na consulta.

## Passo 2: crie o projeto

Crie o `package.json`, instale os pacotes e adicione o host das imagens ao `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"
  }
}
```

Instale os pacotes:

```bash
npm install
```

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

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

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

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.

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

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

A grade de pôsteres em `components/MovieGrid.js` atende a página inicial e a página de busca. A URL de um pôster é `https://img.amodb.dev/t/p/w342` mais o valor de `poster_path`. Veja [imagens](https://amodb.dev/pt/images-api/) para os tamanhos.

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

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](https://amodb.dev/pt/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>
  );
}
```

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](https://amodb.dev/pt/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>
  );
}
```

A página do filme em `app/movie/[id]/page.js` busca um filme com `append_to_response=ratings`, 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.

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

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

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

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

A página de [erros](https://amodb.dev/pt/errors/) lista todos os códigos de status, e [limites de requisições](https://amodb.dev/pt/rate-limits/) explica os cabeçalhos.

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

```bash
npm run dev
```

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

## Pró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_to_response=credits`, descrito em [créditos](https://amodb.dev/pt/credits/).
- Troque `language` pelo idioma do visitante, usando os 14 idiomas base de [idiomas](https://amodb.dev/pt/languages/).
- Veja a mesma tarefa para celular no [tutorial de Swift](https://amodb.dev/pt/swift-movie-api/).

## Perguntas e respostas

### A chave de API pode vazar para o navegador no Next.js?

Só se você a expuser. Mantenha AMODB_KEY sem o prefixo NEXT_PUBLIC_ e chame o amodb em server components, route handlers ou server actions. Nunca importe o helper de fetch em um arquivo que começa com use client.

### Por que o next/image precisa de remotePatterns para os pôsteres?

O componente Image só otimiza imagens de hosts que você permite. Adicione img.amodb.dev com o pathname /t/p/** em remotePatterns no next.config.js, ou o componente lança um erro para cada URL de pôster.

### Qual deve ser o revalidate de cada página?

Siga o cache de borda: 1800 segundos para listas em alta, 600 para busca e 21600 para detalhes do filme. Um valor menor só adiciona chamadas que retornam os mesmos dados e gastam requisições por minuto.

### O que acontece quando o amodb responde 429?

O helper lê o Retry-After, espera até 10 segundos e tenta de novo duas vezes. Se o limite ainda valer, ele lança uma exceção, e o error.js renderiza uma página com um botão de nova tentativa em vez de uma lista quebrada.

### Preciso de TypeScript para este app?

Não. Todos os arquivos aqui são JavaScript puro, com JSX em arquivos .js. Os recursos do App Router funcionam do mesmo jeito, e você pode converter os arquivos para TypeScript depois sem mudar nenhuma chamada ao amodb.

## Páginas relacionadas

- [Tutorial de API de filmes em Swift com SwiftUI](https://amodb.dev/pt/swift-movie-api/): Crie um app SwiftUI para iOS 16 que lista filmes em alta com pôsteres usando URLSession async/await e AsyncImage, e veja por que a chave fica em um servidor.
- [Tutorial de API de filmes em JavaScript](https://amodb.dev/pt/javascript-movie-api/): Escreva um script Node.js que carrega detalhes, créditos e avaliações de ids de filmes em uma requisição cada, com cache TTL, retry em 429 e sem dependências.
- [API de busca](https://amodb.dev/pt/search/): API de busca com caminhos e JSON do TMDB v3. Encontre filmes, séries, pessoas, coleções, produtoras e palavras-chave por texto, com filtro de ano e paginação.
- [Endpoints de em alta e de listas](https://amodb.dev/pt/trending-lists/): API de filmes em alta com caminhos do TMDB v3: rankings do dia e da semana, listas em cartaz, populares, mais bem avaliados e em breve. Cache de 30 minutos.
- [API de imagens e host de imagens](https://amodb.dev/pt/images-api/): API de imagens de filmes com caminhos do TMDB v3: liste pôsteres, backdrops, logos e stills por título e carregue-os de img.amodb.dev em 10 tamanhos, sem chave.
- [Autenticação](https://amodb.dev/pt/authentication/): Como enviar sua chave do amodb: api_key na consulta, um cabeçalho Authorization Bearer ou apikey nas chamadas OMDb. Formato, armazenamento, rotação e erros 401.

Atualizado: 2026-10-18. Source: https://amodb.dev/pt/nextjs-movie-app/
