본문 바로가기

블로그 연동

블로그 목록/상세 페이지 구현 — 컴포넌트 빠른 경로와 직접 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, RootTaleBlogPostbreadcrumb 카테고리 세그먼트가 모두 기본적으로 이 경로를 링크합니다 (defaultCategoryHref). 단일 블로그 사이트도 예외가 아닙니다 — 이 라우트가 없으면 카테고리 칩·브레드크럼을 클릭했을 때 404 가 됩니다. 공지·블로그처럼 섹션을 나눈 사이트는 {basePath}/categories/{slug} 패턴(collections.md "URL이 어떻게 정해지나" 절)⁠을 대신 씁니다. 참조 구현은 예시 코드 app/blog/categories/[slug]/page.tsx(MCP tool readRootTaleNextjsExampleCode).

공지·블로그를 나눈 사이트(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)⁠을 그리고,
  • 글 단위 FAQPage JSON-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 을 안 주면 locale prop 기준으로 자동 번역됩니다(@roottale/cms-i18n ko/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 있으면 우선, 없으면 글 값으로 fallback
  • seo.canonical override → 없으면 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 확인할 수 있습니다.