블로그 연동
블로그 목록/상세 페이지 구현 — 컴포넌트 빠른 경로와 직접 fetch 커스텀 경로
두 가지 경로가 있습니다:
- 빠른 경로 —
@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}`}
/>
);
}
카테고리 허브 라우트(
/blog/categories/{slug})를 구현하세요.showCategoryFilter의 카테고리 칩,RootTaleBlogCategories,RootTaleBlogPost의breadcrumb카테고리 세그먼트가 모두 기본적으로 이 경로를 링크합니다 (defaultCategoryHref). 단일 블로그 사이트도 예외가 아닙니다 — 이 라우트가 없으면 카테고리 칩·브레드크럼을 클릭했을 때 404 가 됩니다. 공지·블로그처럼 섹션을 나눈 사이트는{basePath}/categories/{slug}패턴(collections.md"URL이 어떻게 정해지나" 절)을 대신 씁니다. 참조 구현은 예시 코드app/blog/categories/[slug]/page.tsx(MCP toolreadRootTaleNextjsExampleCode).
공지·블로그를 나눈 사이트(ADR-0060)는 목록을 반드시 섹션으로 스코프하세요.
RootTaleBlogList는 기본적으로 모든 글을 렌더하므로, 섹션을 나눴는데collection을 안 주면 공지 글이/blog목록에 섞여 나옵니다. 각 목록 페이지에서collection+collections를 넘겨 그 섹션 글만 보이게 하세요(글 링크도 소속 섹션 basePath 로 자동 라우팅). 단일 블로그 사이트는 지금처럼 안 줘도 됩니다.import { COLLECTIONS } from "@/lib/collections"; // collections.md 참고 // app/notice/page.tsx <RootTaleBlogList apiKey={apiKey} collection="notice" collections={COLLECTIONS} /> // app/blog/page.tsx <RootTaleBlogList apiKey={apiKey} collection="blog" collections={COLLECTIONS} showCategoryFilter />
/blog가 선언 섹션이 아니라 "나머지 전부"인 사이트(증상/질환/치료 같은 아카이브 스트림만 선언)는 반대로collections+excludeCollections로 어느 스트림에도 안 속한 글만 렌더하세요:<RootTaleBlogList apiKey={apiKey} collections={ARCHIVE_COLLECTIONS} excludeCollections showCategoryFilter />자세한 내용과
excludeCollections오적용 주의(blog자체가 선언 섹션인 사이트에 쓰면 목록이 빈다)는collections.md참고.
상세 페이지
// 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="목차"
relatedPostsCount={3}
/>
);
}
RootTaleBlogPost는 기본적으로 글 제목을 <h1>으로 출력합니다. 공통 배너나
페이지 전용 헤더에서 같은 제목을 직접 마크업한다면 showTitle={false}를
넘기세요. 이 옵션은 CMS 제목만 생략하며 요약·발행일·작성자와 본문은 그대로
렌더합니다.
relatedPostsCount(기본 0=off)를 주면 글 하단에 같은 카테고리 최근 글을 N개
<nav class="rt-cms-related">로 노출합니다(현재 글 제외, 발행일 내림차순).
제목은 relatedPostsTitle(기본 "관련 글"), 링크는 목록과 동일하게 postHref
또는 collections로 라우팅됩니다. 현재 글에 카테고리가 없거나 후보가 없으면
렌더되지 않습니다.
목차(ToC)·작성자 카드·발행일 표시는 어드민의 블로그 표시 설정으로도 제어됩니다
(theme-and-settings.md 참고). 어드민 설정 > 블로그에서 글 하단 CTA(제목·설명·
버튼)를 켜면 RootTaleBlogPost가 모든 글 본문 끝에 같은 CTA 블록을 자동으로
렌더합니다 — 별도 연동 코드는 필요 없습니다.
목차 블록 (본문 임의 위치)
showTableOfContents는 본문 상단에 목차를 자동으로 붙입니다. 글 안의
원하는 위치(예: 인트로 문단 다음)에 목차를 넣고 싶다면, 어드민 에디터에서
슬래시 메뉴 /목차로 목차 블록(roottale/table-of-contents)을 삽입하세요.
렌더러가 그 위치에 문서의 h2–h4 제목으로 목차(<nav class="rt-cms-toc">)를
생성하고 각 제목에 앵커 id 를 부여합니다 — 상단 자동 ToC 와 동일 마크업·클래스라
스타일은 그대로 적용됩니다. 헤딩이 하나도 없으면 아무것도 렌더되지 않습니다.
블록을 본문에 직접 배치할 때는 상단 자동 ToC 와 중복되지 않도록
showTableOfContents를 생략(기본 false)하는 것을 권장합니다.
FAQ 블록 (자주 묻는 질문 + 구조화데이터)
어드민 에디터의 슬래시 메뉴 /FAQ로 FAQ 블록(roottale/faq)을 삽입하면,
발행 시 RootTaleBlogPost / renderBlogPost가 자동으로:
<details class="rt-cms-faq-item"><summary class="rt-cms-faq-q">질문</summary> <div class="rt-cms-faq-a">답변</div></details>아코디언(JS 0)을 그리고,- 글 단위
FAQPageJSON-LD(<script type="application/ld+json">)를 본문 끝에 1개 삽입합니다 — 구글 FAQ 리치결과/AI 검색 인용 대상.
별도 연동·키 없이 동작하며, 업그레이드 후 기존 글에 FAQ 블록이 있으면 즉시
반영됩니다. 스타일은 cms-public.css의 .rt-cms-faq* 클래스가 테마 토큰
(--rt-color-*)을 따르므로 추가 CSS 없이 적용됩니다. 직접 렌더 경로를 쓰는
경우 @roottale/cms-core의 extractFaqEntries(doc) / faqPageJsonLd(entries)
로 동일한 JSON-LD 를 생성할 수 있습니다.
저자 링크 + 브레드크럼 (avcd 구조)
발행 글에 author_slug(작가 아카이브 slug)가 있으면 RootTaleBlogPost가
헤더 메타·저자 카드의 작성자 이름을 자동으로 <a href="/blog/author/{slug}">
로 감쌉니다. slug 가 없는 작가는 지금처럼 plain text 로 렌더됩니다. 링크
대상을 바꾸려면 authorHref:
<RootTaleBlogPost
apiKey={apiKey}
slugOrId={slug}
authorHref={(slug) => `/authors/${slug}`} // 기본 /blog/author/{slug}
/>
breadcrumb prop(opt-in, 기본 off)을 주면 시각 브레드크럼(홈 › 블로그 ›
카테고리 › 글 제목)을 본문 위에 렌더하고, siteUrl을 함께 주면
BreadcrumbList JSON-LD 도 같이 emit합니다:
<RootTaleBlogPost
apiKey={apiKey}
slugOrId={slug}
breadcrumb={{ siteUrl: process.env.NEXT_PUBLIC_SITE_URL }}
// collections 를 함께 주면 "블로그" 세그먼트가 글의 소속 섹션 basePath로
// 자동 해석됩니다(공지 글 → /notice). showCategory:false 로 카테고리
// 세그먼트 생략 가능, categoryHref로 링크 override 가능.
/>
마지막 항목(카테고리 링크, showCategory 기본 true)은 defaultCategoryHref
(/blog/categories/{slug})를 씁니다 — 이 라우트를 구현해야 브레드크럼·
카테고리 칩 클릭이 404 가 나지 않습니다(아래 "카테고리 허브 라우트" 참고).
마크업 변경 주의 — 헤더 발행일이
<span data-rt-cms-meta="date">에서<time dateTime="…" data-rt-cms-meta="date">로 바뀌었습니다.class·data-rt-cms-meta속성은 그대로라 속성 선택자로 스타일링했다면 영향 없지만, 태그명(span)을 직접 선택자로 쓴 고객 CSS/스크립트가 있다면time으로 갱신하세요.
다국어 (ADR-0052 A안, W4-6 PR C1/C2)
번역 사이트(어드민에서 "번역 추가"로 언어판을 만든 글)는 RootTaleBlogList/
RootTaleBlogPost/RootTalePage에 locale prop 을 넘기면 그 언어판만
fetch 합니다(fetchPosts/fetchPost의 locale 쿼리 그대로 전달). 미지정
시 서버가 사이트 기본 locale 만 반환합니다(하위호환).
// app/[locale]/blog/[slug]/page.tsx — starter 참조 구현 패턴
const { locale, slug } = await params;
<RootTaleBlogPost
apiKey={process.env.ROOTTALE_API_KEY!}
slugOrId={slug}
locale={locale}
showTableOfContents
/>;
- URL 전략(권장, starter 참조 구현) — 기본 locale 은 prefix 없는 기존
경로(
/blog/{slug}) 그대로 두고, 나머지 locale 만app/[locale]/...로 별도 트리를 둡니다(app/[locale]/blog/·app/[locale]/blog/[slug]/·app/[locale]/[slug]/). 사이트 활성 locale 목록은 별도 API 없이fetchBlogSettings().sitemap.locales(SSoT =sites.settingsJson.locales) 로 얻습니다 — 첫 원소가 기본 locale. <head>hreflang · 사이트맵 hreflang 은 "SEO 연동" 문서(seo.md)의 "다국어 (hreflang)" 절 참고.- UI 문자열(cms-i18n, ADR-0034 §3) —
RootTaleBlogList.emptyMessage/allCategoriesLabel,RootTaleBlogPost.relatedPostsTitle/미발견 안내,RootTalePage미발견 안내,RootTaleTableOfContents.title은 명시 prop 을 안 주면localeprop 기준으로 자동 번역됩니다(@roottale/cms-i18nko/en 사전,ja/zh-hans등 사전 없는 locale 은 ko 로 fallback). 글 본문·admin UI 는 이 자동 번역 대상이 아닙니다(사람이 직접 작성/ADR-0052 범위 밖).
고정 페이지 (회사소개 등)
어드민의 고정 페이지(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 |
작성자 표시명 |
authorSlug |
작가 아카이브 slug(/blog/author/{slug}) — 미발급 작가는 null |
metaJson |
부가 메타 — metaJson.seo에 SEO 오버라이드 |
정적 경로 사전 생성 + 메타데이터
// app/blog/[slug]/page.tsx (커스텀 UI 버전)
import type { Metadata } from "next";
import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";
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) override 적용 + self-canonical.
// canonical/robots/openGraph 분기를 직접 안 써도 한 곳도 빠뜨리지 않습니다.
return buildPostMetadata(post, {
siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
path: `/blog/${post.slug}`,
});
}
buildPostMetadata(post, opts)는 어드민 SEO 패널 값을 Next Metadata로
한 줄 변환합니다:
seo.title/seo.description/seo.ogImage가 있으면 우선, 없으면 글 값으로 fallbackseo.canonicaloverride → 없으면siteUrl+path로 self-canonical 자동 생성seo.noindex/seo.nofollow중 하나라도 켜지면robots출력openGraph(article·publishedTime·images) 자동 구성
입력은 { title, description?, date?, image?, seo? } 형태면 되고
(getPost의 BlogPostMeta가 그대로 호환), 원본 post 를 쓸 땐
seo로 넘길 값은 (post.metaJson as { seo?: ... }).seo입니다.
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로 확인할 수 있습니다.