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.

.jsx Next.js Actualizat Vezi ca Markdown

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: 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:

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 descrie formele cheii în antet și în interogare.

Shell
AMODB_KEY=YOUR_API_KEY

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:

Shell
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.

JavaScript
/** @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.

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.

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

Pasul 4: construiește paginile

Începe cu layoutul. Conține formularul de căutare, un formular GET simplu care trimite q la /search.

JavaScript
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 pentru dimensiuni.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

StareSemnificațieCe face aplicația
401Cheie lipsă, greșită sau suspendatăHelperul aruncă, error.js se afișează, cauza e în logul serverului.
402Perioada planului s-a încheiatAceeași cale. Reînnoiește la prețuri.
404Nicio înregistrare pentru acel IDHelperul întoarce null, iar pagina apelează notFound().
429Ai depășit cererile pe minutAșteaptă Retry-After secunde, cu plafon de 10, reîncearcă de două ori, apoi aruncă.
500 sau 504Eroare pe partea serveruluiHelperul 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:

JavaScript
'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ă:

Pagina erori listează fiecare cod de stare, iar limite de cereri explică antetele.

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

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 secundeDurata cache-ului la margine
Principală, tendințe180030 de minute
Căutare60010 minute
Detalii film216006 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ă

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șierRol
.env.localCheia, doar pe server.
next.config.jsPermite img.amodb.dev pentru next/image.
lib/amodb.jsHelper de fetch cu timeout și tratarea lui 401, 404 și 429.
app/layout.jsCadrul paginii și formularul de căutare.
app/page.jsLista de tendințe.
app/search/page.jsRezultatele căutării.
app/movie/[id]/page.jsDetaliile filmului cu evaluări.
app/error.js, app/not-found.jsPagini pentru eșec și pentru ID lipsă.
Shell
npm run dev

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.
  • Schimbă language după limba vizitatorului, folosind cele 14 limbi de bază de la limbi.
  • Citește aceeași sarcină pentru un telefon în tutorialul Swift.