# Руководство по Next.js movie app

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

```bash
AMODB_KEY=YOUR_API_KEY
```

Не добавляйте к имени префикс `NEXT_PUBLIC_`. Next.js копирует переменные с таким префиксом в браузерный бандл, а обычное имя остается на сервере. Перезапустите dev-сервер после изменения файла. На странице [аутентификация](https://amodb.dev/ru/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();
  }
}
```

Путь начинается со слеша и добавляется к базовому URL, поэтому `/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/ru/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/ru/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/ru/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/ru/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/ru/errors/) перечисляет все коды статуса, а [лимиты запросов](https://amodb.dev/ru/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. На главной видны постеры, поле поиска находит названия, а постер открывает страницу фильма. Для 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/ru/credits/).
- Переключайте `language` на язык посетителя, используя 14 базовых языков со страницы [языки](https://amodb.dev/ru/languages/).
- Прочитайте ту же задачу для телефона в [руководстве по Swift](https://amodb.dev/ru/swift-movie-api/).

## Вопросы и ответы

### Может ли ключ API попасть в браузер в Next.js?

Только если вы сами его откроете. Храните AMODB_KEY без префикса NEXT_PUBLIC_ и вызывайте amodb из серверных компонентов, route handlers или server actions. Никогда не импортируйте хелпер fetch в файл, который начинается с use client.

### Зачем next/image нужен remotePatterns для постеров?

Компонент Image оптимизирует изображения только с разрешенных хостов. Добавьте img.amodb.dev с путем /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 movie API с SwiftUI](https://amodb.dev/ru/swift-movie-api/): Создайте приложение SwiftUI для iOS 16 со списком трендов и постерами на URLSession async/await и AsyncImage, и поймите, почему ключ API хранят на сервере.
- [Руководство по JavaScript movie API](https://amodb.dev/ru/javascript-movie-api/): Напишите скрипт Node.js: по списку id фильмов он загружает детали, актеров и рейтинги одним запросом на фильм, с кэшем TTL, повтором при 429, без зависимостей.
- [API поиска](https://amodb.dev/ru/search/): API поиска с путями и JSON как в TMDB v3. Находите фильмы, сериалы, людей, коллекции, компании и ключевые слова по тексту, с фильтром по году и пагинацией.
- [Эндпоинты трендов и списков](https://amodb.dev/ru/trending-lists/): API фильмов в тренде с путями TMDB v3: чарты за день и неделю, а также списки идущих в кино, популярных, лучших и скорых фильмов и сериалов, кэш 30 минут.
- [API изображений и хост изображений](https://amodb.dev/ru/images-api/): API изображений фильмов с путями TMDB v3: списки постеров, фонов, логотипов и кадров для каждого проекта, загрузка с img.amodb.dev в 10 размерах без ключа.
- [Аутентификация](https://amodb.dev/ru/authentication/): Как передать ключ amodb: api_key в запросе, заголовок Authorization Bearer или apikey для вызовов OMDb. Формат ключа, хранение, ротация и ошибки 401.

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