# Tutorial aplicație de filme Next.js

> Acest tutorial construiește un site de filme cu trei pagini în Next.js 15 cu App Router, în JavaScript simplu. Server components apelează amodb cu fetch și revalidate, cheia stă doar într-o variabilă de mediu de pe server, iar next.config.js permite gazda de postere img.amodb.dev. Durează cam 25 de minute.

## Ce construiești

Construiești un mic site de filme cu trei pagini: o listă de tendințe pe pagina principală, o pagină de căutare și o pagină de film cu scorurile IMDb, Kinopoisk și TMDB. Folosește Next.js 15 cu App Router, care cere Node.js 18.18 sau mai nou, și JavaScript simplu. Server components apelează amodb, deci cheia nu ajunge niciodată în browser. Socotește cam 25 de minute.

## Pasul 1: obține o cheie API și ține-o pe server

Alege un plan la [prețuri](https://amodb.dev/ro/pricing/): 20, 40 sau 120 de cereri pe minut, cu date identice. Creează o cheie în panoul contului tău, apoi pune-o în `.env.local` la rădăcina proiectului:

```bash
AMODB_KEY=YOUR_API_KEY
```

Nu pune prefixul `NEXT_PUBLIC_` în nume. Next.js copiază variabilele cu acel prefix în pachetul din browser, iar un nume simplu rămâne pe server. Repornește serverul de dezvoltare după ce schimbi fișierul. Pagina [autentificare](https://amodb.dev/ro/authentication/) descrie formele cheii în antet și în interogare.

## Pasul 2: creează proiectul

Creează `package.json`, instalează pachetele și adaugă gazda de imagini în `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"
  }
}
```

Instalează pachetele:

```bash
npm install
```

Posterele se află pe `img.amodb.dev`, iar `next/image` încarcă doar gazdele pe care le listezi. Modelul `pathname` îl limitează la calea posterelor.

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

module.exports = nextConfig;
```

## Pasul 3: scrie helperul de fetch

Fiecare pagină trece printr-o singură funcție din `lib/amodb.js`. Ea adaugă antetul Bearer, un timeout de 10 secunde și opțiunea `revalidate`, care îi spune lui Next.js cât timp poate reutiliza răspunsul. Next.js 15 nu pune `fetch` în cache implicit, deci opțiunea este cea care pornește cache-ul.

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

Calea începe cu o bară oblică și se adaugă la bază, deci `/3` rămâne în URL. Un 404 întoarce `null`, ca o pagină să poată apela `notFound()`. Reîncercarea așteaptă cel mult 10 secunde pentru că un vizitator așteaptă pagina.

## Pasul 4: construiește paginile

Începe cu layoutul. Conține formularul de căutare, un formular `GET` simplu care trimite `q` la `/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>
  );
}
```

Grila de postere din `components/MovieGrid.js` servește pagina principală și pagina de căutare. Un URL de poster este `https://img.amodb.dev/t/p/w342` plus valoarea `poster_path`. Vezi [imagini](https://amodb.dev/ro/images-api/) pentru dimensiuni.

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

Pagina principală din `app/page.js` este un server component async. Citește filmele în tendințe ale săptămânii, descrise la [tendințe și liste](https://amodb.dev/ro/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>
  );
}
```

În Next.js 15, `searchParams` și `params` sunt promisiuni, deci fiecare pagină le așteaptă cu await. Pagina de căutare din `app/search/page.js` folosește endpoint-ul de [căutare](https://amodb.dev/ro/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>
  );
}
```

Pagina de film din `app/movie/[id]/page.js` cere un film cu `append_to_response=ratings`, deci detaliile și scorurile sosesc într-un singur apel. Fiecare sursă este `{ value, votes }` sau `null`. O sursă `null` înseamnă că scorul nu se cunoaște, iar un `votes` `null` ascunde numărul de voturi, așa că pagina spune „not known” sau afișează doar valoarea. Pentru filmul 550 arată `8.8 (2,669,989 votes)` pentru IMDb, `8.7` pentru Kinopoisk și `8.437 (33,039 votes)` pentru 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>
  );
}
```

## Pasul 5: tratează erorile și limitele de cereri

Helperul aruncă la eșec, iar Next.js afișează cel mai apropiat fișier `error.js`.

| Stare       | Semnificație                        | Ce face aplicația                                                                     |
| ----------- | ----------------------------------- | ------------------------------------------------------------------------------------- |
| 401         | Cheie lipsă, greșită sau suspendată | Helperul aruncă, `error.js` se afișează, cauza e în logul serverului.                 |
| 402         | Perioada planului s-a încheiat      | Aceeași cale. Reînnoiește la [prețuri](https://amodb.dev/ro/pricing/).                |
| 404         | Nicio înregistrare pentru acel ID   | Helperul întoarce `null`, iar pagina apelează `notFound()`.                           |
| 429         | Ai depășit cererile pe minut        | Așteaptă `Retry-After` secunde, cu plafon de 10, reîncearcă de două ori, apoi aruncă. |
| 500 sau 504 | Eroare pe partea serverului         | Helperul aruncă, iar `error.js` se afișează.                                          |

O limită de eroare trebuie să fie un client component. În producție Next.js ascunde mesajul de eroare față de browser și îl afișează în logul serverului, deci o cheie respinsă apare în terminalul tău și nu pe pagină. Creează `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>
  );
}
```

Pagina not-found este o singură componentă:

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

Pagina [erori](https://amodb.dev/ro/errors/) listează fiecare cod de stare, iar [limite de cereri](https://amodb.dev/ro/rate-limits/) explică antetele.

## Pasul 6: cache cu revalidate

Valorile `revalidate` copiază duratele cache-ului la margine, deci o pagină nu se reîmprospătează niciodată mai repede decât se schimbă datele. O lovitură în cache-ul de date Next.js nu apelează niciodată amodb, deci nu costă nici cereri pe minut.

| Pagină               | `revalidate` în secunde | Durata cache-ului la margine |
| -------------------- | ----------------------- | ---------------------------- |
| Principală, tendințe | 1800                    | 30 de minute                 |
| Căutare              | 600                     | 10 minute                    |
| Detalii film         | 21600                   | 6 ore                        |

Pagina principală nu are intrare dinamică, deci `next build` o randează o dată și o reîmprospătează în fundal la fiecare 30 de minute. Acel build apelează amodb, deci `AMODB_KEY` trebuie setat și în mediul de build.

## Pasul 7: rulează și verifică

```bash
npm run dev
```

Deschide adresa locală pe care o afișează Next.js. Pagina principală arată postere, caseta de căutare găsește titluri, iar un poster deschide pagina filmului. Pentru o rulare în producție folosește `npm run build` și `npm run start`. Proiectul are acum aceste fișiere:

| Fișier                             | Rol                                                         |
| ---------------------------------- | ----------------------------------------------------------- |
| `.env.local`                       | Cheia, doar pe server.                                      |
| `next.config.js`                   | Permite `img.amodb.dev` pentru `next/image`.                |
| `lib/amodb.js`                     | Helper de fetch cu timeout și tratarea lui 401, 404 și 429. |
| `app/layout.js`                    | Cadrul paginii și formularul de căutare.                    |
| `app/page.js`                      | Lista de tendințe.                                          |
| `app/search/page.js`               | Rezultatele căutării.                                       |
| `app/movie/[id]/page.js`           | Detaliile filmului cu evaluări.                             |
| `app/error.js`, `app/not-found.js` | Pagini pentru eșec și pentru ID lipsă.                      |

## Pașii următori

- Adaugă o funcție `generateMetadata` la pagina de film, ca fiecare titlu să aibă propriul titlu de pagină. Next.js reutilizează același apel fetch.
- Arată distribuția cu `append_to_response=credits`, descris la [credite](https://amodb.dev/ro/credits/).
- Schimbă `language` după limba vizitatorului, folosind cele 14 limbi de bază de la [limbi](https://amodb.dev/ro/languages/).
- Citește aceeași sarcină pentru un telefon în [tutorialul Swift](https://amodb.dev/ro/swift-movie-api/).

## Întrebări și răspunsuri

### Poate scurge cheia API către browser în Next.js?

Doar dacă o expui. Păstrează AMODB_KEY fără prefixul NEXT_PUBLIC_ și apelează amodb din server components, route handlers sau server actions. Nu importa niciodată helperul fetch într-un fișier care începe cu use client.

### De ce are nevoie next/image de remotePatterns pentru postere?

Componenta Image optimizează doar imaginile de pe gazdele pe care le permiți. Adaugă img.amodb.dev cu pathname /t/p/** în remotePatterns din next.config.js, altfel componenta aruncă o eroare pentru fiecare URL de poster.

### Cât de mare ar trebui să fie revalidate pentru fiecare pagină?

Potrivește-l cu cache-ul la margine: 1800 de secunde pentru listele în tendințe, 600 pentru căutare și 21600 pentru detaliile filmelor. O valoare mai mică doar adaugă apeluri care întorc aceleași date și consumă cereri pe minut.

### Ce se întâmplă când amodb răspunde 429?

Helperul citește Retry-After, așteaptă până la 10 secunde și reîncearcă de două ori. Dacă limita se aplică în continuare, aruncă o eroare, iar error.js afișează o pagină cu un buton de reîncercare în loc de o listă stricată.

### Am nevoie de TypeScript pentru această aplicație?

Nu. Fiecare fișier de aici este JavaScript simplu, cu JSX în fișiere .js. Funcțiile App Router merg la fel, iar fișierele pot fi convertite în TypeScript mai târziu fără a schimba niciun apel amodb.

## Pagini conexe

- [Tutorial API filme Swift cu SwiftUI](https://amodb.dev/ro/swift-movie-api/): Aplicație SwiftUI pentru iOS 16 cu filme în tendințe și postere prin URLSession async/await și AsyncImage, plus motivul pentru care cheia aparține unui server.
- [Tutorial API filme JavaScript](https://amodb.dev/ro/javascript-movie-api/): Scrie un script Node.js care încarcă detalii, credite și evaluări pentru o listă de filme, o cerere pe film, cu cache TTL, reîncercare la 429, fără dependențe.
- [API căutare](https://amodb.dev/ro/search/): API căutare cu căile și JSON-ul TMDB v3. Găsește filme, seriale, persoane, colecții, companii și cuvinte-cheie după text, cu filtre de an și paginare.
- [Endpoint-uri pentru tendințe și liste](https://amodb.dev/ro/trending-lists/): API filme în tendințe cu căi TMDB v3: topuri pe zi și săptămână, plus now playing, populare, bine cotate și viitoare pentru filme și TV, în cache 30 de minute.
- [API imagini și gazdă de imagini](https://amodb.dev/ro/images-api/): API imagini filme cu căi TMDB v3: listează postere, imagini de fundal, logouri și cadre, apoi încarcă-le de la img.amodb.dev în 10 dimensiuni, fără cheie.
- [Autentificare](https://amodb.dev/ro/authentication/): Cum trimiți cheia amodb: api_key în interogare, antetul Authorization Bearer sau apikey pentru apelurile OMDb. Format, stocare, rotire și erorile 401.

Actualizat: 2026-10-18. Source: https://amodb.dev/ro/nextjs-movie-app/
