# Next.js

> 在 Kanorio 上建構 Next.js App Router 前端，支援快取讀取、Webhook 重新驗證、草稿模式、SEO 檔案與邊緣重新導向。

> Markdown version of https://kanorio.com/zh-Hant/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-Hant/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_` 變數中。
