Next.js (App Router) 서버 사이드 캐시 태그(next.tags 및 revalidateTag)와 Kanorio의 X-Kanorio-Cache-Tags 및 Webhook cacheTags는 완벽한 조합입니다. 페이지는 캐시에서 즉시 로드되며, Kanorio 대시보드에서 컬렉션을 업데이트하거나 새 기사를 게시하면 Webhook이 몇 초 내에 영향을 받는 페이지를 정확하게 새로 고칩니다.
Next.js 프로젝트의 .env.local 파일에 Kanorio 영구 시스템 URL(Webhook 검증 및 서버 사이드 미리보기용 키 포함)을 구성합니다.
# 공개 기본 URL(사이트의 영구 시스템 URL 또는 사용자 지정 도메인 사용) NEXT_PUBLIC_KANORIO_SITE_URL="https://s-k4m9x2p7q8w3.kanorio.com" # 서버 사이드 전용: Webhook 서명 키(사이트 설정 › Webhooks 또는 webhooks.create에서 획득) KANORIO_WEBHOOK_SECRET="whsec_your_signature_key" # 서버 사이드 전용(선택 사항): 초안 모드용 읽기 전용 미리보기 토큰(사이트 설정 › 개발자 › 미리보기 토큰 생성) KANORIO_PREVIEW_TOKEN="knr_pv_your_preview_token"
미리보기 토큰(
knr_pv_)은 읽기 전용이며 만료되고 언제든지 취소할 수 있으므로knr_pat_관리 키보다 훨씬 안전합니다.KANORIO_PREVIEW_PAT+?preview=1을 사용하는 기존 방식도 여전히 작동하지만, 미리보기 토큰으로 전환하는 것을 권장합니다.
lib/kanorio.ts를 생성하여 서버 컴포넌트에서 호출될 때 컬렉션 및 항목 캐시 태그를 자동으로 바인딩하고, Next.js 초안 모드가 활성화된 경우 초안 읽기를 위해 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에서 formats=html 및 expand=authors,categories를 사용하여 blog 컬렉션에서 직접 데이터를 가져옵니다.
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(또는 webhooks.create API)에 https://your-domain.com/api/revalidate를 등록합니다.
?kp=)사이트 설정 › 개발자에서 "외부 프론트엔드 URL"을 설정하면 "사이트 보기" 버튼을 통해 10분짜리 미리보기 토큰과 함께 외부 프론트엔드로 리디렉션됩니다: https://your-domain.com/?kp=knr_pv_….
app/api/draft/route.ts를 생성(또는 middleware.ts 사용)하여 이 매개변수를 읽고, 초안 모드를 활성화하며, 토큰을 쿠키에 저장합니다. 그런 다음 lib/kanorio.ts를 업데이트하여 쿠키에서 토큰을 가져오면 편집자가 실시간으로 초안을 확인할 수 있습니다.
// 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 쿠키는 클라이언트 측 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=<original_path>로 리디렉션하고, lib/kanorio.ts의 초안 분기를 업데이트하여 cookies().get("kanorio_preview")를 읽도록 합니다. 미리보기 토큰이 유효하지 않거나 만료된 경우 API는 401을 반환하며, 이때 draftMode().disable()을 호출하여 공개 콘텐츠로 되돌립니다.
/api/v1/redirects)Kanorio는 URL이 변경될 때 자동으로 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(본문 없음)가 반환됩니다. 문제 해결이 필요한 경우 응답의 X-Request-Id를 지원팀에 제공하십시오.