# 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 密钥并把它留在服务器上

在[定价](https://amodb.dev/zh/pricing/)页选择套餐：每分钟 20、40 或 120 个请求，数据相同。在账户后台创建密钥，然后把它放进项目根目录的 `.env.local`：

```bash
AMODB_KEY=YOUR_API_KEY
```

不要给名称加上 `NEXT_PUBLIC_` 前缀。Next.js 会把带此前缀的变量复制到浏览器端的包中，而普通名称只留在服务器上。修改文件后请重启开发服务器。[认证](https://amodb.dev/zh/authentication/)页介绍了密钥的请求头形式和查询参数形式。

## 步骤 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"
  }
}
```

安装依赖包：

```bash
npm install
```

海报位于 `img.amodb.dev`，而 `next/image` 只加载你列出的主机。`pathname` 模式把范围限制在海报路径。

```js
/** @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`，所以是这个选项把缓存打开的。

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

路径以斜杠开头并追加到基础地址之后，所以 URL 中保留 `/3`。404 会返回 `null`，这样页面可以调用 `notFound()`。重试最多等待 10 秒，因为访客正在等待页面。

## 步骤 4：构建页面

先从布局开始。它包含搜索表单，这是一个把 `q` 发送到 `/search` 的普通 `GET` 表单。

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

`components/MovieGrid.js` 中的海报网格同时服务于首页和搜索页。海报 URL 是 `https://img.amodb.dev/t/p/w342` 加上 `poster_path` 的值。尺寸见[图片](https://amodb.dev/zh/images-api/)。

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

`app/page.js` 中的首页是一个异步服务器组件。它读取本周的热门电影，详见[热门趋势与列表](https://amodb.dev/zh/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>
  );
}
```

在 Next.js 15 中，`searchParams` 和 `params` 是 Promise，所以每个页面都要 await 它们。`app/search/page.js` 中的搜索页使用[搜索](https://amodb.dev/zh/search/)接口。

```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_to_response=ratings` 请求一部电影，所以详情和评分在一次调用中到达。每个来源是 `{ value, votes }` 或 `null`。来源为 `null` 表示该评分未知，而 `votes` 为 `null` 时则不显示投票数，所以页面显示“未知”，或只打印数值。对于电影 550，它显示 IMDb 的 `8.8 (2,669,989 votes)`、Kinopoisk 的 `8.7` 和 TMDB 的 `8.437 (33,039 votes)`。

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

## 步骤 5：处理错误和速率限制

辅助函数在失败时抛出错误，Next.js 会显示最近的 `error.js` 文件。

| 状态        | 含义          | 应用的做法                                           |
| --------- | ----------- | ----------------------------------------------- |
| 401       | 密钥缺失、错误或已停用 | 辅助函数抛出错误，渲染 `error.js`，原因记录在服务器日志中。             |
| 402       | 套餐周期已结束     | 同样的路径。请在[定价](https://amodb.dev/zh/pricing/)页续费。 |
| 404       | 该 ID 没有记录   | 辅助函数返回 `null`，页面调用 `notFound()`。                |
| 429       | 超过每分钟请求数    | 等待 `Retry-After` 秒（上限 10），重试两次，然后抛出错误。          |
| 500 或 504 | 服务端故障       | 辅助函数抛出错误并渲染 `error.js`。                         |

错误边界必须是客户端组件。在生产环境中，Next.js 会向浏览器隐藏错误消息，只在服务器日志中打印，所以被拒绝的密钥会出现在你的终端里而不是页面上。创建 `app/error.js` 和 `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>
  );
}
```

not-found 页面只是一个组件：

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

[错误](https://amodb.dev/zh/errors/)页列出所有状态码，[速率限制](https://amodb.dev/zh/rate-limits/)说明了响应头。

## 步骤 6：用 revalidate 做缓存

`revalidate` 的取值照搬了边缘缓存的有效期，所以页面刷新的频率不会超过数据变化的频率。命中 Next.js 数据缓存时不会调用 amodb，所以也不消耗每分钟请求数。

| 页面    | `revalidate`（秒） | 边缘缓存有效期 |
| ----- | --------------- | ------- |
| 首页，热门 | 1800            | 30 分钟   |
| 搜索    | 600             | 10 分钟   |
| 电影详情  | 21600           | 6 小时    |

首页没有动态输入，所以 `next build` 会渲染一次，并每 30 分钟在后台刷新。该构建会调用 amodb，所以构建环境中也必须设置 `AMODB_KEY`。

## 步骤 7：运行并检查

```bash
npm run dev
```

打开 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 缺失页。                        |

## 下一步

- 给电影页添加 `generateMetadata` 函数，让每个片名都有自己的页面标题。Next.js 会复用同一个 fetch 调用。
- 用 `append_to_response=credits` 显示演员，详见[演职员](https://amodb.dev/zh/credits/)。
- 把 `language` 切换为访客的语言，可用的 14 种基础语言见[语言](https://amodb.dev/zh/languages/)。
- 在 [Swift 教程](https://amodb.dev/zh/swift-movie-api/)中阅读面向手机的同一个任务。

## 常见问题

### Next.js 中 API 密钥会泄露到浏览器吗？

只有在你把它暴露出去时才会。让 AMODB_KEY 不带 NEXT_PUBLIC_ 前缀，并从服务器组件、路由处理器或服务器操作中调用 amodb。绝不要把 fetch 辅助函数导入以 use client 开头的文件。

### 为什么 next/image 显示海报需要 remotePatterns？

Image 组件只会优化来自你允许的主机的图片。请在 next.config.js 的 remotePatterns 中添加 img.amodb.dev，路径名为 /t/p/**，否则组件会对每个海报 URL 抛出错误。

### 每个页面的 revalidate 应该设多久？

与边缘缓存保持一致：热门列表 1800 秒，搜索 600 秒，电影详情 21600 秒。更短的值只会增加返回相同数据的调用，并消耗每分钟请求数。

### amodb 返回 429 时会怎样？

辅助函数读取 Retry-After，最多等待 10 秒并重试两次。如果限制仍然存在，它就抛出错误，由 error.js 渲染带有重试按钮的页面，而不是一个损坏的列表。

### 这个应用需要 TypeScript 吗？

不需要。这里的每个文件都是纯 JavaScript，JSX 写在 .js 文件中。App Router 的功能用法完全相同，你以后可以把文件转换为 TypeScript，而无需改动任何 amodb 调用。

## 相关页面

- [使用 SwiftUI 的 Swift 电影 API 教程](https://amodb.dev/zh/swift-movie-api/): 用 URLSession 和 AsyncImage 构建 iOS 16 的 SwiftUI 应用，列出带海报的热门电影，并说明密钥为何放在服务器上。
- [JavaScript 电影 API 教程](https://amodb.dev/zh/javascript-movie-api/): 编写 Node.js 脚本，对一组电影 ID 各用一次请求加载详情、演职员和评分，带 TTL 缓存、429 重试且无依赖。
- [搜索 API](https://amodb.dev/zh/search/): 路径和 JSON 与 TMDB v3 相同的搜索 API。按文本查找电影、电视剧、人物、系列合集、公司和关键词，支持年份筛选和分页。
- [热门与榜单接口](https://amodb.dev/zh/trending-lists/): 兼容 TMDB v3 路径的热门电影 API：日榜与周榜，以及电影和电视剧的正在上映、热门、高分、即将上映榜单，缓存 30 分钟。
- [图片 API 与图片主机](https://amodb.dev/zh/images-api/): 采用 TMDB v3 路径的电影图片 API：列出作品的海报、背景图、标志和剧照，再从 img.amodb.dev 以 10 种尺寸无密钥加载。
- [认证](https://amodb.dev/zh/authentication/): 如何发送 amodb 密钥：查询中的 api_key、Bearer 请求头，或 OMDb 的 apikey。含密钥格式、保管和 401 错误。

更新于: 2026-10-18. Source: https://amodb.dev/zh/nextjs-movie-app/
