# Next.js

> Next.js App RouterフロントエンドをKanorioで構築し、キャッシュされた読み取り、Webhook再検証、ドラフトモード、SEOファイル、エッジリダイレクトを活用しましょう。

> Markdown version of https://kanorio.com/ja/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を使用します。ミドルウェアは `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) から永続的なシステムURLを使用してください。Webhookシークレットは、ステップ4で作成するエンドポイントから取得します。プレビュー用トークンは任意であり、サーバー上でのみ使用されます。

## 2. 共有フェッチヘルパー

`lib/kanorio.ts` はすべての読み取りにKanorioのキャッシュタグを付与し、ドラフトモードが有効な場合はドラフトに切り替えます。タグはフェッチ前に判明している必要があるため、SDKの `cacheTag` ヘルパーを使用して構築します（これらにはWebhookハンドラーが使用する `kanorio:` プレフィックスが付与されます）。レスポンス独自の `X-Kanorio-Cache-Tags` ヘッダーには、フェッチ後にタグ付けが可能なフレームワークのために、パンくずリストの祖先や展開された参照を含む完全なセットがリストされています。

```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` を登録し、コレクション、ページ、ローカライズイベントをサブスクライブします。ハンドラーは署名を検証し、`data.cacheTags` 内の同じ `kanorio:` プレフィックスを持つすべてのタグを再検証します。1つの投稿が公開されると、その投稿のページとすべてのロケールのブログインデックスが更新されます。翻訳が公開されると、そのロケールのみが更新されます。[Webhooks](https://kanorio.com/ja/developers/webhooks) を参照してください。

## 5. 管理画面からのドラフトモード

開発者設定で、フロントエンドのURLを外部ストアフロントURLとして設定します。管理画面の **サイトを表示** ボタンをクリックすると、`?kp=` に短期間有効なプレビュー用トークンが含まれた状態でフロントエンドが開きます。1つのルートハンドラーがこれをドラフトモードのセッションに変換します。

```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クッキーに保存し、オープンリダイレクトを防いでページにリダイレクトします。`proxy.ts` では、`?kp=` を含むリクエストを `/api/draft?kp=…&redirect=<path>` に送信します。`lib/kanorio.ts` では、環境変数の代わりに `tokenFromCookieHeader` を使用してクッキーからトークンを読み取ることができます。トークンの有効期限が切れると、APIは `401` を返します。その場合はドラフトモードを無効にし、公開済みコンテンツに戻してください。

## 6. SEOファイル、リダイレクト、クローラールール

サイトマップ、robots.txt、llms.txt、フィードを独自のドメインから配信します。

```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).*)"],
};
```

ミドルウェアはルールを1分ごとに更新するため、管理画面での変更はデプロイなしで適用されます。リダイレクトルールは、Kanorioでホストされているサイトと同様に、完全一致パスが最初に照合され、次に最も長い `/*` ワイルドカードプレフィックスが照合されます。静的エクスポート（`output: "export"`）では `proxy.ts` を実行できないため、代わりにホスト側でリダイレクトを設定してください。

## 7. ロケール

多言語サイトでは、プライマリロケールをルートで配信し、その他のすべてのロケールをオーナーが選択したURLプレフィックス（`/en/about` など）の下で配信します。`GET /api/v1/site` は `pathPrefix` を含む `locales[]` をリストし、すべての読み取りは `locale`（コードまたはプレフィックス）を受け入れます。翻訳されていないフィールドはプライマリテキストにフォールバックし、レスポンスは `resolvedLocale` で配信された言語を、`availableLocales` で翻訳が存在するロケールを伝えます。

`createKanorioMiddleware` はすでにエッジルールを処理しています。最初のセグメントがプライマリロケールコードであるリクエスト（`/zh-hant/about`）はルートパスにリダイレクトされ、`/en/llms.txt`、`/en/llms-full.txt`、`/en/rss.xml` はContent Delivery APIから配信され、リダイレクトルールはプレフィックスを除いたパスと照合されます。ミドルウェアと同じヘルパーを使用して、ルート内のロケールを読み取ります。

```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` を返します。URLと異なる場合は、`permanentRedirect` を呼び出してください。

`buildAlternates` はホストされているサイトと同じルールに従います。すべてのロケールバージョンは独自の正規URLを持ち、このページの翻訳があるロケールのみがリストされ、`x-default` はプライマリロケールを指し、フォールバックで配信されたページ（ロケールにまだ翻訳がない場合）はプライマリロケールの正規URLを取得し、`hreflang` は持ちません。`languageSwitcher({ siteUrl, site, path })` は言語メニュー用のリンクを返します。ロケール固有のフェッチにはレスポンス独自のキャッシュタグ（`@<locale>` を含む）を付与してください。翻訳が公開されると、そのロケールのみが更新されます。

## 8. セットアップの確認

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

`doctor` はSEOファイル、JSON-LD、IndexNowキー、コンテンツ同期、ページビューレポート、Webhookをチェックします。多言語サイトでは、各ロケールのページが翻訳されたタイトルでプレフィックスの下に到達可能であること、`hreflang` がすべての公開ロケールをリストしていること、`/{prefix}/llms.txt` が配信されていることもチェックします。

## トークンをブラウザから遠ざける

公開読み取りにトークンは不要であり、ドラフトプレビューにはサーバー上で読み取り専用の `knr_pv_` トークンを使用します。`knr_pat_` トークンをクライアントコンポーネントや `NEXT_PUBLIC_` 変数にインポートしないでください。
