# Poradnik aplikacji filmowej w Next.js

> Ten poradnik buduje trzystronicowy serwis filmowy w Next.js 15 z App Routerem w czystym JavaScript. Komponenty serwerowe wołają amodb przez fetch i revalidate, klucz żyje tylko w zmiennej środowiskowej serwera, a next.config.js zezwala na hosta plakatów img.amodb.dev. Zajmuje około 25 minut.

## Co zbudujesz

Zbudujesz mały serwis filmowy z trzema stronami: lista trendów na stronie głównej, strona wyszukiwania i strona filmu z wynikami IMDb, Kinopoisk i TMDB. Używa Next.js 15 z App Routerem, który wymaga Node.js 18.18 lub nowszego, oraz czystego JavaScript. Komponenty serwerowe wołają amodb, więc klucz nigdy nie trafia do przeglądarki. Zaplanuj około 25 minut.

## Krok 1: Zdobądź klucz API i trzymaj go na serwerze

Wybierz plan na stronie [cennik](https://amodb.dev/pl/pricing/): 20, 40 lub 120 żądań na minutę, z identycznymi danymi. Utwórz klucz w panelu konta, a potem umieść go w `.env.local` w katalogu głównym projektu:

```bash
AMODB_KEY=YOUR_API_KEY
```

Nie dodawaj do nazwy przedrostka `NEXT_PUBLIC_`. Next.js kopiuje zmienne z tym przedrostkiem do paczki dla przeglądarki, a zwykła nazwa zostaje na serwerze. Po zmianie pliku zrestartuj serwer deweloperski. Formy klucza w nagłówku i w zapytaniu opisuje strona [uwierzytelnianie](https://amodb.dev/pl/authentication/).

## Krok 2: Utwórz projekt

Utwórz `package.json`, zainstaluj pakiety i dodaj hosta obrazów do `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"
  }
}
```

Zainstaluj pakiety:

```bash
npm install
```

Plakaty są na `img.amodb.dev`, a `next/image` ładuje tylko hosty, które wymienisz. Wzorzec `pathname` ogranicza go do ścieżki plakatów.

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

module.exports = nextConfig;
```

## Krok 3: Napisz funkcję pomocniczą fetch

Każda strona przechodzi przez jedną funkcję w `lib/amodb.js`. Dodaje nagłówek Bearer, 10-sekundowy timeout i opcję `revalidate`, która mówi Next.js, jak długo może ponownie użyć odpowiedzi. Next.js 15 domyślnie nie zapisuje `fetch` w cache, więc to ta opcja włącza 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();
  }
}
```

Ścieżka zaczyna się od ukośnika i jest dodawana do bazy, więc `/3` zostaje w URL. 404 zwraca `null`, dzięki czemu strona może wywołać `notFound()`. Ponawianie czeka najwyżej 10 sekund, bo odwiedzający czeka na stronę.

## Krok 4: Zbuduj strony

Zacznij od layoutu. Zawiera formularz wyszukiwania, zwykły formularz `GET`, który wysyła `q` do `/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>
  );
}
```

Siatka plakatów w `components/MovieGrid.js` obsługuje stronę główną i stronę wyszukiwania. URL plakatu to `https://img.amodb.dev/t/p/w342` plus wartość `poster_path`. Rozmiary zobacz w [obrazy](https://amodb.dev/pl/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>
  );
}
```

Strona główna w `app/page.js` to asynchroniczny komponent serwerowy. Czyta filmy będące w tym tygodniu w trendach, opisane na stronie [trendy i listy](https://amodb.dev/pl/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>
  );
}
```

W Next.js 15 `searchParams` i `params` są obietnicami (promises), więc każda strona na nie czeka (await). Strona wyszukiwania w `app/search/page.js` używa endpointu [wyszukiwanie](https://amodb.dev/pl/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>
  );
}
```

Strona filmu w `app/movie/[id]/page.js` żąda jednego filmu z `append_to_response=ratings`, więc szczegóły i wyniki przychodzą jednym wywołaniem. Każde źródło to `{ value, votes }` albo `null`. Źródło `null` oznacza, że wynik nie jest znany, a `votes` równe `null` ukrywa liczbę głosów, więc strona pisze „not known” albo wypisuje samą wartość. Dla filmu 550 pokazuje `8.8 (2,669,989 votes)` dla IMDb, `8.7` dla Kinopoisk i `8.437 (33,039 votes)` dla 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>
  );
}
```

## Krok 5: Obsłuż błędy i limity żądań

Funkcja pomocnicza rzuca błąd przy awariach, a Next.js pokazuje najbliższy plik `error.js`.

| Status      | Znaczenie                               | Co robi aplikacja                                                                       |
| ----------- | --------------------------------------- | --------------------------------------------------------------------------------------- |
| 401         | Brak klucza, jest błędny lub zawieszony | Funkcja pomocnicza rzuca błąd, renderuje się `error.js`, przyczyna jest w logu serwera. |
| 402         | Okres planu dobiegł końca               | Ta sama ścieżka. Odnów go na stronie [cennik](https://amodb.dev/pl/pricing/).           |
| 404         | Brak rekordu dla tego ID                | Funkcja pomocnicza zwraca `null`, a strona wywołuje `notFound()`.                       |
| 429         | Przekroczono żądania na minutę          | Czeka `Retry-After` sekund, maksymalnie 10, ponawia dwa razy, a potem rzuca błąd.       |
| 500 lub 504 | Awaria po stronie serwera               | Funkcja pomocnicza rzuca błąd i renderuje się `error.js`.                               |

Granica błędu musi być komponentem klienckim. W produkcji Next.js ukrywa komunikat błędu przed przeglądarką i wypisuje go w logu serwera, więc odrzucony klucz zobaczysz w terminalu, a nie na stronie. Utwórz `app/error.js` i `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>
  );
}
```

Strona not-found to jeden komponent:

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

Strona [błędy](https://amodb.dev/pl/errors/) wymienia wszystkie kody statusu, a [limity żądań](https://amodb.dev/pl/rate-limits/) wyjaśniają nagłówki.

## Krok 6: Cache z revalidate

Wartości `revalidate` kopiują czasy życia cache na edge, więc strona nigdy nie odświeża się szybciej, niż zmieniają się dane. Trafienie w pamięci danych Next.js nigdy nie wywołuje amodb, więc nie kosztuje też żądań na minutę.

| Strona                | `revalidate` w sekundach | Czas życia cache na edge |
| --------------------- | ------------------------ | ------------------------ |
| Strona główna, trendy | 1800                     | 30 minut                 |
| Wyszukiwanie          | 600                      | 10 minut                 |
| Szczegóły filmu       | 21600                    | 6 godzin                 |

Strona główna nie ma dynamicznych danych wejściowych, więc `next build` renderuje ją raz i odświeża w tle co 30 minut. Ten build wywołuje amodb, więc `AMODB_KEY` musi być ustawiony także w środowisku builda.

## Krok 7: Uruchom i sprawdź

```bash
npm run dev
```

Otwórz lokalny adres, który wypisze Next.js. Strona główna pokazuje plakaty, pole wyszukiwania znajduje tytuły, a plakat otwiera stronę swojego filmu. Do uruchomienia produkcyjnego użyj `npm run build` i `npm run start`. Projekt ma teraz te pliki:

| Plik                               | Rola                                                           |
| ---------------------------------- | -------------------------------------------------------------- |
| `.env.local`                       | Klucz, tylko na serwerze.                                      |
| `next.config.js`                   | Zezwala na `img.amodb.dev` dla `next/image`.                   |
| `lib/amodb.js`                     | Funkcja pomocnicza fetch z timeoutem i obsługą 401, 404 i 429. |
| `app/layout.js`                    | Powłoka strony i formularz wyszukiwania.                       |
| `app/page.js`                      | Lista trendów.                                                 |
| `app/search/page.js`               | Wyniki wyszukiwania.                                           |
| `app/movie/[id]/page.js`           | Szczegóły filmu z ocenami.                                     |
| `app/error.js`, `app/not-found.js` | Strony awarii i brakującego ID.                                |

## Kolejne kroki

- Dodaj do strony filmu funkcję `generateMetadata`, by każdy tytuł miał własny tytuł strony. Next.js użyje ponownie tego samego wywołania fetch.
- Pokaż obsadę przez `append_to_response=credits`, opisane na stronie [obsada i ekipa](https://amodb.dev/pl/credits/).
- Przełącz `language` na język odwiedzającego, korzystając z 14 języków bazowych na stronie [języki](https://amodb.dev/pl/languages/).
- Przeczytaj to samo zadanie dla telefonu w [poradniku Swift](https://amodb.dev/pl/swift-movie-api/).

## Pytania i odpowiedzi

### Czy klucz API może wyciec do przeglądarki w Next.js?

Tylko jeśli go ujawnisz. Trzymaj AMODB_KEY bez przedrostka NEXT_PUBLIC_ i wywołuj amodb z komponentów serwerowych, route handlerów lub server actions. Nigdy nie importuj funkcji pomocniczej fetch do pliku zaczynającego się od use client.

### Dlaczego next/image potrzebuje remotePatterns dla plakatów?

Komponent Image optymalizuje tylko obrazy z hostów, na które zezwolisz. Dodaj img.amodb.dev z pathname /t/p/** do remotePatterns w next.config.js, inaczej komponent rzuca błąd dla każdego URL plakatu.

### Jak długi ma być revalidate dla każdej strony?

Dopasuj do cache na edge: 1800 sekund dla list trendów, 600 dla wyszukiwania i 21600 dla szczegółów filmu. Krótsza wartość tylko dodaje wywołania, które zwracają te same dane i zużywają żądania na minutę.

### Co się dzieje, gdy amodb odpowiada 429?

Funkcja pomocnicza czyta Retry-After, czeka do 10 sekund i ponawia dwa razy. Jeśli limit nadal obowiązuje, rzuca błąd, a error.js renderuje stronę z przyciskiem ponowienia zamiast zepsutej listy.

### Czy potrzebuję TypeScript do tej aplikacji?

Nie. Każdy plik tutaj to czysty JavaScript z JSX w plikach .js. Funkcje App Routera działają tak samo, a pliki możesz później przekonwertować na TypeScript bez zmieniania żadnego wywołania amodb.

## Powiązane strony

- [Poradnik API filmów w Swift z SwiftUI](https://amodb.dev/pl/swift-movie-api/): Zbuduj aplikację SwiftUI na iOS 16 z filmami w trendach i plakatami przez URLSession async/await i AsyncImage oraz dowiedz się, czemu klucz należy na serwer.
- [Poradnik API filmów w JavaScript](https://amodb.dev/pl/javascript-movie-api/): Napisz skrypt Node.js, który jednym żądaniem na ID pobiera szczegóły, obsadę i oceny filmów, z cache z TTL, ponawianiem po 429 i bez zależności.
- [API wyszukiwania](https://amodb.dev/pl/search/): API wyszukiwania z tymi samymi ścieżkami i JSON co TMDB v3. Znajdź filmy, seriale, osoby, kolekcje, firmy i słowa kluczowe po tekście, z filtrem roku.
- [Trendy i listy](https://amodb.dev/pl/trending-lists/): API trendów filmowych ze ścieżkami TMDB v3: wykresy dzienne i tygodniowe oraz listy now playing, popular, top rated i upcoming, w cache 30 minut.
- [API obrazów i host obrazów](https://amodb.dev/pl/images-api/): API obrazów filmów ze ścieżkami TMDB v3: wypisz plakaty, tła, loga i kadry każdego tytułu, a potem ładuj je z img.amodb.dev w 10 rozmiarach bez klucza.
- [Uwierzytelnianie](https://amodb.dev/pl/authentication/): Jak wysłać klucz amodb: api_key w zapytaniu, nagłówek Authorization Bearer albo apikey dla wywołań OMDb. Format klucza, przechowywanie, rotacja i błędy 401.

Zaktualizowano: 2026-10-18. Source: https://amodb.dev/pl/nextjs-movie-app/
