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 提供給客服。