מדריך אפליקציית סרטים ב-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, פסק זמן של 10 שניות ואת האפשרות revalidate, שאומרת ל-Next.js עד מתי מותר לו להשתמש שוב בתשובה. Next.js 15 לא שומר fetch במטמון כברירת מחדל, ולכן האפשרות הזו היא שמפעילה את המטמון.
הנתיב מתחיל בלוכסן ומתווסף לבסיס, ולכן /3 נשאר בכתובת. תגובת 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/ משרתת את עמוד הבית ואת עמוד החיפוש. כתובת פוסטר היא 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 הם promises, ולכן כל עמוד ממתין להם עם 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 אומר שהציון לא ידוע, ו-votes שהוא null מסתיר את מספר ההצבעות, ולכן העמוד כותב "לא ידוע" או מדפיס רק את הערך. עבור סרט 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 | אין רשומה למזהה הזה | פונקציית העזר מחזירה 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>
);
}עמוד ה-not-found הוא רכיב אחד:
עמוד שגיאות מפרט כל קוד סטטוס, ומגבלות קצב מסביר את הכותרות.
export default function NotFound() {
return <p>That movie does not exist.</p>;
}שלב 6: מטמון עם revalidate
ערכי revalidate מעתיקים את משכי מטמון הקצה, ולכן עמוד אף פעם לא מתרענן מהר יותר ממה שהנתונים משתנים. פגיעה במטמון הנתונים של Next.js לא קוראת ל-amodb, ולכן גם לא צורכת בקשות לדקה.
| עמוד | revalidate בשניות | משך מטמון הקצה |
|---|---|---|
| בית, טרנדים | 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 עם פסק זמן וטיפול ב-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 | עמודי כשל ומזהה חסר. |
npm run devהצעדים הבאים
- הוסיפו פונקציית
generateMetadataלעמוד הסרט כדי שלכל כותר תהיה כותרת עמוד משלו. Next.js משתמש שוב באותה קריאת fetch. - הציגו שחקנים עם
append_, כמתואר בעמוד קרדיטים.to_ response= credits - החליפו את
languageלשפת המבקר, עם 14 שפות הבסיס שבעמוד שפות. - קראו את אותה משימה לטלפון ב-מדריך Swift.