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: 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.
AMODB_KEY=YOUR_API_KEYPasul 2: creează proiectul
Creează package.json, instalează pachetele și adaugă gazda de imagini în next.config.js.
{
"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:
npm installPosterele se află pe img.amodb.dev, iar next/image încarcă doar gazdele pe care le listezi. Modelul pathname îl limitează la calea posterelor.
/** @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.
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.
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/ servește pagina principală și pagina de căutare. Un URL de poster este https:/ plus valoarea poster_path. Vezi imagini pentru dimensiuni.
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.
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.
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_, 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.
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. |
| 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:
'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.
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 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ă
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ă. |
npm run devPașii următori
- Adaugă o funcție
generateMetadatala pagina de film, ca fiecare titlu să aibă propriul titlu de pagină. Next.js reutilizează același apel fetch. - Arată distribuția cu
append_, descris la credite.to_ response= credits - Schimbă
languagedupă limba vizitatorului, folosind cele 14 limbi de bază de la limbi. - Citește aceeași sarcină pentru un telefon în tutorialul Swift.