Kanorio

Get started

Next.js

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

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

# .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. 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.

// 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

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

// 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 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.

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:

// 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:

// 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:

// 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:

// 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

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.