Kanorio

시작하기

Next.js

캐시된 읽기, 웹훅 재검증, 초안 모드, SEO 파일 및 엣지 리다이렉트를 사용하여 Kanorio에서 Next.js App Router 프론트엔드를 구축하세요.

Next.js 캐시 태그와 Kanorio의 캐시 태그는 서로 완벽하게 호환됩니다. 페이지는 Next.js 캐시에서 렌더링되며, 누군가 Kanorio에서 콘텐츠를 게시하면 웹훅이 변경된 콘텐츠를 사용한 페이지를 몇 초 이내에 정확하게 재검증합니다.

이 가이드는 App Router와 Next.js 16을 사용하며, 미들웨어는 proxy.ts라고 합니다.

1. 환경 변수

# .env.local
NEXT_PUBLIC_KANORIO_SITE_URL="https://s-xxxxxxxxxxxx.kanorio.com"

# server only
KANORIO_WEBHOOK_SECRET="whsec_..."
KANORIO_PREVIEW_TOKEN="knr_pv_..."

사이트 설정 › 개발자에서 영구 시스템 URL을 사용하세요. 웹훅 시크릿은 4단계에서 생성하는 엔드포인트에서 가져옵니다. 미리보기 토큰은 선택 사항이며 서버에서만 사용됩니다.

2. 공유 페치 헬퍼

lib/kanorio.ts는 모든 읽기 작업에 Kanorio의 캐시 태그를 태그하고, 초안 모드가 켜져 있을 때 초안으로 전환합니다. 태그는 페치 전에 알려져야 하므로 SDK의 cacheTag 헬퍼로 빌드하세요(이 태그들은 웹훅 핸들러가 사용하는 kanorio: 접두사를 가집니다). 응답 자체의 X-Kanorio-Cache-Tags 헤더는 페치 후 태그를 지정할 수 있는 프레임워크를 위해 브레드크럼 상위 항목과 확장된 참조를 포함한 전체 세트를 나열합니다.

// 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. 게시물 렌더링

// 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. 게시 시 재검증

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

사이트 설정 › Webhooks에 https://www.example.com/api/revalidate를 등록하고 컬렉션, 페이지 및 로컬라이제이션 이벤트를 구독하세요. 핸들러는 서명을 확인하고 data.cacheTags에 있는 동일한 kanorio: 접두사를 가진 모든 태그를 재검증합니다. 게시물이 라이브 상태가 되면 해당 게시물 페이지와 모든 로케일의 블로그 인덱스가 새로 고쳐집니다. 번역이 라이브 상태가 되면 해당 로케일만 새로 고쳐집니다. Webhooks를 참조하세요.

5. 관리 화면에서 초안 모드 사용

개발자 설정에서 프론트엔드 URL을 외부 스토어프론트 URL로 설정하세요. 관리 화면의 사이트 보기 버튼을 클릭하면 ?kp=에 단기 미리보기 토큰이 포함된 상태로 프론트엔드가 열립니다. 하나의 라우트 핸들러가 이를 초안 모드 세션으로 변환합니다.

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

이 핸들러는 Kanorio로 토큰을 검증하고, HttpOnly 쿠키에 저장하며, 오픈 리다이렉트를 방지하고 페이지로 리다이렉트합니다. proxy.ts에서 ?kp=가 포함된 요청을 /api/draft?kp=…&redirect=<path>로 보내세요. lib/kanorio.ts에서는 환경 변수 대신 tokenFromCookieHeader를 사용하여 쿠키에서 토큰을 읽을 수 있습니다. 토큰이 만료되면 API는 401을 반환합니다. 이때 초안 모드를 비활성화하고 게시된 콘텐츠로 돌아가세요.

6. SEO 파일, 리다이렉트 및 크롤러 규칙

사이트맵, robots.txt, llms.txt 및 피드를 자체 도메인에서 제공하세요.

// next.config.ts
import { seoRewrites } from "@kanorio/sdk";

export default {
  async rewrites() {
    return seoRewrites({ siteUrl: process.env.NEXT_PUBLIC_KANORIO_SITE_URL!, framework: "next" });
  },
};

리다이렉트와 관리 화면에서 소유자가 설정한 AI 크롤러 규칙을 적용하고, IndexNow 키 파일을 엣지에서 제공하세요.

// 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).*)"],
};

미들웨어는 1분마다 규칙을 새로 고치므로 관리 화면의 변경 사항이 배포 없이 적용됩니다. 리다이렉트 규칙은 Kanorio 호스팅 사이트와 동일하게 정확한 경로가 먼저 일치하고, 그 다음 가장 긴 /* 와일드카드 접두사가 일치합니다. 정적 내보내기(output: "export")는 proxy.ts를 실행할 수 없으므로 호스트에서 리다이렉트를 구성하세요.

7. 로케일

다국어 사이트는 기본 로케일을 루트에서 제공하고, 다른 모든 로케일은 소유자가 선택한 URL 접두사(예: /en/about) 아래에서 제공합니다. GET /api/v1/site는 pathPrefix와 함께 locales[]를 나열하며, 모든 읽기 작업은 locale(코드 또는 접두사)을 허용합니다. 번역되지 않은 필드는 기본 텍스트로 대체되며, 응답은 resolvedLocale로 제공된 언어를, availableLocales로 번역이 존재하는 로케일을 알려줍니다.

createKanorioMiddleware는 이미 엣지 규칙을 처리합니다. 첫 번째 세그먼트가 기본 로케일 코드인 요청(/zh-hant/about)은 루트 경로로 리다이렉트되고, /en/llms.txt, /en/llms-full.txt 및 /en/rss.xml은 Content Delivery API에서 제공되며, 리다이렉트 규칙은 접두사를 제외한 경로와 일치합니다. 미들웨어가 사용하는 것과 동일한 헬퍼를 사용하여 라우트에서 로케일을 읽으세요.

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

각 로케일은 고유한 페이지 슬러그를 가질 수 있으므로 응답의 localePaths를 buildAlternates 및 languageSwitcher에 전달하세요. 페이지 엔드포인트는 다른 로케일의 경로도 허용하며 요청된 로케일의 page.slug를 반환합니다. URL과 다를 경우 permanentRedirect를 호출하세요.

buildAlternates는 호스팅된 사이트와 동일한 규칙을 따릅니다. 모든 로케일 버전은 고유한 정식 URL을 가지며, 이 페이지의 번역이 있는 로케일만 나열되고, x-default는 기본 로케일을 가리키며, 대체 언어로 제공된 페이지(로케일에 아직 번역이 없는 경우)는 기본 로케일의 정식 URL을 가져오고 hreflang은 없습니다. languageSwitcher({ siteUrl, site, path })는 언어 메뉴용 링크를 반환합니다. 로케일별 페치에는 응답 자체의 캐시 태그(@<locale> 포함)를 지정하세요. 번역이 라이브 상태가 되면 해당 로케일만 새로 고쳐집니다.

8. 설정 확인

npx @kanorio/cli doctor --site ws_example123

doctor는 SEO 파일, JSON-LD, IndexNow 키, 콘텐츠 동기화, 페이지뷰 보고 및 웹훅을 확인합니다. 다국어 사이트의 경우 각 로케일의 페이지가 번역된 제목으로 접두사 아래에서 도달 가능한지, hreflang이 모든 라이브 로케일을 나열하는지, /{prefix}/llms.txt가 제공되는지도 확인합니다.

브라우저에서 토큰 노출 방지

공개 읽기 작업에는 토큰이 필요하지 않으며, 초안 미리보기에는 서버에서 읽기 전용 knr_pv_ 토큰을 사용합니다. knr_pat_ 토큰을 클라이언트 컴포넌트나 NEXT_PUBLIC_ 변수로 가져오지 마세요.