# Посібник із застосунку фільмів на 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/uk/pricing/): 20, 40 або 120 запитів на хвилину, з однаковими даними. Створіть ключ у панелі облікового запису, потім покладіть його в `.env.local` у корені проєкту:

```bash
AMODB_KEY=YOUR_API_KEY
```

Не додавайте до назви префікс `NEXT_PUBLIC_`. Next.js копіює змінні з цим префіксом у пакет для браузера, а звичайна назва лишається на сервері. Перезапустіть сервер розробки після зміни файлу. Сторінка [автентифікація](https://amodb.dev/uk/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`. Розміри дивіться на сторінці [API зображень](https://amodb.dev/uk/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/uk/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/uk/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/uk/pricing/).      |
| 404         | Немає запису для цього id                | Допоміжна функція повертає `null`, а сторінка викликає `notFound()`.                   |
| 429         | Перевищено ваші запити на хвилину        | Зачекайте `Retry-After` секунд, не більше 10, повторіть двічі, потім викиньте помилку. |
| 500 або 504 | Збій на боці сервера                     | Допоміжна функція викидає помилку, показується `error.js`.                             |

Межа помилок має бути клієнтським компонентом. У production 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>
  );
}
```

Сторінка not-found це один компонент:

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

Сторінка [помилки](https://amodb.dev/uk/errors/) перелічує всі коди статусів, а [ліміти запитів](https://amodb.dev/uk/rate-limits/) пояснюють заголовки.

## Крок 6: кешування через revalidate

Значення `revalidate` повторюють терміни життя кешу на периферії, тож сторінка ніколи не оновлюється швидше, ніж змінюються дані. Влучання в кеш даних Next.js ніколи не викликає amodb, тож також не витрачає запити на хвилину.

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

Головна сторінка не має динамічного вводу, тож `next build` рендерить її один раз і оновлює у фоні кожні 30 хвилин. Ця збірка викликає amodb, тому `AMODB_KEY` має бути заданий і в середовищі збірки.

## Крок 7: запустіть і перевірте

```bash
npm run dev
```

Відкрийте локальну адресу, яку виводить Next.js. Головна сторінка показує постери, поле пошуку знаходить назви, а клік на постер відкриває сторінку фільму. Для запуску в production використовуйте `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/uk/credits/).
- Перемкніть `language` на мову відвідувача, використовуючи 14 базових мов зі сторінки [мови](https://amodb.dev/uk/languages/).
- Прочитайте те саме завдання для телефона в [посібнику зі Swift](https://amodb.dev/uk/swift-movie-api/).

## Запитання й відповіді

### Чи може ключ API витекти в браузер у Next.js?

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

### Навіщо next/image потрібен remotePatterns для постерів?

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

### Яким має бути revalidate для кожної сторінки?

Відповідайте кешу на периферії: 1800 секунд для списків трендів, 600 для пошуку й 21600 для деталей фільму. Менше значення лише додає виклики, що повертають ті самі дані та витрачають запити на хвилину.

### Що буде, коли amodb відповість 429?

Допоміжна функція читає Retry-After, чекає до 10 секунд і повторює двічі. Якщо ліміт усе ще діє, вона викидає помилку, а error.js показує сторінку з кнопкою повтору замість зламаного списку.

### Чи потрібен TypeScript для цього застосунку?

Ні. Усі файли тут це звичайний JavaScript із JSX у файлах .js. Можливості App Router працюють так само, а пізніше ви можете перевести файли на TypeScript без змін у жодному виклику amodb.

## Пов'язані сторінки

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

Оновлено: 2026-10-18. Source: https://amodb.dev/uk/nextjs-movie-app/
