מדריך אפליקציית סרטים ב-Next.js

המדריך בונה אתר סרטים בן שלושה עמודים עם Next.js 15 ו-App Router ב-JavaScript רגיל. רכיבי שרת קוראים ל-amodb עם fetch ו-revalidate, המפתח חי רק במשתנה סביבה של השרת, ו-next.config.js מתיר את מארח הפוסטרים img.amodb.dev. שלושת העמודים הם סרטים פופולריים, חיפוש ופרטי סרט. זה לוקח כ-25 דקות.

.jsx Next.js עודכן צפייה כ-Markdown

מה בונים

בונים אתר סרטים קטן עם שלושה עמודים: רשימת טרנדים בעמוד הבית, עמוד חיפוש ועמוד סרט עם ציונים של 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 מעתיק משתנים עם הקידומת הזו לחבילה של הדפדפן, ושם רגיל נשאר בשרת. הפעילו מחדש את שרת הפיתוח אחרי שמשנים את הקובץ. עמוד אימות מתאר את צורות הכותרת והשאילתה של המפתח.

Shell
AMODB_KEY=YOUR_API_KEY

שלב 2: יצירת הפרויקט

צרו את package.json, התקינו את החבילות והוסיפו את מארח התמונות ל-next.config.js.

JSON
{
  "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"
  }
}

התקינו את החבילות:

Shell
npm install

פוסטרים נמצאים ב-img.amodb.dev, ו-next/image טוען רק מארחים שרשמתם. תבנית pathname מגבילה אותו לנתיב הפוסטרים.

JavaScript
/** @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 שניות לכל היותר, כי מבקר מחכה לעמוד.

JavaScript
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.

JavaScript
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/MovieGrid.js משרתת את עמוד הבית ואת עמוד החיפוש. כתובת פוסטר היא https://img.amodb.dev/t/p/w342 ועוד הערך של poster_path. הגדלים בעמוד תמונות.

JavaScript
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 הוא רכיב שרת אסינכרוני. הוא קורא את הסרטים הפופולריים של השבוע, כמתואר בעמוד טרנדים ורשימות.

JavaScript
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 משתמש בנקודת הקצה של חיפוש.

JavaScript
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_to_response=ratings, ולכן פרטים וציונים מגיעים בקריאה אחת. כל מקור הוא { value, votes } או null. מקור null אומר שהציון לא ידוע, ו-votes שהוא null מסתיר את מספר ההצבעות, ולכן העמוד כותב "לא ידוע" או מדפיס רק את הערך. עבור סרט 550 הוא מציג 8.8 (2,669,989 votes) עבור IMDb, 8.7 עבור Kinopoisk ו-8.437 (33,039 votes) עבור TMDB.

JavaScript
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:

JavaScript
'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 הוא רכיב אחד:

עמוד שגיאות מפרט כל קוד סטטוס, ומגבלות קצב מסביר את הכותרות.

JavaScript
export default function NotFound() {
  return <p>That movie does not exist.</p>;
}

שלב 6: מטמון עם revalidate

ערכי revalidate מעתיקים את משכי מטמון הקצה, ולכן עמוד אף פעם לא מתרענן מהר יותר ממה שהנתונים משתנים. פגיעה במטמון הנתונים של Next.js לא קוראת ל-amodb, ולכן גם לא צורכת בקשות לדקה.

עמודrevalidate בשניותמשך מטמון הקצה
בית, טרנדים180030 דקות
חיפוש60010 דקות
פרטי סרט216006 שעות

לעמוד הבית אין קלט דינמי, ולכן 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עמודי כשל ומזהה חסר.
Shell
npm run dev

הצעדים הבאים

  • הוסיפו פונקציית generateMetadata לעמוד הסרט כדי שלכל כותר תהיה כותרת עמוד משלו. Next.js משתמש שוב באותה קריאת fetch.
  • הציגו שחקנים עם append_to_response=credits, כמתואר בעמוד קרדיטים.
  • החליפו את language לשפת המבקר, עם 14 שפות הבסיס שבעמוד שפות.
  • קראו את אותה משימה לטלפון ב-מדריך Swift.