# Урок за приложение за филми с 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/bg/pricing/): 20, 40 или 120 заявки в минута, с еднакви данни. Създайте ключ в личния си профил, после го сложете в `.env.local` в корена на проекта:

```bash
AMODB_KEY=YOUR_API_KEY
```

Не слагайте префикс `NEXT_PUBLIC_` на името. Next.js копира променливите с този префикс в пакета за браузъра, а обикновеното име остава на сървъра. Рестартирайте сървъра за разработка, след като промените файла. Страницата [удостоверяване](https://amodb.dev/bg/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, timeout от 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/bg/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/bg/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` са promise, така че всяка страница ги изчаква с await. Страницата за търсене в `app/search/page.js` ползва ендпойнта за [търсене](https://amodb.dev/bg/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/bg/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/bg/errors/) изброява всеки статус код, а [лимити на заявките](https://amodb.dev/bg/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 с timeout и обработка на 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/bg/credits/).
- Превключвайте `language` към езика на посетителя, като ползвате 14-те основни езика от страницата [езици](https://amodb.dev/bg/languages/).
- Същата задача за телефон прочетете в [урока за Swift](https://amodb.dev/bg/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/bg/swift-movie-api/): SwiftUI приложение за iOS 16 със списък на филми в тенденция и постери през URLSession async/await и AsyncImage, и защо ключът трябва да стои на сървър.
- [Урок за JavaScript и API за филми](https://amodb.dev/bg/javascript-movie-api/): Напишете Node.js скрипт, който зарежда детайли, актьори и рейтинги за списък с id на филми с по една заявка, с TTL кеш, повторение при 429 и без зависимости.
- [API за търсене](https://amodb.dev/bg/search/): API за търсене със същите пътища и JSON като TMDB v3. Намира филми, сериали, личности, колекции, компании и ключови думи по текст, с филтър по година.
- [Ендпойнти за тенденции и списъци](https://amodb.dev/bg/trending-lists/): API за филми в тенденция с пътища на TMDB v3: класации за ден и седмица, списъци в кината, популярни, най-добри и предстоящи, кеш 30 минути.
- [API за изображения и хост за изображения](https://amodb.dev/bg/images-api/): API за изображения на филми с пътища на TMDB v3: списъци с постери, фонови изображения, лога и кадри, после зареждане от img.amodb.dev в 10 размера без ключ.
- [Удостоверяване](https://amodb.dev/bg/authentication/): Как да изпратите ключа си за amodb: api_key в заявката, заглавка Authorization с Bearer или apikey за OMDb. Формат, съхранение, смяна на ключ и грешките 401.

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