# Next.js

> Build a Next.js App Router front end on Kanorio with cached reads, webhook revalidation, draft mode, SEO files and edge redirects.

> Markdown version of https://kanorio.com/en/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

Next.js cache tags and Kanorio's cache tags were made for each other. Pages render from the Next.js cache, and when someone publishes in Kanorio, a webhook revalidates exactly the pages that used the changed content within seconds.

This guide uses the App Router and Next.js 16, where middleware is called `proxy.ts`.

## 1. Environment variables

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

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

Use the permanent system URL from [Site settings › Developer](https://app.kanorio.com/settings/developer). The webhook secret comes from the endpoint you create in step 4. The preview token is optional and only ever used on the server.

## 2. A shared fetch helper

`lib/kanorio.ts` tags every read with Kanorio's cache tags, and switches to drafts when draft mode is on. Tags must be known before the fetch, so build them with the SDK's `cacheTag` helpers (they carry the `kanorio:` prefix the webhook handler uses); the response's own `X-Kanorio-Cache-Tags` header lists the complete set, including breadcrumb ancestors and expanded references, for frameworks that can tag after the 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. Render posts

```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. Revalidate on publish

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

Register `https://www.example.com/api/revalidate` under [Site settings › Webhooks](https://app.kanorio.com/settings/webhooks) and subscribe to the collection, page and localization events. The handler verifies the signature and revalidates every tag in `data.cacheTags` with the same `kanorio:` prefix. A single post going live refreshes that post's page and the blog index in every locale; a translation going live refreshes only that locale. See [Webhooks](https://kanorio.com/en/developers/webhooks).

## 5. Draft mode from the dashboard

Set your front end's URL as the external storefront URL in the developer settings. The dashboard's **View site** button then opens your front end with a short-lived preview token in `?kp=`. One route handler turns it into a draft-mode session:

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

It validates the token with Kanorio, stores it in an HttpOnly cookie, guards against open redirects and redirects to the page. In `proxy.ts`, send requests with `?kp=` to `/api/draft?kp=…&redirect=<path>`. In `lib/kanorio.ts` you can read the token from the cookie with `tokenFromCookieHeader` instead of the environment variable. When the token expires, the API returns `401`; disable draft mode and fall back to published content.

## 6. SEO files, redirects and crawler rules

Serve the sitemap, robots.txt, llms.txt and feeds from your own domain:

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

Apply redirects and the AI crawler rules the owner set in the dashboard, and serve the IndexNow key file, at the edge:

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

The middleware refreshes its rules every minute, so changes in the dashboard apply without a deploy. Redirect rules match exact paths first, then the longest `/*` wildcard prefix, the same way the Kanorio-hosted site matches them. A static export (`output: "export"`) cannot run `proxy.ts`; configure redirects on your host instead.

## 7. Locales

A multilingual site serves its primary locale at the root and every other locale under a URL prefix the owner chose, such as `/en/about`. `GET /api/v1/site` lists them in `locales[]` with `pathPrefix`, and every read accepts `locale` (the code or the prefix). Untranslated fields fall back to the primary text, and responses say what they served in `resolvedLocale` and which locales have a translation in `availableLocales`.

`createKanorioMiddleware` already handles the edge rules: a request whose first segment is the primary locale code (`/zh-hant/about`) is redirected to the root path, `/en/llms.txt`, `/en/llms-full.txt` and `/en/rss.xml` are served from the Content Delivery API, and redirect rules match the path without its prefix. Read the locale in your routes with the same helper the middleware uses:

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

Each locale can have its own page slugs, so pass the response's `localePaths` to `buildAlternates` and `languageSwitcher`. The page endpoint also accepts another locale's path and returns `page.slug` in the requested locale; when it differs from the URL, call `permanentRedirect` to it.

`buildAlternates` follows the same rules as the hosted site: every locale version is its own canonical, only locales that have a translation of this page are listed, `x-default` points at the primary locale, and a page served in fallback (the locale has no translation yet) gets the primary locale's canonical and no `hreflang`. `languageSwitcher({ siteUrl, site, path })` returns the links for a language menu. Tag locale-specific fetches with the response's own cache tags (they carry `@<locale>`), and a translation going live refreshes only that locale.

## 8. Check the setup

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

`doctor` checks the SEO files, JSON-LD, the IndexNow key, content sync, pageview reporting and webhooks. On a multilingual site it also checks that each locale's pages are reachable under their prefix with the translated title, that `hreflang` lists every live locale, and that `/{prefix}/llms.txt` is served.

## Keep the token out of the browser

Public reads never need a token, and draft previews use the read-only `knr_pv_` token on the server. Never import a `knr_pat_` token into a client component or a `NEXT_PUBLIC_` variable.
