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

## 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](https://amodb.dev/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:

```bash
AMODB_KEY=YOUR_API_KEY
```

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](https://amodb.dev/authentication/) page describes the header and query forms of the 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:

```bash
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.

```js
/** @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.

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

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.

## Step 4: Build the pages

Start with the layout. It holds the search form, a plain `GET` form that sends `q` to `/search`.

```js
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](https://amodb.dev/images-api/) for the sizes.

```js
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](https://amodb.dev/trending-lists/).

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

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](https://amodb.dev/search/) endpoint.

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

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.

```js
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.

| Status     | Meaning                         | What the app does                                                      |
| ---------- | ------------------------------- | ---------------------------------------------------------------------- |
| 401        | Key missing, wrong or suspended | The helper throws, `error.js` renders, the cause is in the server log. |
| 402        | The plan period has ended       | Same path. Renew on [pricing](https://amodb.dev/pricing/).             |
| 404        | No record for that id           | The helper returns `null` and the page calls `notFound()`.             |
| 429        | Over your requests per minute   | Wait `Retry-After` seconds, capped at 10, retry twice, then throw.     |
| 500 or 504 | Server side failure             | The 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`:

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

The not-found page is one component:

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

The [errors](https://amodb.dev/errors/) page lists every status code, and [rate limits](https://amodb.dev/rate-limits/) explains the headers.

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

| Page           | `revalidate` in seconds | Edge cache lifetime |
| -------------- | ----------------------- | ------------------- |
| Home, trending | 1800                    | 30 minutes          |
| Search         | 600                     | 10 minutes          |
| Movie details  | 21600                   | 6 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

```bash
npm run dev
```

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:

| File                               | Role                                                  |
| ---------------------------------- | ----------------------------------------------------- |
| `.env.local`                       | The key, server only.                                 |
| `next.config.js`                   | Allows `img.amodb.dev` for `next/image`.              |
| `lib/amodb.js`                     | Fetch helper with timeout, 401, 404 and 429 handling. |
| `app/layout.js`                    | Page shell and search form.                           |
| `app/page.js`                      | Trending list.                                        |
| `app/search/page.js`               | Search results.                                       |
| `app/movie/[id]/page.js`           | Movie details with ratings.                           |
| `app/error.js`, `app/not-found.js` | Failure and missing id pages.                         |

## 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](https://amodb.dev/credits/).
- Switch `language` to a visitor's language, using the 14 base languages on [languages](https://amodb.dev/languages/).
- Read the same task for a phone in the [Swift tutorial](https://amodb.dev/swift-movie-api/).

## Questions and answers

### Can the API key leak to the browser in Next.js?

Only if you expose it. Keep AMODB_KEY without the NEXT_PUBLIC_ prefix and call amodb from server components, route handlers or server actions. Never import the fetch helper into a file that starts with use client.

### Why does next/image need remotePatterns for the posters?

The Image component only optimizes images from hosts you allow. Add img.amodb.dev with the pathname /t/p/** to remotePatterns in next.config.js, or the component throws an error for every poster URL.

### How long should revalidate be for each page?

Match the edge cache: 1800 seconds for trending lists, 600 for search and 21600 for movie details. A shorter value only adds calls that return the same data and spend requests per minute.

### What happens when amodb answers 429?

The helper reads Retry-After, waits up to 10 seconds and retries twice. If the limit still applies it throws, and error.js renders a page with a retry button instead of a broken list.

### Do I need TypeScript for this app?

No. Every file here is plain JavaScript with JSX in .js files. The App Router features work the same way, and you can convert files to TypeScript later without changing any amodb call.

## Related pages

- [Swift movie API tutorial with SwiftUI](https://amodb.dev/swift-movie-api/): Build a SwiftUI app for iOS 16 that lists trending movies with posters using URLSession async/await and AsyncImage, and learn why the key belongs on a server.
- [JavaScript movie API tutorial](https://amodb.dev/javascript-movie-api/): Write a Node.js script that loads details, credits and ratings for a list of movie ids in one request each, with a TTL cache, 429 retry and no dependencies.
- [Search API](https://amodb.dev/search/): Search API with the same paths and JSON as TMDB v3. Find movies, TV shows, people, collections, companies and keywords by text, with year filters and paging.
- [Trending and list endpoints](https://amodb.dev/trending-lists/): Trending movies API with TMDB v3 paths: day and week charts, plus now playing, popular, top rated and upcoming lists for movies and TV, cached for 30 minutes.
- [Images API and image host](https://amodb.dev/images-api/): Movie images API with TMDB v3 paths: list posters, backdrops, logos and stills per title, then load them from img.amodb.dev in 10 sizes with no key.
- [Authentication](https://amodb.dev/authentication/): How to send your amodb key: api_key in the query, an Authorization Bearer header, or apikey for OMDb calls. Key format, storage, rotation and the 401 errors.

Updated: 2026-10-18. Source: https://amodb.dev/nextjs-movie-app/
