Tutorial app film con Next.js
Questo tutorial costruisce un sito di film di tre pagine con Next.js 15 e l'App Router in JavaScript. I server component chiamano amodb con fetch e revalidate, la chiave vive solo in una variabile d'ambiente del server, e next.config.js consente l'host delle locandine img.amodb.dev. Richiede circa 25 minuti.
Cosa costruisci
Costruisci un piccolo sito di film con tre pagine: una lista di tendenze nella home, una pagina di ricerca e una pagina del film con i punteggi IMDb, Kinopoisk e TMDB. Usa Next.js 15 con l'App Router, che richiede Node.js 18.18 o successivo, e JavaScript semplice. I server component chiamano amodb, quindi la chiave non arriva mai al browser. Calcola circa 25 minuti.
Passaggio 1: ottieni una chiave API e tienila sul server
Scegli un piano nella pagina dei prezzi: 20, 40 o 120 richieste al minuto, con dati identici. Crea una chiave nella dashboard del tuo account, poi mettila in .env.local nella radice del progetto:
Non aggiungere il prefisso NEXT_PUBLIC_ al nome. Next.js copia nel bundle del browser le variabili con quel prefisso, mentre un nome semplice resta sul server. Riavvia il server di sviluppo dopo aver modificato il file. La pagina autenticazione descrive le forme della chiave come header e come parametro di query.
AMODB_KEY=YOUR_API_KEYPassaggio 2: crea il progetto
Crea package.json, installa i pacchetti e aggiungi l'host delle immagini a 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"
}
}Installa i pacchetti:
npm installLe locandine stanno su img.amodb.dev, e next/image carica solo gli host che elenchi. Il pattern pathname lo limita al percorso delle locandine.
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'img.amodb.dev', pathname: '/t/p/**' },
],
},
};
module.exports = nextConfig;Passaggio 3: scrivi l'helper fetch
Ogni pagina passa da una sola funzione in lib/amodb.js. Aggiunge l'header Bearer, un timeout di 10 secondi e l'opzione revalidate, che dice a Next.js per quanto tempo può riusare la risposta. Next.js 15 non mette fetch in cache per impostazione predefinita, quindi è questa opzione ad attivare la cache.
Il percorso inizia con una barra e viene aggiunto alla base, quindi /3 resta nell'URL. Un 404 restituisce null, così una pagina può chiamare notFound(). Il nuovo tentativo attende al massimo 10 secondi perché un visitatore sta aspettando la 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();
}
}Passaggio 4: costruisci le pagine
Inizia dal layout. Contiene il modulo di ricerca, un semplice form GET che invia q a /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>
);
}La griglia di locandine in components/ serve la home e la pagina di ricerca. L'URL di una locandina è https:/ più il valore di poster_path. Vedi immagini per le dimensioni.
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>
);
}La home in app/page.js è un server component asincrono. Legge i film di tendenza della settimana, descritti nella pagina tendenze e 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>
);
}In Next.js 15, searchParams e params sono promise, quindi ogni pagina le attende con await. La pagina di ricerca in app/search/page.js usa l'endpoint di ricerca.
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>
);
}La pagina del film in app/movie/[id]/page.js richiede un film con append_, così dettagli e punteggi arrivano in un'unica chiamata. Ogni fonte è { value, votes } oppure null. Una fonte null significa che il punteggio non è noto, e un votes a null nasconde il numero di voti, quindi la pagina scrive «non noto» o stampa solo il valore. Per il film 550 mostra 8.8 (2,669,989 votes) per IMDb, 8.7 per Kinopoisk e 8.437 (33,039 votes) per 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>
);
}Passaggio 5: gestisci errori e limiti di richieste
L'helper solleva un'eccezione in caso di errore, e Next.js mostra il file error.js più vicino.
| Stato | Significato | Cosa fa l'app |
|---|---|---|
| 401 | Chiave mancante, sbagliata o sospesa | L'helper solleva un'eccezione, error.js si mostra, la causa è nel log del server. |
| 402 | Il periodo del piano è terminato | Stesso percorso. Rinnova nella pagina dei prezzi. |
| 404 | Nessun record per quell'ID | L'helper restituisce null e la pagina chiama notFound(). |
| 429 | Oltre le tue richieste al minuto | Attende i secondi di Retry-After, con un massimo di 10, ritenta due volte, poi solleva un'eccezione. |
| 500 o 504 | Errore lato server | L'helper solleva un'eccezione e error.js si mostra. |
Un error boundary deve essere un client component. In produzione Next.js nasconde il messaggio di errore al browser e lo scrive nel log del server, quindi una chiave rifiutata compare nel tuo terminale e non nella pagina. Crea app/error.js e 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>
);
}La pagina non trovata è un solo componente:
La pagina errori elenca tutti i codici di stato, e limiti di richieste spiega gli header.
export default function NotFound() {
return <p>That movie does not exist.</p>;
}Passaggio 6: usa la cache con revalidate
I valori di revalidate copiano i tempi di vita della cache edge, quindi una pagina non si aggiorna mai più in fretta di quanto cambino i dati. Un hit nella data cache di Next.js non chiama mai amodb, quindi non costa nemmeno richieste al minuto.
| Pagina | revalidate in secondi | Durata della cache edge |
|---|---|---|
| Home, tendenze | 1800 | 30 minuti |
| Ricerca | 600 | 10 minuti |
| Dettagli del film | 21600 | 6 ore |
La home non ha input dinamici, quindi next build la genera una volta e la aggiorna in background ogni 30 minuti. Quella build chiama amodb, quindi AMODB_KEY deve essere impostata anche nell'ambiente di build.
Passaggio 7: avvia e verifica
Apri l'indirizzo locale che stampa Next.js. La home mostra le locandine, la casella di ricerca trova i titoli e una locandina apre la sua pagina del film. Per un'esecuzione in produzione usa npm run build e npm run start. Il progetto ora ha questi file:
| File | Ruolo |
|---|---|
.env.local | La chiave, solo sul server. |
next.config.js | Consente img.amodb.dev per next/image. |
lib/amodb.js | Helper fetch con timeout e gestione di 401, 404 e 429. |
app/layout.js | Struttura della pagina e modulo di ricerca. |
app/page.js | Lista delle tendenze. |
app/search/page.js | Risultati di ricerca. |
app/movie/[id]/page.js | Dettagli del film con le valutazioni. |
app/error.js, app/not-found.js | Pagine di errore e di ID mancante. |
npm run devProssimi passi
- Aggiungi una funzione
generateMetadataalla pagina del film così ogni titolo ha il proprio titolo di pagina. Next.js riusa la stessa chiamata fetch. - Mostra il cast con
append_, descritto nella pagina crediti.to_ response= credits - Passa
languagealla lingua del visitatore, usando le 14 lingue di base elencate nella pagina lingue. - Leggi lo stesso compito per un telefono nel tutorial Swift.