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: 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.
AMODB_KEY=YOUR_API_KEYStep 2: Create the project
Create package.json, install the packages and add the image host to 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"
}
}Install the packages:
npm installPosters live on img.amodb.dev, and next/image only loads hosts you list. The pathname pattern limits it to the poster path.
/** @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.
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.
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/ serves the home page and the search page. A poster URL is https:/ plus the poster_path value. See images for the sizes.
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.
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.
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_, 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.
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. |
| 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:
'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.
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.
| 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
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. |
npm run devNext steps
- Add a
generateMetadatafunction to the movie page so every title gets its own page title. Next.js reuses the same fetch call. - Show cast with
append_, described on credits.to_ response= credits - Switch
languageto a visitor's language, using the 14 base languages on languages. - Read the same task for a phone in the Swift tutorial.