Next.js 电影应用教程
用 Next.js 15 和 App Router 以纯 JavaScript 构建三页电影网站。服务器组件用 fetch 和 revalidate 调用 amodb,密钥只存于服务器环境变量,并放行海报主机 img.amodb.dev。约 25 分钟。
你将构建什么
你将构建一个有三个页面的小型电影网站:首页的热门列表、搜索页,以及带有 IMDb、Kinopoisk 和 TMDB 评分的电影页。它使用带 App Router 的 Next.js 15(需要 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,所以是这个选项把缓存打开的。
路径以斜杠开头并追加到基础地址之后,所以 URL 中保留 /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:构建页面
先从布局开始。它包含搜索表单,这是一个把 q 发送到 /search 的普通 GET 表单。
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/ 中的海报网格同时服务于首页和搜索页。海报 URL 是 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 是 Promise,所以每个页面都要 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,它显示 IMDb 的 8.8 (2,669,989 votes)、Kinopoisk 的 8.7 和 TMDB 的 8.437 (33,039 votes)。
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 | 该 ID 没有记录 | 辅助函数返回 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>
);
}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 | 允许 next/image 使用 img.amodb.dev。 |
lib/amodb.js | 带超时、401、404 和 429 处理的 fetch 辅助函数。 |
app/layout.js | 页面外壳和搜索表单。 |
app/page.js | 热门列表。 |
app/search/page.js | 搜索结果。 |
app/movie/[id]/page.js | 带评分的电影详情。 |
app/error.js、app/not-found.js | 失败页和 ID 缺失页。 |
npm run dev