블로그 연동
두 가지 경로가 있습니다:
- 빠른 경로 —
@roottale/cms-renderer-next의 RSC 컴포넌트 사용. 데이터 fetch + 렌더링까지 한 번에. - 커스텀 경로 —
@roottale/cms-client로 raw 데이터를 가져와 자체 UI로 렌더링. 디자인을 완전히 통제할 때.
본문 렌더링은 두 경로 모두 RootTaleBlogPost(블록 JSON → React)를 쓰는 것을
권장합니다. 본문 JSON 스키마를 직접 파싱하지 마세요.
본문 서식과 재사용 블록
ROOT-ADMIN의 글씨 크기·서식 지우기·줄간격·문단 뒤 간격과 이미지 너비·정렬·사진
설명은 공통 에디터에서 저장합니다. textStyle.attrs.fontSize, 문단·제목의
lineHeight·paragraphSpacing, 이미지의 displayWidth·imageAlign·caption을
RenderTiptap과 RootTaleBlogPost가 출력합니다. 별도 JSON 렌더러를 만들거나
본문의 style을 일괄 제거하면 이 서식이 사라집니다. 크기를 지정하지 않은 기존 글은
사이트 기본 CSS를 계속 따릅니다. 사진 원본 width·height는 유지합니다.
관리자는 선택한 내용을 재사용 블록 메뉴에서 현재 사이트의 공통 블록으로 저장할 수 있습니다. 저장에는 공통 블록 관리 권한이 필요하며, 삽입은 글을 편집할 수 있는 사용자에게 제공됩니다. 삽입한 내용은 독립 복사본이므로 원본 블록을 수정해도 이미 삽입한 글은 바뀌지 않습니다. 기존 글 하단의 공통 블록 배치 규칙과는 별개입니다.
공개 사이트는 아래 패키지를 같은 릴리즈 버전으로 업데이트하고 다시 배포하세요.
pnpm update @roottale/cms-client @roottale/cms-core @roottale/cms-renderer-next --latest
빠른 경로 — 컴포넌트
목록 페이지
// 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.path(ADR-0105)를 그대로 쓰고, 없을 때(구 서버·주소 규칙 없는 유형)만collections규칙 →/blog/{slug}순으로 폴백합니다.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참고.
상세 페이지
주소 규칙은 관리자 콘텐츠 모델(
blog)의 presentation이 정합니다. RootTale 표준 사이트는/blog/{category}/{slug}(카테고리 허브/blog/{category})이고, 이 문서의 예시는 평면/blog/{slug}상세 규칙(detail)을 쓰는 사이트 기준입니다. 어느 쪽이든 글 링크·canonical·사이트맵은 공개 APIpath(post.path)를 그대로 읽으면 됩니다 —content-models-and-exposures.md"목록·피드 규칙도 모델이 소유합니다" 참고. 표준 사이트의 라우트 파일 배치는 카테고리 허브app/blog/[category]/page.tsx(카테고리가 아니면 옛 글 주소로 보고 정본으로 301) + 글app/blog/[category]/[slug]/page.tsx입니다.
// app/blog/[slug]/page.tsx
import { fetchPost } from "@roottale/cms-client/server";
import { RootTaleBlogPost } from "@roottale/cms-renderer-next/server";
import { notFound } from "next/navigation";
export const revalidate = 1800;
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params; // Next.js 15+ async params
const post = await fetchPost({
apiKey: process.env.ROOTTALE_API_KEY!,
baseUrl: process.env.ROOTTALE_API_BASE,
slugOrId: slug,
});
if (!post) notFound();
return (
<RootTaleBlogPost
apiKey={process.env.ROOTTALE_API_KEY!}
baseUrl={process.env.ROOTTALE_API_BASE}
slugOrId={post.id}
showTableOfContents
tableOfContentsTitle="목차"
tocPosition="inline"
theme={null}
footerPatternPresentation={null}
relatedPostsCount={3}
breadcrumb={{ siteUrl: process.env.NEXT_PUBLIC_SITE_URL }}
/>
);
}
RootTaleBlogPost는 기본적으로 글 제목을 <h1>으로 출력합니다. CMS에서 검토일과
검토자를 지정한 글은 글 상단 메타 영역에 검토 이력도 자동으로 표시됩니다. 공통 배너나
페이지 전용 헤더에서 같은 제목을 직접 마크업한다면 showTitle={false}를
넘기세요. 이 옵션은 CMS 제목만 생략하며 요약·발행일·작성자와 본문은 그대로
렌더합니다.
상세 라우트는 렌더 전에 글을 조회하고, 없으면 Next.js notFound()를 호출해야
실제 HTTP 404가 됩니다. notFoundElement는 컴포넌트 안의 대체 화면일 뿐 응답
상태를 404로 바꾸지 못합니다.
카테고리가 있으면 H1 위에 링크로 표시됩니다. 메타 줄에는 발행일이 명시되고, 수정일의 달력 날짜가 발행일과 다를 때만 수정일을 따로 표시합니다.
편집자가 어드민 "관련 콘텐츠"에서 글을 골라 두면(유형 무관, 최대 10개) RootTaleBlogPost 는
relatedPostsCount 와 무관하게 그 글들을 고른 순서대로 글 하단에 그립니다(발행 글만, 저장된
공개 주소로 링크). 고른 것이 없을 때만 아래 자동 추천이 동작합니다.
relatedPostsCount(기본 0=off)를 주면 글 하단에 같은 카테고리 최근 글을 N개
<nav class="rt-cms-related"> 로 노출합니다(현재 글 제외, 발행일 내림차순).
제목은 relatedPostsTitle(기본 "관련 글"), 링크는 목록과 동일하게 postHref
또는 collections 로 라우팅됩니다. 현재 글에 카테고리가 없거나 후보가 없으면
렌더되지 않습니다.
목차 위치는 사이트 코드의 tocPosition="inline" | "sidebar"로 정합니다. 명시한
값은 CMS 설정보다 우선하며, 생략한 기존 연동은 CMS의 tocPosition을 유지합니다.
관리 주체를 코드로 옮길 때는 현재 화면의 배치 값을 명시하세요. 발행 페이지와
미리보기에는 같은 값을 적용합니다. theme={null}은 원격 테마 조회·스타일 주입을
끄고, footerPatternPresentation={null}은 공통 블록 디자인을 사이트 CSS에 맡깁니다.
두 prop을 생략한 기존 연동은 원격 테마·공통 블록 디자인을 계속 사용합니다.
목차(ToC) 노출·작성자 카드·발행일 표시는 어드민의 블로그 표시 설정으로도 제어됩니다
(theme-and-settings.md 참고). 여러 글에 같은 CTA가 필요하면 공통 블록의
post_footer 자리를 쓰세요. 공통 블록으로 아직 옮기지 않은 기존 사이트는
RootTaleBlogPost가 레거시 postCta를 계속 렌더하며, 공통 블록이 배치되면
레거시 CTA를 생략해 고객 화면에 두 개가 겹치지 않습니다.
목차 블록 (본문 임의 위치)
showTableOfContents 는 본문 상단에 목차를 자동으로 붙입니다. 글 안의
원하는 위치(예: 인트로 문단 다음)에 목차를 넣고 싶다면, 어드민 에디터에서
슬래시 메뉴 /목차 로 목차 블록(roottale/table-of-contents)을 삽입하세요.
렌더러가 그 위치에 문서의 h2–h4 제목으로 목차(<nav class="rt-cms-toc">)를
생성하고 각 제목에 앵커 id 를 부여합니다 — 상단 자동 ToC 와 동일 마크업·클래스라
스타일은 그대로 적용됩니다. 헤딩이 하나도 없으면 아무것도 렌더되지 않습니다.
블록을 본문에 직접 배치할 때는 상단 자동 ToC 와 중복되지 않도록
showTableOfContents 를 생략(기본 false)하는 것을 권장합니다.
블로그 글 구성 + 공식 출처
어드민 에디터의 슬래시 메뉴 /블로그 글 구성 또는 툴바의 블로그 글 구성을
누르면 인트로 → 목차 → H2 본문 → 공식 출처 → FAQ 골격이 한 번에 들어갑니다.
저장될 안내 문구는 넣지 않고 실제 작성 칸만 비워 둡니다.
/공식 출처는 references 블록을 삽입합니다. 학회·정부·연구기관처럼 본문
주장을 직접 뒷받침하는 원문을 불릿 링크로 적으세요. 공개 화면에서는
<section class="rt-cms-references">와 보이는 제목으로 렌더됩니다. SEO 점검은
이 블록 안에 외부 원문 링크가 있는지도 확인합니다.
이미지 삽입 시 대체 텍스트를 함께 입력합니다. 에디터와 공개 렌더러 모두 줄바꿈· 과도한 공백을 정리하고 최대 160자로 제한하므로, 이미지 주변 문단이나 캡션 전체를 복사하지 말고 이미지의 핵심 의미만 짧게 적으세요.
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:
작성자 사진은 같은 작성자 원장의 author_image_position_x/y를 초점으로 사용합니다.
관리자는 작성자 화면에서 사진과 초점을 함께 바꿀 수 있고, 원형·사각형 같은 모양은
각 사이트의 시멘틱 토큰/CSS가 정합니다. 구 서버처럼 좌표가 없으면 가운데(50/50)를
사용합니다.
<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으로 갱신하세요.
미리보기 페이지 — 관리자 미리보기 = 발행 결과
관리자(ROOT-ADMIN) 편집기의 사이트 화면 미리보기는 기본으로 관리자가
RootTale 기본 테마로 그린 화면을 엽니다. 사이트 고유 폰트·본문 너비·목차와는
다를 수 있어서, 사이트에 아래 라우트를 두면 사이트의 실제 글 템플릿으로
미리보기를 그리게 할 수 있습니다. 라우트를 배포한 뒤 관리자 설정 › 외부
연결(Webhook) › 글 미리보기 여는 곳에서 "홈페이지에서 열기"를 켜면 편집기의
미리보기가 https://{사이트}/preview/post/{글 ID}?token=… 으로 열립니다.
// app/preview/post/[id]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import {
fetchPostPreview,
isPreviewExpiredError,
} from "@roottale/cms-client/server";
import {
RootTaleBlogPost,
RootTalePreviewNotice,
} from "@roottale/cms-renderer-next/server";
import { buildPreviewMetadata } from "@roottale/cms-renderer-next/routes";
// 초안이 ISR·CDN 에 남으면 안 된다 — 항상 동적 렌더.
export const dynamic = "force-dynamic";
export const revalidate = 0;
interface Props {
params: Promise<{ id: string }>;
searchParams: Promise<{ token?: string }>;
}
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
const { token } = await searchParams;
const post = token
? await fetchPostPreview({
apiKey: process.env.ROOTTALE_API_KEY!,
baseUrl: process.env.ROOTTALE_API_BASE,
token,
}).catch(() => null)
: null;
return buildPreviewMetadata({ title: post?.title }); // 항상 noindex/nofollow
}
export default async function PostPreviewPage({ params, searchParams }: Props) {
const { id } = await params;
const { token } = await searchParams;
if (!token) notFound();
let expiresAt: string | undefined;
try {
const post = await fetchPostPreview({
apiKey: process.env.ROOTTALE_API_KEY!,
baseUrl: process.env.ROOTTALE_API_BASE,
token,
});
if (!post || post.id !== id) notFound(); // 토큰은 글 1건 전용
expiresAt = post.preview.expiresAt;
} catch (error) {
if (!isPreviewExpiredError(error)) throw error; // 만료는 컴포넌트가 안내
}
return (
<>
<RootTalePreviewNotice expiresAt={expiresAt} />
{/* 발행 글 상세와 같은 props 를 쓰되 slugOrId 대신 previewToken 만 넘긴다. */}
<RootTaleBlogPost
apiKey={process.env.ROOTTALE_API_KEY!}
baseUrl={process.env.ROOTTALE_API_BASE}
previewToken={token}
relatedPostsCount={3}
/>
</>
);
}
지켜야 할 것:
RootTaleBlogPost에previewToken을 주면slugOrId대신 미리보기 조회 (fetchPostPreview)를 쓰고 같은 템플릿으로 그립니다. 만료(410)면previewExpiredElement(기본 안내 문구), 없으면(404)notFoundElement.- 항상
dynamic = "force-dynamic"— 응답이no-store라도 페이지 자체가 정적 생성되면 안 됩니다. buildPreviewMetadata는 항상noindex, nofollow이며 canonical·og 를 내지 않습니다.robots.ts에도disallow: ["/preview/"]를 더하세요.- 토큰 없이 접근하면
notFound()— 주소만으로는 아무것도 보이지 않습니다. - 커스텀 템플릿(직접 fetch)이라면
fetchPostPreview가 돌려주는 값이fetchPost와 같은CmsPostContent형식(+preview)이므로 상세 렌더 함수를 그대로 재사용하면 됩니다. - 응답에는 저장 전 제목·요약·본문과 함께 커스텀 필드(
fields)·대표 이미지 (featuredImageUrl)도 편집 중인 값으로 담깁니다. 자기 주소가 없는 정보 항목(면허·연혁 등)은 그 항목이 들어가는 화면을 그려 보여 주면 됩니다.
전체 예시는 examples/nextjs/app/preview/post/[id]/page.tsx 에 있습니다.
다국어 (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 | 발행 시각 (UTC ISO). 화면에는 사이트 운영 시간대로 변환해서 표시 |
bodyJson | 본문 블록 JSON — RootTaleBlogPost 또는 renderBlocks로 렌더 |
terms | 분류 용어 배열 (taxonomy: "category" | "tag", name, slug) |
featuredImageUrl | 대표 이미지 |
authorName | 작성자 표시명 |
authorSlug | 작가 아카이브 slug(/blog/author/{slug}) — 미발급 작가는 null |
metaJson | 부가 메타 — metaJson.seo에 SEO 오버라이드 |
patternSlots | 공통 블록 자리별 블록 key({ post_footer: "clinic-guide" }) — RootTaleBlogPost가 자동 렌더, 자체 화면은 fetchSitePatterns + selectSitePatternForSlot(theme-and-settings.md "공통 블록") |
발행일 시간대 — 서버 기본값에 맡기지 않기
ROOT-ADMIN은 발행 시점을 UTC ISO 문자열로 저장하고, 관리자 화면에서는 한국 운영
시간대(Asia/Seoul)로 표시합니다. publishedAt은 날짜 문자열이 아니라 한 시점이므로,
커스텀 UI에서 new Date() 뒤 서버 기본 시간대로 포맷하거나 YYYY-MM-DD 부분만 자르면
UTC 자정 부근의 글이 관리자보다 하루 전 날짜로 보일 수 있습니다. Vercel 같은 서버
런타임은 UTC를 기본값으로 사용할 수 있으므로 실행 환경에 기대지 마세요.
사이트의 운영 시간대를 한 곳에 정하고 Intl.DateTimeFormat에 항상 명시합니다. 한국
ROOT-ADMIN 사이트의 기본 구현은 다음과 같습니다.
const SITE_TIME_ZONE = "Asia/Seoul";
export function formatPublishedDate(publishedAt: string): string {
const date = new Date(publishedAt);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: SITE_TIME_ZONE,
}).format(date);
}
<time dateTime={post.publishedAt}>
{formatPublishedDate(post.publishedAt)}
</time>;
dateTime에는 API 원본 ISO 시각을 그대로 둡니다.- 보이는 날짜에만 사이트 운영 시간대를 적용합니다.
- 목록 정렬·예약 판정은 보이는 날짜 문자열이 아니라 원본 시각으로 처리합니다.
- 최소 경계 확인값:
2026-08-22T21:40:50.666Z는 서울에서2026년 8월 23일이어야 합니다. - 다른 국가 사이트는
SITE_TIME_ZONE만 해당 지역의 IANA 시간대 이름으로 바꾸고, 목록·상세·OG 이미지 등 모든 날짜 표시가 같은 값을 사용하게 합니다.
정적 경로 사전 생성 + 메타데이터
// 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, ogImageAlt,
noindex, nofollow.
buildPostMetadata는 글 페이지에 Google 큰 이미지 미리보기
(max-image-preview:large)를 기본으로 허용합니다. ogImageAlt가 있으면 OG 이미지의
대체 텍스트로 사용하고, 없으면 검색 제목을 사용합니다.
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로 확인할 수 있습니다.