Next.js (App Router) server-side cache tags (next.tags and revalidateTag) and Kanorio’s X-Kanorio-Cache-Tags and Webhook cacheTags are a perfect match: pages load instantly from cache, and when you update a collection or publish a new article in the Kanorio dashboard, Webhooks precisely refresh the affected pages within seconds.
Configure your Kanorio permanent system URL (along with keys for Webhook verification and server-side previews) in your Next.js project’s .env.local file:
# Public base URL (use your site's permanent system URL or custom domain) NEXT_PUBLIC_KANORIO_SITE_URL="https://s-k4m9x2p7q8w3.kanorio.com" # Server-side only: Webhook signature key (obtain from Site Settings › Webhooks or webhooks.create) KANORIO_WEBHOOK_SECRET="whsec_your_signature_key" # Server-side only (optional): Read-only preview token for Draft Mode (Site Settings › Developer › Generate Preview Token) KANORIO_PREVIEW_TOKEN="knr_pv_your_preview_token"
Preview tokens (
knr_pv_) are read-only, expire, and can be revoked at any time, making them much safer thanknr_pat_management keys. While the legacy method usingKANORIO_PREVIEW_PAT+?preview=1still works, we recommend switching to preview tokens.
Create lib/kanorio.ts to automatically bind collection and item cache tags when called in Server Components, and switch to X-Kanorio-Preview for draft reading when Next.js Draft Mode is enabled:
import { draftMode } from "next/headers"; const SITE_URL = process.env.NEXT_PUBLIC_KANORIO_SITE_URL!; export async function fetchCollectionItems<T = Record<string, unknown>>( collectionKey: string, params: Record<string, string> = {} ) { const { isEnabled: isDraft } = await draftMode(); const search = new URLSearchParams(params); const url = `${SITE_URL}/api/v1/collections/${encodeURIComponent(collectionKey)}/items?${search.toString()}`; const res = await fetch( url, isDraft ? { headers: { "X-Kanorio-Preview": process.env.KANORIO_PREVIEW_TOKEN! }, cache: "no-store", } : { next: { tags: [`collection:${collectionKey}`] }, } ); if (!res.ok) throw new Error(`Kanorio fetch failed: ${res.status}`); return (await res.json()) as { items: Array<{ id: string; slug: string; publishedAt: string | null; updatedAt: string; fieldData: T; expanded?: Record<string, unknown>; segments?: unknown[]; toc?: Array<{ id: string; label: string; level: 2 | 3 }>; contentHtml?: string; }>; assets: Record< string, { assetId: string; url: string; width: number | null; height: number | null; alt: string | null; } >; total: number; nextCursor: string | null; }; } export async function fetchCollectionItem<T = Record<string, unknown>>( collectionKey: string, slug: string, params: Record<string, string> = {} ) { const { isEnabled: isDraft } = await draftMode(); const search = new URLSearchParams(params); const url = `${SITE_URL}/api/v1/collections/${encodeURIComponent(collectionKey)}/items/${encodeURIComponent(slug)}?${search.toString()}`; const res = await fetch( url, isDraft ? { headers: { "X-Kanorio-Preview": process.env.KANORIO_PREVIEW_TOKEN! }, cache: "no-store", } : { next: { tags: [`collection:${collectionKey}`, `slug:${collectionKey}:${slug}`] }, } ); if (res.status === 404) return null; if (!res.ok) throw new Error(`Kanorio fetch failed: ${res.status}`); const data = await res.json(); // Bind item:<id> to cache tags as well return data as { item: { id: string; slug: string; publishedAt: string | null; fieldData: T; expanded?: Record<string, unknown>; toc?: Array<{ id: string; label: string; level: 2 | 3 }>; contentHtml?: string; }; assets: Record<string, { url: string; width: number | null; height: number | null; alt: string | null }>; }; }
In app/blog/[slug]/page.tsx, fetch directly from the blog collection using formats=html and expand=authors,categories:
import { notFound } from "next/navigation"; import { fetchCollectionItem } from "@/lib/kanorio"; export default async function BlogPostPage({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; const data = await fetchCollectionItem<{ title: string; excerpt: string | null; cover_image: string | null; reading_time_min: number | null; }>("blog", slug, { expand: "authors,categories,tags", formats: "markdown,html", }); if (!data) notFound(); const { item, assets } = data; const coverId = item.fieldData.cover_image?.replace(/^asset:/, "") ?? ""; const cover = assets[coverId]; return ( <article> <h1>{item.fieldData.title}</h1> {cover && ( <img src={cover.url} alt={cover.alt ?? item.fieldData.title} width={cover.width ?? undefined} height={cover.height ?? undefined} /> )} {item.contentHtml && ( <div dangerouslySetInnerHTML={{ __html: item.contentHtml }} /> )} </article> ); }
app/api/revalidate/route.ts)Create app/api/revalidate/route.ts to verify X-Kanorio-Signature and execute revalidateTag based on payload.data.cacheTags:
import { createHmac, timingSafeEqual } from "node:crypto"; import { revalidateTag } from "next/cache"; import { NextRequest, NextResponse } from "next/server"; function verifySignature(rawBody: string, header: string | null, secret: string): boolean { if (!header || !secret) return false; const parts = header.split(",").map((p) => p.split("=")); const t = parts.find(([k]) => k === "t")?.[1]; const v1s = parts.filter(([k]) => k === "v1").map(([, v]) => v); if (!t || v1s.length === 0) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; const expected = Buffer.from( createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"), "utf8" ); return v1s.some((sig) => { const buf = Buffer.from(sig, "utf8"); return buf.length === expected.length && timingSafeEqual(buf, expected); }); } export async function POST(req: NextRequest) { const rawBody = await req.text(); const sig = req.headers.get("x-kanorio-signature"); const secret = process.env.KANORIO_WEBHOOK_SECRET ?? ""; if (!verifySignature(rawBody, sig, secret)) { return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); } const payload = JSON.parse(rawBody) as { event: string; data?: { collectionKey?: string; slug?: string; cacheTags?: string[]; }; }; for (const tag of payload.data?.cacheTags ?? []) { revalidateTag(tag); } if (payload.data?.collectionKey && payload.data?.slug) { revalidateTag(`slug:${payload.data.collectionKey}:${payload.data.slug}`); } return NextResponse.json({ revalidated: true }); }
Register https://your-domain.com/api/revalidate in Site Settings › Webhooks (or via webhooks.create API).
?kp=)Once you set the "External Frontend URL" in Site Settings › Developer, the "View Site" button will redirect you to your external frontend with a 10-minute preview token: https://your-domain.com/?kp=knr_pv_…. Create app/api/draft/route.ts (or use middleware.ts) to read this parameter, enable Draft Mode, and store the token in a cookie. Then, update lib/kanorio.ts to fetch the token from the cookie so editors can see drafts in real-time:
// app/api/draft/route.ts import { draftMode } from "next/headers"; import { redirect } from "next/navigation"; export async function GET(request: Request) { const url = new URL(request.url); const token = url.searchParams.get("kp"); if (!token?.startsWith("knr_pv_")) return new Response("Invalid preview token", { status: 401 }); const draft = await draftMode(); draft.enable(); // HttpOnly cookie prevents client-side JS access; the token is read-only and expires const response = Response.redirect(new URL(url.searchParams.get("redirect") ?? "/", url), 307); response.headers.append("Set-Cookie", `kanorio_preview=${encodeURIComponent(token)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`); return response; }
Use middleware.ts to redirect ?kp= to /api/draft?kp=…&redirect=<original_path>, and update the Draft branch in lib/kanorio.ts to read cookies().get("kanorio_preview"). If the preview token is invalid or expired, the API returns 401; call draftMode().disable() and revert to public content.
/api/v1/redirects)Kanorio automatically creates 301 rules when URLs change. Fetch GET /api/v1/redirects during build time and include them in next.config.ts’s redirects() to maintain SEO during migrations:
// next.config.ts export default { async redirects() { const res = await fetch(`${process.env.NEXT_PUBLIC_KANORIO_SITE_URL}/api/v1/redirects`); const { redirects } = (await res.json()) as { redirects: Array<{ sourcePath: string; targetUrl: string; statusCode: 301 | 302 }>; }; return redirects.map((r) => ({ source: r.sourcePath, destination: r.targetUrl, permanent: r.statusCode === 301 })); }, };
Subscribe to the content.page.updated Webhook (check if data.previousSlug exists) to trigger a redeploy and apply new rules.
knr_pat_ keys are administrative credentials. If exposed in client-side components or via NEXT_PUBLIC_ prefixes, anyone can steal your keys using browser developer tools. Public content doesn't require keys; for draft previews in browsers or shared links, always use the read-only, expiring, and revocable knr_pv_ preview tokens.
No. Every Content Delivery API response includes a weak ETag. When your browser or server-side cache revalidates with If-None-Match, it returns 304 Not Modified (no body) if the content hasn't changed. For troubleshooting, provide the X-Request-Id from the response to support.