# Next.js

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

> Markdown version of https://kanorio.com/ko/developers/guides/nextjs. Append `.md` to any developer docs URL for this format. Index: https://kanorio.com/llms.txt · MCP: https://app.kanorio.com/api/mcp
>
> Translated from the English original: https://kanorio.com/en/developers/guides/nextjs.md

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

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

## 1. 환경 변수

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

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

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

## 2. 공유 페치 헬퍼

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

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

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

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

```ts
// 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://app.kanorio.com/settings/webhooks)에 `https://www.example.com/api/revalidate`를 등록하고 컬렉션, 페이지 및 로컬라이제이션 이벤트를 구독하세요. 핸들러는 서명을 확인하고 `data.cacheTags`에 있는 동일한 `kanorio:` 접두사를 가진 모든 태그를 재검증합니다. 게시물이 라이브 상태가 되면 해당 게시물 페이지와 모든 로케일의 블로그 인덱스가 새로 고쳐집니다. 번역이 라이브 상태가 되면 해당 로케일만 새로 고쳐집니다. [Webhooks](https://kanorio.com/ko/developers/webhooks)를 참조하세요.

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

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

```ts
// 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 및 피드를 자체 도메인에서 제공하세요.

```ts
// 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 키 파일을 엣지에서 제공하세요.

```ts
// 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에서 제공되며, 리다이렉트 규칙은 접두사를 제외한 경로와 일치합니다. 미들웨어가 사용하는 것과 동일한 헬퍼를 사용하여 라우트에서 로케일을 읽으세요.

```tsx
// 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. 설정 확인

```bash
npx @kanorio/cli doctor --site ws_example123
```

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

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

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