---
title: 블로그 연동
description: 블로그 목록/상세 페이지 구현 — 컴포넌트 빠른 경로와 직접 fetch 커스텀 경로
---

# 블로그 연동

두 가지 경로가 있습니다:

- **빠른 경로** — `@roottale/cms-renderer-next`의 RSC 컴포넌트 사용. 데이터
  fetch + 렌더링까지 한 번에.
- **커스텀 경로** — `@roottale/cms-client`로 raw 데이터를 가져와 자체 UI로
  렌더링. 디자인을 완전히 통제할 때.

본문 렌더링은 두 경로 모두 `RootTaleBlogPost`(블록 JSON → React)를 쓰는 것을
권장합니다. 본문 JSON 스키마를 직접 파싱하지 마세요.

## 빠른 경로 — 컴포넌트

### 목록 페이지

```tsx
// app/blog/page.tsx
import { RootTaleBlogList } from "@roottale/cms-renderer-next/server";

export const revalidate = 1800; // 30분 fallback — 실시간 갱신은 웹훅이 담당

export default function BlogPage() {
  return (
    <RootTaleBlogList
      apiKey={process.env.ROOTTALE_API_KEY!}
      baseUrl={process.env.ROOTTALE_API_BASE}
      limit={20}
      showCategoryFilter
      postHref={(post) => `/blog/${post.slug}`}
    />
  );
}
```

### 상세 페이지

```tsx
// app/blog/[slug]/page.tsx
import { RootTaleBlogPost } from "@roottale/cms-renderer-next/server";

export const revalidate = 1800;

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params; // Next.js 15+ async params
  return (
    <RootTaleBlogPost
      apiKey={process.env.ROOTTALE_API_KEY!}
      baseUrl={process.env.ROOTTALE_API_BASE}
      slugOrId={slug}
      showTableOfContents
      tableOfContentsTitle="목차"
    />
  );
}
```

목차(ToC)·작성자 카드·발행일 표시는 어드민의 블로그 표시 설정으로도 제어됩니다
(`theme-and-settings.md` 참고).

### 고정 페이지 (회사소개 등)

어드민의 고정 페이지(`type: "page"`)는 `RootTalePage`로 렌더링합니다 — 블로그
크롬(날짜·작성자·작성자 카드) 없이 제목+본문만 출력합니다 (renderer-next
0.22.0+):

```tsx
// app/about/page.tsx
import { RootTalePage } from "@roottale/cms-renderer-next/server";

export const revalidate = 1800;

export default function AboutPage() {
  return (
    <RootTalePage
      apiKey={process.env.ROOTTALE_API_KEY!}
      baseUrl={process.env.ROOTTALE_API_BASE}
      slugOrId="about"
      // showTitle={false} — 페이지 제목을 직접 마크업할 때
    />
  );
}
```

## 커스텀 경로 — 직접 fetch

```ts
// lib/blog.ts
import { fetchPosts, fetchPost, type CmsPostContent } from "@roottale/cms-client/server";

export async function getAllPosts() {
  const page = await fetchPosts({
    apiKey: process.env.ROOTTALE_API_KEY!,
    baseUrl: process.env.ROOTTALE_API_BASE,
    type: "post",
    limit: 100,
  });
  return page.items; // CmsPostContent[]
  // page.hasMore / page.nextCursor 로 커서 페이지네이션
}

export async function getPost(slug: string) {
  return fetchPost({
    apiKey: process.env.ROOTTALE_API_KEY!,
    baseUrl: process.env.ROOTTALE_API_BASE,
    slugOrId: slug, // slug 또는 UUID — 404면 null 반환
  });
}
```

`CmsPostContent` 주요 필드:

| 필드 | 설명 |
|---|---|
| `id`, `slug`, `title` | 식별자·제목 |
| `excerpt` | 요약 (목록 카드용) |
| `publishedAt` | 발행 시각 (ISO) |
| `bodyJson` | 본문 블록 JSON — `RootTaleBlogPost` 또는 `renderBlocks`로 렌더 |
| `terms` | 분류 용어 배열 (`taxonomy: "category" \| "tag"`, `name`, `slug`) |
| `featuredImageUrl` | 대표 이미지 |
| `authorName` | 작성자 표시명 |
| `metaJson` | 부가 메타 — `metaJson.seo`에 SEO 오버라이드 |

### 정적 경로 사전 생성 + 메타데이터

```tsx
// app/blog/[slug]/page.tsx (커스텀 UI 버전)
import type { Metadata } from "next";
import { getAllPosts, getPost } from "@/lib/blog";

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((p) => ({ slug: p.slug }));
}

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};
  // 어드민 글 에디터의 SEO 패널 값(metaJson.seo)을 우선 적용
  const seo = (post.metaJson as { seo?: Record<string, string | boolean> })?.seo;
  return {
    title: (seo?.title as string) || post.title,
    description: (seo?.description as string) || post.excerpt,
    ...(seo?.noindex || seo?.nofollow
      ? { robots: { index: !seo?.noindex, follow: !seo?.nofollow } }
      : {}),
  };
}
```

SEO 오버라이드 필드: `title`, `description`, `canonical`, `ogImage`,
`noindex`, `nofollow`.

### slug 변경 시 301 리다이렉트 (필수 권장)

어드민에서 글 slug를 바꿔도 API는 **옛 slug로 글을 찾아 현재 slug로
응답**합니다(slug history fallback). 페이지에서 요청 slug와 응답 slug가
다르면 301로 보내야 검색엔진 순위가 새 URL로 승계됩니다:

```tsx
// app/blog/[slug]/page.tsx
import { notFound, permanentRedirect } from "next/navigation";
import { postRedirectPath } from "@roottale/cms-renderer-next/routes";

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();
  const redirect = postRedirectPath(post, slug); // 기본 basePath "/blog"
  if (redirect) permanentRedirect(redirect);
  // ... 렌더
}
```

`generateMetadata`는 redirect 페이지에서 실행돼도 무방하지만, canonical을
직접 계산한다면 `post.slug`(현재 slug) 기준으로 계산하세요.

## 캐싱 전략

- 페이지에 `export const revalidate = 1800` (30분) — **fallback일 뿐**입니다.
- 정상 동작은 발행 웹훅이 즉시 revalidate 하는 것 → `revalidation-webhooks.md`
  를 반드시 함께 설정하세요.
- 홈 화면에 최신 글 섹션을 둔다면 홈도 웹훅의 `alsoRevalidate`에 포함하세요.

완전한 동작 예시는 MCP tool `readRootTaleNextjsExampleCode`로 확인할 수 있습니다.
