# Next.js

> 在 Kanorio 上构建 Next.js App Router 前端，支持缓存读取、Webhook 重新验证、草稿模式、SEO 文件与边缘重定向。

> Markdown version of https://kanorio.com/zh-CN/developers/guides/nextjs. Append `.md` to any developer docs URL for this format. Index: https://kanorio.com/llms.txt · MCP: https://app.kanorio.com/api/mcp
>
> Translated from the English original: https://kanorio.com/en/developers/guides/nextjs.md

Next.js 缓存标签与 Kanorio 的缓存标签是绝佳搭档。页面从 Next.js 缓存进行渲染，当有人在 Kanorio 发布内容时，Webhook 会在几秒钟内重新验证所有使用该变更内容的页面。

本指南使用 App Router 和 Next.js 16，其中 middleware 称为 `proxy.ts`。

## 1. 环境变量

```bash
# .env.local
NEXT_PUBLIC_KANORIO_SITE_URL="https://s-xxxxxxxxxxxx.kanorio.com"

# server only
KANORIO_WEBHOOK_SECRET="whsec_..."
KANORIO_PREVIEW_TOKEN="knr_pv_..."
```

请使用来自 [网站设置 › 开发者](https://app.kanorio.com/settings/developer) 的永久系统域名。Webhook 密钥来自您在第 4 步创建的端点。预览令牌为选填，且仅在服务器端使用。

## 2. 共享的 fetch 辅助函数

`lib/kanorio.ts` 会为每次读取加上 Kanorio 的缓存标签，并在草稿模式开启时切换至草稿。标签必须在 fetch 前已知，因此请使用 SDK 的 `cacheTag` 辅助函数来创建它们（它们带有 Webhook 处理程序使用的 `kanorio:` 前缀）；响应本身的 `X-Kanorio-Cache-Tags` 标头会列出完整集合，包含面包屑祖先与展开的引用，适用于可在 fetch 后进行标记的框架。

```ts
// lib/kanorio.ts
import { draftMode } from "next/headers";
import { prefixCacheTags } from "@kanorio/sdk";

const SITE_URL = process.env.NEXT_PUBLIC_KANORIO_SITE_URL!;

export async function kanorio<T>(path: string, tags: string[]): Promise<T | null> {
  const { isEnabled: isDraft } = await draftMode();
  const url = new URL(`/api/v1${path}`, SITE_URL);
  if (isDraft) url.searchParams.set("preview", "1");

  const res = await fetch(
    url,
    isDraft
      ? { headers: { "X-Kanorio-Preview": process.env.KANORIO_PREVIEW_TOKEN! }, cache: "no-store" }
      : { next: { tags: prefixCacheTags(tags) } }
  );
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Kanorio ${res.status} for ${path}`);
  return (await res.json()) as T;
}
```

## 3. 渲染文章

```tsx
// app/blog/page.tsx
import Link from "next/link";
import { cacheTag } from "@kanorio/sdk";
import { kanorio } from "@/lib/kanorio";

type Post = { id: string; slug: string; fieldData: { title: string; excerpt: string | null } };

export default async function BlogIndex() {
  const page = await kanorio<{ data: Post[]; nextCursor: string | null }>(
    "/collections/blog/items?sort=-publishedAt&limit=20&fields=title,excerpt",
    [cacheTag.collection("blog")]
  );
  return (
    <ul>
      {page?.data.map((post) => (
        <li key={post.id}>
          <Link href={`/blog/${post.slug}`}>{post.fieldData.title}</Link>
        </li>
      ))}
    </ul>
  );
}
```

```tsx
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { cacheTag } from "@kanorio/sdk";
import { kanorio } from "@/lib/kanorio";

type Asset = { url: string; width: number | null; height: number | null; alt: string | null; placeholder?: string };
type PostResponse = {
  item: { id: string; fieldData: { title: string; cover_image: string | null }; contentHtml?: string };
  assets: Record<string, Asset>;
};

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  // Single items are tagged with the collection's item tag, not the list tag, so publishing another post does not evict this one
  const data = await kanorio<PostResponse>(
    `/collections/blog/items/${encodeURIComponent(slug)}?expand=authors,categories&formats=html`,
    [cacheTag.collectionItems("blog")]
  );
  if (!data) notFound();

  const { item, assets } = data;
  const cover = item.fieldData.cover_image ? assets[item.fieldData.cover_image] : undefined;
  return (
    <article>
      <h1>{item.fieldData.title}</h1>
      {cover && <img src={cover.url} alt={cover.alt ?? ""} width={cover.width ?? undefined} height={cover.height ?? undefined} />}
      {item.contentHtml && <div dangerouslySetInnerHTML={{ __html: item.contentHtml }} />}
    </article>
  );
}
```

## 4. 发布时重新验证

```ts
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { createRevalidateHandler } from "@kanorio/sdk";

export const POST = createRevalidateHandler({
  signingSecret: process.env.KANORIO_WEBHOOK_SECRET!,
  revalidateTag: (tag) => revalidateTag(tag),
});
```

在 [网站设置 › Webhooks](https://app.kanorio.com/settings/webhooks) 下注册 `https://www.example.com/api/revalidate`，并订阅 collection、page 和 localization 事件。处理程序会验证签名，并重新验证 `data.cacheTags` 中所有带有相同 `kanorio:` 前缀的标签。单篇文章上线会刷新该文章页面与所有语言的博客索引；翻译上线则仅刷新该语言。请参阅 [Webhooks](https://kanorio.com/zh-CN/developers/webhooks)。

## 5. 从后台进入草稿模式

在开发者设置中，将前端域名设为外部商店域名。后台的「查看网站」按钮随即会以带有短效预览令牌的 `?kp=` 打开您的前端。一个路由处理程序会将其转换为草稿模式会话：

```ts
// app/api/draft/route.ts
import { draftMode } from "next/headers";
import { createPreviewRouteHandler } from "@kanorio/sdk/preview";

export const GET = createPreviewRouteHandler({
  siteUrl: process.env.NEXT_PUBLIC_KANORIO_SITE_URL,
  enableDraftMode: async () => (await draftMode()).enable(),
});
```

它会使用 Kanorio 验证令牌，将其存储在 HttpOnly Cookie 中，防范开放重定向，并重定向至该页面。在 `proxy.ts` 中，将带有 `?kp=` 的请求发送至 `/api/draft?kp=…&redirect=<path>`。在 `lib/kanorio.ts` 中，您可以使用 `tokenFromCookieHeader` 从 Cookie 读取令牌，而非使用环境变量。当令牌过期时，API 会返回 `401`；请禁用草稿模式并回退至已发布内容。

## 6. SEO 文件、重定向与爬虫规则

从您自己的域名提供 sitemap、robots.txt、llms.txt 和 feeds：

```ts
// next.config.ts
import { seoRewrites } from "@kanorio/sdk";

export default {
  async rewrites() {
    return seoRewrites({ siteUrl: process.env.NEXT_PUBLIC_KANORIO_SITE_URL!, framework: "next" });
  },
};
```

在边缘层应用拥有者在后台设置的重定向与 AI 爬虫规则，并提供 IndexNow 密钥文件：

```ts
// proxy.ts
import type { NextFetchEvent, NextRequest } from "next/server";
import { createKanorioMiddleware } from "@kanorio/sdk/edge";

const kanorioEdge = createKanorioMiddleware({ siteUrl: process.env.NEXT_PUBLIC_KANORIO_SITE_URL! });

export function proxy(request: NextRequest, event: NextFetchEvent) {
  return kanorioEdge.handleRequest(request, event);
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
```

Middleware 每分钟会刷新规则，因此后台的变更无需重新部署即可生效。重定向规则会优先匹配精确路径，接着是长度最长的 `/*` 通配符前缀，与 Kanorio 托管网站的匹配方式相同。静态导出 (`output: "export"`) 无法运行 `proxy.ts`；请改在您的主机上设置重定向。

## 7. 语言

多语言网站会在根目录提供主要语言，并在拥有者选择的域名前缀下提供其他语言，例如 `/en/about`。`GET /api/v1/site` 会在 `locales[]` 中列出它们与 `pathPrefix`，且每次读取都接受 `locale`（代码或前缀）。未翻译的字段会回退至主要文本，响应会在 `resolvedLocale` 中说明服务内容，并在 `availableLocales` 中说明哪些语言有翻译。

`createKanorioMiddleware` 已处理边缘规则：第一个区段为主要语言代码的请求（如 `/zh-hant/about`）会被重定向至根路径，`/en/llms.txt`、`/en/llms-full.txt` 和 `/en/rss.xml` 会由 Content Delivery API 提供，且重定向规则会匹配不含前缀的路径。请使用与 Middleware 相同的辅助函数在路由中读取语言：

```tsx
// app/[[...path]]/page.tsx
import type { Metadata } from "next";
import { buildAlternates, requestLocaleOf } from "@kanorio/sdk";
import { kanorio } from "@/lib/kanorio";

const SITE_URL = "https://www.example.com";

type Site = { primaryLocale: string | null; locales: Array<{ locale: string; isPrimary: boolean; pathPrefix: string | null }> };
type PageResponse = {
  page: { id: string; slug: string; title: string; resolvedLocale?: string; availableLocales?: string[]; localePaths?: Record<string, string>; sections: unknown[] };
};

async function load(path: string[] | undefined) {
  const pathname = `/${(path ?? []).join("/")}`;
  const site = (await kanorio<Site>("/site", ["site"]))!;
  const { locale, pathname: path0 } = requestLocaleOf(pathname, site);
  const query = locale === site.primaryLocale ? "" : `?locale=${encodeURIComponent(locale)}`;
  const data = await kanorio<PageResponse>(`/pages${path0 === "/" ? "/home" : path0}${query}`, []);
  return { site, locale, path0, data };
}

export async function generateMetadata({ params }: { params: Promise<{ path?: string[] }> }): Promise<Metadata> {
  const { site, locale, path0, data } = await load((await params).path);
  if (!data) return {};
  const alternates = buildAlternates({
    siteUrl: SITE_URL,
    site,
    path: path0,
    requestLocale: locale,
    resolvedLocale: data.page.resolvedLocale,
    availableLocales: data.page.availableLocales,
    localePaths: data.page.localePaths,
  });
  return { title: data.page.title, alternates: { canonical: alternates.canonical, languages: alternates.languages } };
}
```

每种语言可拥有自己的页面别名，因此请将响应的 `localePaths` 传递给 `buildAlternates` 和 `languageSwitcher`。页面端点也接受其他语言的域名路径，并返回该要求语言下的 `page.slug`；当它与域名不同时，请调用 `permanentRedirect` 进行导向。

`buildAlternates` 遵循与托管网站相同的规则：每个语言版本皆为其自身的标准域名 (canonical)，仅列出有该页面翻译的语言，`x-default` 指向主要语言，而以回退模式（该语言尚未翻译）提供的页面会取得主要语言的标准域名且无 `hreflang`。`languageSwitcher({ siteUrl, site, path })` 会返回语言菜单的链接。请使用响应本身的缓存标签（带有 `@<locale>`）标记语言特定的 fetch，翻译上线时仅会刷新该语言。

## 8. 检查设置

```bash
npx @kanorio/cli doctor --site ws_example123
```

`doctor` 会检查 SEO 文件、JSON-LD、IndexNow 密钥、内容同步、页面浏览报告与 Webhooks。在多语言网站上，它还会检查每种语言的页面是否能在其前缀下通过翻译后的标题访问、`hreflang` 是否列出所有上线语言，以及 `/{prefix}/llms.txt` 是否已提供。

## 让令牌远离浏览器

公开读取无需令牌，草稿预览则在服务器端使用只读的 `knr_pv_` 令牌。切勿将 `knr_pat_` 令牌导入客户端组件或 `NEXT_PUBLIC_` 变量中。
