Next.js movie app tutorial

This tutorial builds a three page movie site with Next.js 15 and the App Router in plain JavaScript. Server components call amodb with fetch and revalidate, the key lives only in a server environment variable, and next.config.js allows the img.amodb.dev poster host. It takes about 25 minutes.

.jsx Next.js Updated View as Markdown

What you build

You build a small movie site with three pages: a trending list on the home page, a search page and a movie page with IMDb, Kinopoisk and TMDB scores. It uses Next.js 15 with the App Router, which needs Node.js 18.18 or newer, and plain JavaScript. Server components call amodb, so the key never reaches the browser. Plan on about 25 minutes.

Step 1: Get an API key and keep it on the server

Pick a plan on pricing: 20, 40 or 120 requests per minute, with identical data. Create a key in your account dashboard, then put it in .env.local at the project root:

Do not prefix the name with NEXT_PUBLIC_. Next.js copies variables with that prefix into the browser bundle, and a plain name stays on the server. Restart the dev server after you change the file. The authentication page describes the header and query forms of the key.

Shell
AMODB_KEY=YOUR_API_KEY

Step 2: Create the project

Create package.json, install the packages and add the image host to 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"
  }
}

Install the packages:

Shell
npm install

Posters live on img.amodb.dev, and next/image only loads hosts you list. The pathname pattern limits it to the poster path.

JavaScript
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'img.amodb.dev', pathname: '/t/p/**' },
    ],
  },
};

module.exports = nextConfig;

Step 3: Write the fetch helper

Every page goes through one function in lib/amodb.js. It adds the Bearer header, a 10 second timeout and the revalidate option, which tells Next.js how long it may reuse the answer. Next.js 15 does not cache fetch by default, so the option is what turns the cache on.

The path starts with a slash and is added to the base, so /3 stays in the URL. A 404 returns null, so a page can call notFound(). The retry waits at most 10 seconds because a visitor is waiting for the page.

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();
  }
}

Step 4: Build the pages

Start with the layout. It holds the search form, a plain GET form that sends q to /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>
  );
}

The poster grid in components/MovieGrid.js serves the home page and the search page. A poster URL is https://img.amodb.dev/t/p/w342 plus the poster_path value. See images for the sizes.

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>
  );
}

The home page in app/page.js is an async server component. It reads this week's trending movies, described on trending and lists.

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>
  );
}

In Next.js 15, searchParams and params are promises, so each page awaits them. The search page in app/search/page.js uses the search endpoint.

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>
  );
}

The movie page in app/movie/[id]/page.js requests one movie with append_to_response=ratings, so details and scores arrive in a single call. Each source is { value, votes } or null. A null source means the score is not known, and a null votes hides the vote count, so the page says "not known" or prints only the value. For movie 550 it shows 8.8 (2,669,989 votes) for IMDb, 8.7 for Kinopoisk and 8.437 (33,039 votes) for 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>
  );
}

Step 5: Handle errors and rate limits

The helper throws on failures, and Next.js shows the closest error.js file.

StatusMeaningWhat the app does
401Key missing, wrong or suspendedThe helper throws, error.js renders, the cause is in the server log.
402The plan period has endedSame path. Renew on pricing.
404No record for that idThe helper returns null and the page calls notFound().
429Over your requests per minuteWait Retry-After seconds, capped at 10, retry twice, then throw.
500 or 504Server side failureThe helper throws and error.js renders.

An error boundary must be a client component. In production Next.js hides the error message from the browser and prints it in the server log, so a rejected key shows up in your terminal and not on the page. Create app/error.js and 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>
  );
}

The not-found page is one component:

The errors page lists every status code, and rate limits explains the headers.

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

Step 6: Cache with revalidate

The revalidate values copy the edge cache lifetimes, so a page never refreshes faster than the data changes. A hit in the Next.js data cache never calls amodb, so it also costs no requests per minute.

Pagerevalidate in secondsEdge cache lifetime
Home, trending180030 minutes
Search60010 minutes
Movie details216006 hours

The home page has no dynamic input, so next build renders it once and refreshes it in the background every 30 minutes. That build calls amodb, so AMODB_KEY must be set in the build environment too.

Step 7: Run and check

Open the local address that Next.js prints. The home page shows posters, the search box finds titles and a poster opens its movie page. For a production run use npm run build and npm run start. The project now has these files:

FileRole
.env.localThe key, server only.
next.config.jsAllows img.amodb.dev for next/image.
lib/amodb.jsFetch helper with timeout, 401, 404 and 429 handling.
app/layout.jsPage shell and search form.
app/page.jsTrending list.
app/search/page.jsSearch results.
app/movie/[id]/page.jsMovie details with ratings.
app/error.js, app/not-found.jsFailure and missing id pages.
Shell
npm run dev

Next steps

  • Add a generateMetadata function to the movie page so every title gets its own page title. Next.js reuses the same fetch call.
  • Show cast with append_to_response=credits, described on credits.
  • Switch language to a visitor's language, using the 14 base languages on languages.
  • Read the same task for a phone in the Swift tutorial.