Next.js(App Router)的服务器端缓存标签(next.tags 与 revalidateTag)和 Kanorio 的 X-Kanorio-Cache-Tags 及 Webhook cacheTags 是绝佳搭档:页面平时由缓存秒开,当您在 Kanorio 后台修改表格或发布新文章时,Webhook 会在几秒内精准刷新受影响的页面。
在 Next.js 项目的 .env.local 设置您的 Kanorio 永久系统网址(以及供 Webhook 验签与服务器端预览使用的密钥):
# 公开读取基底网址(使用网站永久系统网址或自定义域名) NEXT_PUBLIC_KANORIO_SITE_URL="https://s-k4m9x2p7q8w3.kanorio.com" # 仅限服务器端:Webhook 签名密钥(由后台网站设置 › Webhooks 或 webhooks.create 获取) KANORIO_WEBHOOK_SECRET="whsec_您的签名密钥" # 仅限服务器端(选填):Draft Mode 预览草稿用的只读预览令牌(网站设置 › 开发者 › 生成预览令牌) KANORIO_PREVIEW_TOKEN="knr_pv_您的预览令牌"
预览令牌(
knr_pv_)只能读取该网站的草稿,会过期也能随时撤销,比knr_pat_管理密钥安全得多;旧版文档使用KANORIO_PREVIEW_PAT+?preview=1的做法仍可用,但建议改用预览令牌。
建立 lib/kanorio.ts,在 Server Component 调用时自动绑定集合与项目的缓存标签,并在开启 Next.js Draft Mode 时自动改带 X-Kanorio-Preview 读取草稿:
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(); // 把后端返回的 item:<id> 也绑定进缓存标签 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 }>; }; }
在 app/blog/[slug]/page.tsx 直接读取 blog 系统集合并使用 formats=html 与 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)建立 app/api/revalidate/route.ts,验证 X-Kanorio-Signature 后对 payload.data.cacheTags 执行 revalidateTag:
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 }); }
接着在后台“网站设置 › Webhooks”(或请 AI 调用 webhooks.create)将 https://您的域名/api/revalidate 注册上去即可。
?kp=)当您在“网站设置 › 开发者”设置了“外部前端主网址”,后台“查看网站”按钮会将您带到外部前端,并在网址带上一把 10 分钟有效的预览令牌:https://您的域名/?kp=knr_pv_…。建立 app/api/draft/route.ts(或在 middleware.ts)读取此参数、开启 Draft Mode 并将令牌存入 cookie,之后 lib/kanorio.ts 改从 cookie 获取令牌,即可让编辑者实时查看草稿:
// 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,前端 JS 无法读取;令牌本身只读且会过期 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; }
在 middleware.ts 将 ?kp= 重定向至 /api/draft?kp=…&redirect=<原路径>,lib/kanorio.ts 的 Draft 分支改读取 cookies().get("kanorio_preview")。预览令牌无效或过期时 API 会返回 401,请在此处执行 draftMode().disable() 后退回公开内容。
/api/v1/redirects)页面在 Kanorio 后台修改网址时会自动建立 301 规则,站主也可能手动设置重定向。外部前端请在构建期读取 GET /api/v1/redirects 并放入 next.config.ts 的 redirects(),SEO 才不会在迁移时断掉:
// 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 })); }, };
订阅 content.page.updated Webhook(data.previousSlug 有值代表改了网址)触发重新部署,新规则就会生效。
knr_pat_ 密钥属于管理凭证,一旦放进客户端组件或加上 NEXT_PUBLIC_ 前缀,任何访客打开浏览器开发者工具都能偷走您的密钥。公开内容读取本来就不需要密钥;需要在浏览器或分享链接中预览草稿时,请一律改用只读、会过期、可撤销的 knr_pv_ 预览令牌。
不会。Content Delivery API 的每个读取响应都带弱 ETag,浏览器或您的服务器端缓存带 If-None-Match 重验时,内容没变会直接回 304 Not Modified(无 body)。排错时可把响应的 X-Request-Id 提供给客服。