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: 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:
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.
AMODB_KEY=YOUR_API_KEYKrok 2: Utwórz projekt
Utwórz package.json, zainstaluj pakiety i dodaj hosta obrazów do 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"
}
}Zainstaluj pakiety:
npm installPlakaty są na img.amodb.dev, a next/image ładuje tylko hosty, które wymienisz. Wzorzec pathname ogranicza go do ścieżki plakatów.
/** @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.
Ś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ę.
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();
}
}Krok 4: Zbuduj strony
Zacznij od layoutu. Zawiera formularz wyszukiwania, zwykły formularz GET, który wysyła q do /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>
);
}Siatka plakatów w components/ obsługuje stronę główną i stronę wyszukiwania. URL plakatu to https:/ plus wartość poster_path. Rozmiary zobacz w obrazy.
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.
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.
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_, 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.
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. |
| 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:
'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:
Strona błędy wymienia wszystkie kody statusu, a limity żądań wyjaśniają nagłówki.
export default function NotFound() {
return <p>That movie does not exist.</p>;
}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ź
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. |
npm run devKolejne 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_, opisane na stronie obsada i ekipa.to_ response= credits - Przełącz
languagena język odwiedzającego, korzystając z 14 języków bazowych na stronie języki. - Przeczytaj to samo zadanie dla telefonu w poradniku Swift.