# Падручнік па праграме фільмаў на Next.js

> Гэты падручнік будуе сайт фільмаў з трох старонак на Next.js 15 і App Router на чыстым JavaScript. Серверныя кампаненты выклікаюць amodb праз fetch і revalidate, ключ жыве толькі ў зменнай асяроддзя сервера, а next.config.js дазваляе хост постэраў img.amodb.dev. Заняткі займаюць каля 25 хвілін.

## Што вы пабудуеце

Вы пабудуеце невялікі сайт фільмаў з трох старонак: спіс трэндаў на галоўнай, старонка пошуку і старонка фільма з ацэнкамі IMDb, Kinopoisk і TMDB. Ён выкарыстоўвае Next.js 15 з App Router, якому патрэбны Node.js 18.18 або новей, і чысты JavaScript. Серверныя кампаненты выклікаюць amodb, таму ключ ніколі не трапляе ў браўзер. Закладзіце каля 25 хвілін.

## Крок 1: Атрымайце ключ API і трымайце яго на серверы

Абярыце план на старонцы [цэны](https://amodb.dev/be/pricing/): 20, 40 або 120 запытаў у хвіліну, даныя аднолькавыя. Стварыце ключ у асабістым кабінеце, потым змесціце яго ў `.env.local` у корані праекта:

```bash
AMODB_KEY=YOUR_API_KEY
```

Не дадавайце да назвы прэфікс `NEXT_PUBLIC_`. Next.js капіюе зменныя з гэтым прэфіксам у пакет для браўзера, а проста названая застаецца на серверы. Перазапусціце сервер распрацоўкі пасля змены файла. Старонка [аўтэнтыфікацыя](https://amodb.dev/be/authentication/) апісвае формы ключа ў загалоўку і ў запыце.

## Крок 2: Стварыце праект

Стварыце `package.json`, усталюйце пакеты і дадайце хост выяў у `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"
  }
}
```

Усталюйце пакеты:

```bash
npm install
```

Постэры жывуць на `img.amodb.dev`, а `next/image` загружае толькі пералічаныя вамі хосты. Шаблон `pathname` абмяжоўвае гэта шляхам постэраў.

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

module.exports = nextConfig;
```

## Крок 3: Напішыце дапаможную функцыю fetch

Кожная старонка ідзе праз адну функцыю ў `lib/amodb.js`. Яна дадае загаловак Bearer, тайм-аўт 10 секунд і опцыю `revalidate`, якая кажа Next.js, як доўга можна выкарыстоўваць адказ паўторна. Next.js 15 не кэшуе `fetch` па змаўчанні, таму менавіта опцыя ўключае кэш.

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

Шлях пачынаецца з косай рыскі і дадаецца да базы, таму `/3` застаецца ў URL. 404 вяртае `null`, каб старонка магла выклікаць `notFound()`. Паўтор чакае не болей за 10 секунд, бо наведвальнік чакае старонку.

## Крок 4: Пабудуйце старонкі

Пачніце з макета. У ім форма пошуку, звычайная форма `GET`, што адпраўляе `q` на `/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>
  );
}
```

Сетка постэраў у `components/MovieGrid.js` абслугоўвае галоўную і старонку пошуку. URL постэра гэта `https://img.amodb.dev/t/p/w342` плюс значэнне `poster_path`. Памеры гл. на старонцы [выявы](https://amodb.dev/be/images-api/).

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

Галоўная старонка ў `app/page.js` гэта асінхронны серверны кампанент. Ён чытае трэндавыя фільмы тыдня, апісаныя на старонцы [трэнды і спісы](https://amodb.dev/be/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>
  );
}
```

У Next.js 15 `searchParams` і `params` гэта промісы, таму кожная старонка ўжывае да іх await. Старонка пошуку ў `app/search/page.js` выкарыстоўвае эндпоінт [пошуку](https://amodb.dev/be/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>
  );
}
```

Старонка фільма ў `app/movie/[id]/page.js` запытвае адзін фільм з `append_to_response=ratings`, таму дэталі і ацэнкі прыходзяць адным выклікам. Кожная крыніца мае выгляд `{ value, votes }` або `null`. Крыніца `null` азначае, што ацэнка невядомая, а `null` у `votes` хавае колькасць галасоў, таму старонка піша «not known» або друкуе толькі значэнне. Для фільма 550 яна паказвае `8.8 (2,669,989 votes)` для IMDb, `8.7` для Kinopoisk і `8.437 (33,039 votes)` для 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>
  );
}
```

## Крок 5: Апрацоўка памылак і лімітаў

Дапаможная функцыя кідае выключэнне пры збоях, а Next.js паказвае бліжэйшы файл `error.js`.

| Статус      | Значэнне                                   | Што робіць праграма                                                                     |
| ----------- | ------------------------------------------ | --------------------------------------------------------------------------------------- |
| 401         | Ключ адсутнічае, няправільны або прыпынены | Функцыя кідае выключэнне, `error.js` адлюстроўваецца, прычына ў серверным логу.         |
| 402         | Тэрмін дзеяння плана скончыўся             | Той жа шлях. Прадоўжыце доступ на старонцы [цэны](https://amodb.dev/be/pricing/).       |
| 404         | Для гэтага id няма запісу                  | Функцыя вяртае `null`, а старонка выклікае `notFound()`.                                |
| 429         | Перавышаны ліміт запытаў у хвіліну         | Чакаць `Retry-After` секунд, не болей за 10, паўтарыць двойчы, потым кінуць выключэнне. |
| 500 або 504 | Збой на баку сервера                       | Функцыя кідае выключэнне і адлюстроўваецца `error.js`.                                  |

Мяжа памылак павінна быць кліенцкім кампанентам. У прадакшне Next.js хавае тэкст памылкі ад браўзера і друкуе яго ў серверным логу, таму адхілены ключ з’явіцца ў вашым тэрмінале, а не на старонцы. Стварыце `app/error.js` і `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>
  );
}
```

Старонка «не знойдзена» гэта адзін кампанент:

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

Старонка [памылкі](https://amodb.dev/be/errors/) пералічвае кожны код статусу, а [ліміты запытаў](https://amodb.dev/be/rate-limits/) тлумачаць загалоўкі.

## Крок 6: Кэшуйце праз revalidate

Значэнні `revalidate` паўтараюць тэрміны жыцця edge-кэшу, таму старонка ніколі не абнаўляецца хутчэй, чым мяняюцца даныя. Трапленне ў кэш даных Next.js ніколі не выклікае amodb, таму яно таксама не расходуе запыты ў хвіліну.

| Старонка         | `revalidate` у секундах | Тэрмін жыцця edge-кэшу |
| ---------------- | ----------------------- | ---------------------- |
| Галоўная, трэнды | 1800                    | 30 хвілін              |
| Пошук            | 600                     | 10 хвілін              |
| Дэталі фільма    | 21600                   | 6 гадзін               |

У галоўнай старонкі няма дынамічнага ўводу, таму `next build` рэндэрыць яе адзін раз і абнаўляе ў фоне кожныя 30 хвілін. Гэтая зборка выклікае amodb, таму `AMODB_KEY` трэба задаць і ў асяроддзі зборкі.

## Крок 7: Запусціце і праверце

```bash
npm run dev
```

Адкрыйце лакальны адрас, які друкуе Next.js. Галоўная старонка паказвае постэры, поле пошуку знаходзіць назвы, а постэр адкрывае старонку свайго фільма. Для запуску ў прадакшне выкарыстоўвайце `npm run build` і `npm run start`. Цяпер у праекце такія файлы:

| Файл                               | Роля                                                               |
| ---------------------------------- | ------------------------------------------------------------------ |
| `.env.local`                       | Ключ, толькі для сервера.                                          |
| `next.config.js`                   | Дазваляе `img.amodb.dev` для `next/image`.                         |
| `lib/amodb.js`                     | Дапаможная функцыя fetch з тайм-аўтам і апрацоўкай 401, 404 і 429. |
| `app/layout.js`                    | Каркас старонкі і форма пошуку.                                    |
| `app/page.js`                      | Спіс трэндаў.                                                      |
| `app/search/page.js`               | Вынікі пошуку.                                                     |
| `app/movie/[id]/page.js`           | Дэталі фільма з рэйтынгамі.                                        |
| `app/error.js`, `app/not-found.js` | Старонкі збою і адсутнага id.                                      |

## Наступныя крокі

- Дадайце функцыю `generateMetadata` на старонку фільма, каб кожная назва мела свой загаловак старонкі. Next.js паўторна выкарыстоўвае той жа выклік fetch.
- Паказвайце акцёраў праз `append_to_response=credits`, апісаны на старонцы [удзельнікі](https://amodb.dev/be/credits/).
- Пераключайце `language` на мову наведвальніка, выкарыстоўваючы 14 базавых моў са старонкі [мовы](https://amodb.dev/be/languages/).
- Тую ж задачу для тэлефона гл. у [падручніку па Swift](https://amodb.dev/be/swift-movie-api/).

## Пытанні і адказы

### Ці можа ключ API трапіць у браўзер у Next.js?

Толькі калі вы яго выставіце. Трымайце AMODB_KEY без прэфікса NEXT_PUBLIC_ і выклікайце amodb з серверных кампанентаў, апрацоўшчыкаў маршрутаў або серверных дзеянняў. Ніколі не імпартуйце дапаможную функцыю fetch у файл, які пачынаецца з use client.

### Чаму next/image патрабуе remotePatterns для постэраў?

Кампанент Image аптымізуе выявы толькі з дазволеных вамі хостаў. Дадайце img.amodb.dev з pathname /t/p/** у remotePatterns у next.config.js, інакш кампанент кіне памылку для кожнага URL постэра.

### Якім павінен быць revalidate для кожнай старонкі?

Выбірайце па edge-кэшы: 1800 секунд для спісаў трэндаў, 600 для пошуку і 21600 для дэталяў фільма. Меншае значэнне толькі дадае выклікі, што вяртаюць тыя ж даныя і расходуюць запыты ў хвіліну.

### Што адбываецца, калі amodb адказвае 429?

Дапаможная функцыя чытае Retry-After, чакае да 10 секунд і паўтарае двойчы. Калі ліміт усё яшчэ дзейнічае, яна кідае памылку, а error.js паказвае старонку з кнопкай паўтору замест зламанага спісу.

### Ці патрэбны TypeScript для гэтай праграмы?

Не. Кожны файл тут гэта чысты JavaScript з JSX у файлах .js. Магчымасці App Router працуюць гэтак жа, а файлы можна перавесці на TypeScript пазней, не змяняючы ніводнага выкліку amodb.

## Звязаныя старонкі

- [Падручнік па Swift і API фільмаў з SwiftUI](https://amodb.dev/be/swift-movie-api/): Праграма SwiftUI для iOS 16 са спісам трэндавых фільмаў і постэрамі праз URLSession async/await і AsyncImage, а таксама чаму ключ трэба трымаць на серверы.
- [Падручнік па JavaScript і API фільмаў](https://amodb.dev/be/javascript-movie-api/): Напішыце скрыпт Node.js, які загружае дэталі, акцёраў і рэйтынгі для спісу id фільмаў адным запытам на фільм, з TTL-кэшам, паўторам пры 429 і без залежнасцей.
- [API пошуку](https://amodb.dev/be/search/): API пошуку з шляхамі і JSON як у TMDB v3. Фільмы, серыялы, персоны, калекцыі, кампаніі і ключавыя словы па тэксце, з фільтрамі па годзе і старонкамі.
- [Эндпоінты трэндаў і спісаў](https://amodb.dev/be/trending-lists/): API трэндавых фільмаў з шляхамі TMDB v3: чарты за дзень і тыдзень, спісы зараз у пракаце, папулярных, лепшых і хутка ў пракаце, кэш 30 хвілін.
- [API выяў і хост выяў](https://amodb.dev/be/images-api/): API выяў фільмаў з шляхамі TMDB v3: спісы постэраў, фонавых выяў, лагатыпаў і кадраў назвы, а потым загрузка з img.amodb.dev у 10 памерах без ключа.
- [Аўтэнтыфікацыя](https://amodb.dev/be/authentication/): Як перадаваць ключ amodb: api_key у запыце, загаловак Authorization Bearer або apikey для выклікаў OMDb. Фармат ключа, захоўванне, замена і памылкі 401.

Абноўлена: 2026-10-18. Source: https://amodb.dev/be/nextjs-movie-app/
