Урок за приложение за филми с Next.js
Този урок изгражда сайт за филми с три страници с Next.js 15 и App Router на чист JavaScript. Сървърните компоненти викат amodb с fetch и revalidate, ключът живее само в променлива на средата на сървъра, а next.config.js разрешава хоста за постери img.amodb.dev. Отнема около 25 минути.
Какво изграждате
Изграждате малък сайт за филми с три страници: списък с тенденции на началната страница, страница за търсене и страница на филм с оценки от IMDb, Kinopoisk и TMDB. Ползва Next.js 15 с App Router, който изисква Node.js 18.18 или по-нов, и чист JavaScript. Сървърните компоненти викат amodb, така че ключът никога не стига до браузъра. Отделете около 25 минути.
Стъпка 1: Вземете API ключ и го дръжте на сървъра
Изберете план на страницата с цените: 20, 40 или 120 заявки в минута, с еднакви данни. Създайте ключ в личния си профил, после го сложете в .env.local в корена на проекта:
Не слагайте префикс NEXT_PUBLIC_ на името. Next.js копира променливите с този префикс в пакета за браузъра, а обикновеното име остава на сървъра. Рестартирайте сървъра за разработка, след като промените файла. Страницата удостоверяване описва формите на ключа в заглавка и в заявка.
AMODB_KEY=YOUR_API_KEYСтъпка 2: Създайте проекта
Създайте package.json, инсталирайте пакетите и добавете хоста за изображения в 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"
}
}Инсталирайте пакетите:
npm installПостерите живеят на img.amodb.dev, а next/image зарежда само хостове, които изброите. Шаблонът pathname го ограничава до пътя на постерите.
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'img.amodb.dev', pathname: '/t/p/**' },
],
},
};
module.exports = nextConfig;Стъпка 3: Напишете помощната функция за fetch
Всяка страница минава през една функция в lib/amodb.js. Тя добавя заглавката Bearer, timeout от 10 секунди и опцията revalidate, която казва на Next.js колко дълго може да ползва отговора повторно. Next.js 15 не кешира fetch по подразбиране, така че опцията е това, което включва кеша.
Пътят започва с наклонена черта и се добавя към основата, така че /3 остава в URL адреса. 404 връща null, за да може страницата да извика notFound(). Повторението чака най-много 10 секунди, защото посетителят чака страницата.
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();
}
}Стъпка 4: Построете страниците
Започнете с шаблона. Той съдържа формата за търсене, обикновена форма GET, която изпраща q към /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>
);
}Мрежата от постери в components/ обслужва началната страница и страницата за търсене. URL адресът на постер е https:/ плюс стойността на poster_path. Вижте изображения за размерите.
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>
);
}Началната страница в app/page.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>
);
}В Next.js 15 searchParams и params са promise, така че всяка страница ги изчаква с await. Страницата за търсене в app/search/page.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>
);
}Страницата на филм в app/movie/[id]/page.js иска един филм с append_, така че детайлите и оценките идват с едно извикване. Всеки източник е { value, votes } или null. Източник null означава, че оценката не е известна, а null във votes скрива броя гласове, така че страницата пише „not known“ или отпечатва само стойността. За филм 550 показва 8.8 (2,669,989 votes) за IMDb, 8.7 за Kinopoisk и 8.437 (33,039 votes) за 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>
);
}Стъпка 5: Обработка на грешки и лимити
Помощната функция хвърля при неуспех, а Next.js показва най-близкия файл error.js.
| Статус | Значение | Какво прави приложението |
|---|---|---|
| 401 | Ключът липсва, е грешен или е спрян | Функцията хвърля, error.js се визуализира, причината е в сървърния лог. |
| 402 | Периодът на плана е изтекъл | Същият път. Подновете от страницата с цените. |
| 404 | Няма запис за това id | Функцията връща null, а страницата вика notFound(). |
| 429 | Надхвърлени заявки в минута | Чака Retry-After секунди, най-много 10, повтаря два пъти, после хвърля. |
| 500 или 504 | Грешка от страна на сървъра | Функцията хвърля и error.js се визуализира. |
Границата за грешки трябва да е клиентски компонент. В продукционна среда Next.js скрива текста на грешката от браузъра и го печата в сървърния лог, така че отхвърлен ключ се вижда в терминала ви, а не на страницата. Създайте app/error.js и 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>
);
}Страницата „не е намерено“ е един компонент:
Страницата грешки изброява всеки статус код, а лимити на заявките обяснява заглавките.
export default function NotFound() {
return <p>That movie does not exist.</p>;
}Стъпка 6: Кеширайте с revalidate
Стойностите на revalidate повтарят срока на живот на edge кеша, така че страница никога не се обновява по-бързо, отколкото се променят данните. Попадение в кеша на данните на Next.js никога не вика amodb, така че също не харчи заявки в минута.
| Страница | revalidate в секунди | Срок на живот на edge кеша |
|---|---|---|
| Начална, тенденции | 1800 | 30 минути |
| Търсене | 600 | 10 минути |
| Детайли на филм | 21600 | 6 часа |
Началната страница няма динамичен вход, така че next build я визуализира веднъж и я обновява на заден план на всеки 30 минути. Това изграждане вика amodb, затова AMODB_KEY трябва да е зададен и в средата за изграждане.
Стъпка 7: Стартирайте и проверете
Отворете локалния адрес, който Next.js отпечатва. Началната страница показва постери, полето за търсене намира заглавия, а постер отваря страницата на своя филм. За продукционно пускане ползвайте npm run build и npm run start. Сега проектът има тези файлове:
| Файл | Роля |
|---|---|
.env.local | Ключът, само за сървъра. |
next.config.js | Разрешава img.amodb.dev за next/image. |
lib/amodb.js | Помощна функция за fetch с timeout и обработка на 401, 404 и 429. |
app/layout.js | Обвивка на страницата и форма за търсене. |
app/page.js | Списък с тенденции. |
app/search/page.js | Резултати от търсене. |
app/movie/[id]/page.js | Детайли на филм с рейтинги. |
app/error.js, app/not-found.js | Страници за неуспех и липсващо id. |
npm run devСледващи стъпки
- Добавете функция
generateMetadataкъм страницата на филм, за да получи всяко заглавие собствено заглавие на страница. Next.js ползва повторно същото извикване на fetch. - Показвайте актьорите с
append_, описано на страницата участници.to_ response= credits - Превключвайте
languageкъм езика на посетителя, като ползвате 14-те основни езика от страницата езици. - Същата задача за телефон прочетете в урока за Swift.