블로그 연동
두 가지 경로가 있습니다:
- 빠른 경로 —
@roottale/cms-renderer-next의 RSC 컴포넌트 사용. 데이터 fetch + 렌더링까지 한 번에. - 커스텀 경로 —
@roottale/cms-client로 raw 데이터를 가져와 자체 UI로 렌더링. 디자인을 완전히 통제할 때.
본문 렌더링은 두 경로 모두 RootTaleBlogPost(블록 JSON → React)를 쓰는 것을
권장합니다. 본문 JSON 스키마를 직접 파싱하지 마세요.
빠른 경로 — 컴포넌트
목록 페이지
// 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}`}
/>
);
}
상세 페이지
// 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+):
// 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
// 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 오버라이드 |
정적 경로 사전 생성 + 메타데이터
// 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로 승계됩니다:
// 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로 확인할 수 있습니다.