본문 바로가기

SEO (RSS⁠·사이트맵⁠·JSON‑LD)

RSS 피드, 사이트맵, JSON-LD 스키마, fleet 프로브 라우트

SEO — RSS·사이트맵·JSON-LD

@roottale/cms-renderer-next/routes의 팩토리로 RSS·사이트맵을 한 줄에 구성하고, @roottale/cms-client/server 헬퍼로 JSON-LD를 생성합니다.

RSS 피드

// app/feed.xml/route.ts
import { createFeedRoute } from "@roottale/cms-renderer-next/routes";

export const dynamic = "force-dynamic";

export const GET = createFeedRoute({
  apiKey: process.env.ROOTTALE_API_KEY!,
  apiBase: process.env.ROOTTALE_API_BASE,
  siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
  title: "예시 블로그",
  description: "예시 블로그 설명",
});

발행된 글이 자동 포함된 RSS 2.0 XML을 반환합니다.

사이트맵

createSitemapIndex 사이트맵 인덱스(/sitemap.xml)와 섹션별 하위 사이트맵 (/sitemap/static.xml·/sitemap/blog.xml·/sitemap/categories.xml)⁠을 만듭니다. 하나의 거대한 파일 대신 섹션별로 나뉘어 검색엔진이 더 잘 크롤하고, 50,000 URL/50MB 한도에도 안전합니다. 발행 글·카테고리는 자동 포함됩니다.

// app/sitemap.ts
import { createSitemapIndex } from "@roottale/cms-renderer-next/routes";

const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;

const { generateSitemaps, sitemap } = createSitemapIndex(
  {
    apiKey: process.env.ROOTTALE_API_KEY!,
    apiBase: process.env.ROOTTALE_API_BASE,
    siteUrl: SITE_URL,
    title: "예시 사이트",
  },
  [
    // 정적 경로 — 발행 글·카테고리 URL은 자동 추가됨
    { url: SITE_URL },
    { url: `${SITE_URL}/blog` },
    { url: `${SITE_URL}/contact` },
  ],
);

// Next 16: 인덱스를 만들려면 generateSitemaps·default 를 둘 다 export.
export { generateSitemaps };
export default sitemap;
  • 블로그 이미지 색인 — 글에 대표 이미지가 있으면 <image:image> 함께 색인합니다. 어드민 설정 > 블로그 > 사이트맵에서 켜고 끌 수 있어요(기본 켜짐).
  • 분류·작가 모음 페이지는 기본으로 사이트맵에서 빠집니다 — 어드민에서 "검색에 노출"을 켠 것만 들어갑니다. 아래 "색인 위생" 절을 보세요.
  • changeFrequency/priority Google이 무시하므로 더 이상 내보내지 않습니다(loc + lastmod + 이미지만).
  • 단일 평면 사이트맵이 필요하면 레거시 createSitemap(default export 하나)⁠도 그대로 동작하지만, 신규 사이트는 createSitemapIndex 권장합니다.

작가 아카이브 (/blog/author/{slug})

어드민 설정 > 팀에서 작가에게 주소(slug)⁠를 발급하면, 그 작가의 글 모음 페이지와 작가 사이트맵(/sitemap/authors.xml)을 만들 수 있습니다. 어드민 설정 > 블로그 > 사이트맵에서 "작가 사이트맵"을 켜면 인덱스에 authors 섹션이 추가됩니다.

// app/blog/author/[slug]/page.tsx
import { notFound } from "next/navigation";
import { fetchAuthors, profilePageSchema } from "@roottale/cms-client/server";
import { RootTaleBlogList } from "@roottale/cms-renderer-next/server";

const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;

export default async function AuthorArchive({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const authors = await fetchAuthors({ apiKey: process.env.ROOTTALE_API_KEY! });
  const author = authors.find((a) => a.slug === slug);
  if (!author) notFound();

  const jsonLd = profilePageSchema({
    url: `${SITE_URL}/blog/author/${slug}`,
    name: author.name,
    image: author.imageUrl,
    description: author.bio,
  });

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <h1>{author.name}</h1>
      {author.bio ? <p>{author.bio}</p> : null}
      {/* author= 로 그 작가의 발행 글만 렌더 */}
      <RootTaleBlogList apiKey={process.env.ROOTTALE_API_KEY!} author={slug} />
    </>
  );
}
  • fetchAuthors() slug가 있고 발행 글이 1건 이상인 작가만 반환합니다 (GET /v1/cms/public/authors).
  • RootTaleBlogList author={slug} 주면 그 작가의 글만 가져옵니다 (GET /v1/cms/public/posts?author={slug}).
  • 글 상세의 articleSchema authorUrl: ${SITE_URL}/blog/author/${post.authorSlug} 를 넘기면 글의 author 엔티티가 이 작가 페이지의 ProfilePage.mainEntity 와 같은 @id({authorUrl}#person)로 연결됩니다.

카테고리 허브 (/blog/categories/{slug})

RootTaleBlogList 카테고리 칩, RootTaleBlogCategories, RootTaleBlogPostbreadcrumb 카테고리 세그먼트가 모두 기본적으로 /blog/categories/{slug} 를 링크합니다(defaultCategoryHref)단일 블로그 사이트도 이 라우트를 구현해야 링크가 404 나지 않습니다. 참조 구현은 예시 코드 app/blog/categories/[slug]/page.tsx(MCP tool readRootTaleNextjsExampleCode). 공지·블로그처럼 섹션을 나눈 사이트는 {basePath}/categories/{slug} 패턴 (collections.md "URL이 어떻게 정해지나" 절)⁠을 대신 씁니다.

목록 페이지용 JSON-LD는 collectionPageSchema:

import { collectionPageSchema } from "@roottale/cms-client/server";

const jsonLd = collectionPageSchema({
  url: `${SITE_URL}/blog/categories/${category.slug}`,
  name: `${category.name} 글 모음`,
  siteUrl: SITE_URL,
});

색인 위생 — 모음 페이지는 기본 "검색 노출 안 함"

분류·태그·작가·검색 결과처럼 글을 모아 보여주는 페이지는 내용이 겹치기 쉬워, 전부 검색에 올리면 오히려 사이트 평가가 내려갑니다. 그래서 RootTale은 모음 페이지를 기본으로 검색에서 빼고, 소개 글을 채워 대표로 삼은 분류만 올립니다.

  • 켜는 곳: 어드민 내 사이트 > 카테고리에서 분류를 펼치고 "검색에 이 분류 페이지 노출"을 체크합니다. 켠 분류만 사이트맵에 들어가고 robots 색인 허용이 됩니다.
  • 작가 모음은 설정 > 블로그 > 사이트맵의 "작가별 글 모음도 검색엔진에 알리기"가 같은 역할을 합니다.
  • 글 자체는 영향을 받지 않습니다 — 모음 페이지만 빠집니다.

판정은 decideArchiveIndex 하나에서 나옵니다. 사이트맵 필터와 페이지의 robots 같은 함수를 보므로 "검색에서 빼기로 해놓고 사이트맵에는 넣는" 어긋남이 생기지 않습니다. 단, 같은 함수를 쓰는 것만으로는 부족하고 판정에 넣는 값도 같아야 합니다. 그래서 입력을 만드는 세 가지도 함께 제공합니다:

  • resolveCategoryPromotion(slugs, slug) — 노출 설정을 3단계 (promoted / not-promoted / unknown)⁠로 바꿉니다. 설정을 못 읽었을 때 unknown 그대로 넘기면 정책이 기존 상태를 보존합니다. 여기서 임의로 "안 켬"으로 바꾸면, 설정 조회가 한 번 실패했다는 이유만으로 이미 검색에 올라가 있던 주소에 noindex 가 붙습니다.
  • fetchBlogArchiveCategories(config)/blog 분류 모음 페이지가 다루는 글 에서 분류와 글 수를 뽑습니다. 사이트맵도 같은 함수를 씁니다. 직접 글을 받아 세지 마세요 — 세는 글 집합이 갈리면 "사이트맵엔 주소가 있는데 페이지는 404" 가 생깁니다.
  • ARCHIVE_POST_COUNT_LIMIT — 아래 폴백 경로가 쓰는 글 개수 한도. 직접 셀 때도 같은 상수를 쓰세요.

글 수는 GET /v1/cms/public/categories 서버에서 세어 내려줍니다. 글이 몇 건이든 모든 분류의 정확한 개수가 나오므로, 오래된 글에만 달린 분류가 검색에서 사라지지 않습니다. fetchBlogArchiveCategories 이 API를 먼저 쓰고, 못 받으면 (구버전 API이거나 일시 장애) 예전처럼 최근 글 ARCHIVE_POST_COUNT_LIMIT 건을 받아 세는 방식으로 되돌아갑니다 — 사이트맵과 페이지가 여전히 같은 함수를 쓰므로 어느 경로든 두 곳의 결론은 같습니다.

어떤 글을 세나요: /blog 계열 모음 페이지는 "콘텐츠 유형(스트림)⁠에 속하지 않은 글"만 다룹니다. 증상·질환 같은 별도 유형을 쓰는 사이트에서 그 유형 글에만 달린 분류는 /blog/categories/{slug} 볼 수 없으므로(그 유형은 자기 주소가 따로 있습니다) 사이트맵에서도 빠집니다. fetchBlogArchiveCategories 이 범위를 서버에 물어보므로(exclude_collections), 어떤 유형을 만들었든 두 곳이 항상 같은 글을 셉니다. 유형별 모음 페이지({유형주소}/categories/{slug})는 collectCollectionCategories(posts, collections, key) 가 짝입니다.

// app/blog/categories/[slug]/page.tsx
import { notFound } from "next/navigation";
import {
  buildArchiveMetadata,
  decideArchiveIndex,
  fetchBlogArchiveCategories,
  resolveCategoryPromotion,
} from "@roottale/cms-renderer-next/routes";
import { fetchBlogSettings } from "@roottale/cms-client/server";

const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
const apiKey = process.env.ROOTTALE_API_KEY!;

async function archiveState(slug: string) {
  const [settings, categories] = await Promise.all([
    // 설정 조회 실패는 "안 켬"이 아니라 "모름"입니다 — undefined 로 넘깁니다.
    fetchBlogSettings({ apiKey }).catch(() => null),
    // 사이트맵과 같은 함수 = 같은 글 집합, 같은 한도.
    fetchBlogArchiveCategories({ apiKey }),
  ]);
  return {
    promotion: resolveCategoryPromotion(
      settings?.sitemap?.promotedCategorySlugs,
      slug,
    ),
    // 이 라우트가 모르는 분류면 0건 → 아래에서 404.
    postCount: categories.find((c) => c.slug === slug)?.count ?? 0,
  };
}

export async function generateMetadata({ params }) {
  const { slug } = await params;
  const { promotion, postCount } = await archiveState(slug);
  return buildArchiveMetadata(
    { kind: "category", promotion, postCount, title: `${slug} 글 모음` },
    { siteUrl: SITE_URL, path: `/blog/categories/${slug}` },
  );
}

export default async function CategoryArchive({ params }) {
  const { slug } = await params;
  const { promotion, postCount } = await archiveState(slug);
  // 글이 하나도 없는 모음 페이지는 만들지 않습니다(404).
  if (decideArchiveIndex({ kind: "category", promotion, postCount }).render === "notFound") {
    notFound();
  }
  // ... 목록 렌더
}

정책 요약(decideArchiveIndex):

상황 화면 검색 노출 사이트맵
글 0건 (검색 결과 제외) 404
2페이지 이상 정상
태그·날짜·사이트 내 검색 정상
분류·작가 — 노출 켬(promoted) 정상
분류·작가 — 노출 끔(not-promoted, 기본) 정상
분류·작가 — 설정 모름(unknown) 정상

승격 목록은 GET /v1/cms/public/blog-settings 응답의 sitemap.promoted_category_slugs(cms-client에서는 sitemap.promotedCategorySlugs) 로 내려옵니다. 이 필드가 아예 없는 구버전 API에 붙어 있거나 설정 조회에 실패하면 resolveCategoryPromotion unknown 돌려주고, 렌더러는 정책을 적용하지 않고 기존처럼 모든 분류를 사이트맵에 넣습니다(모른다고 이미 색인된 주소를 지우지 않기 위해서입니다). 빈 배열 [] "승격 0건"이라는 확정 정보라 의미가 다릅니다.

구버전 클라이언트 호환: sitemap.promoted_category_slugs 응답에 새로 생긴 키입니다. @roottale/cms-client 응답에서 아는 필드만 골라 쓰고 모르는 키는 무시하므로(스키마 전체 검증을 하지 않습니다) 구버전 사이트를 그대로 두어도 이 키 때문에 깨지지 않습니다 — 사이트를 올리지 않으면 이 절의 색인 정책이 적용되지 않을 뿐입니다.

한도 안내: 분류별 글 수는 GET /v1/cms/public/categories 서버에서 세므로 글이 아무리 많아도 누락되지 않습니다. 다만 그 API를 못 받아 폴백으로 도는 동안 (구버전 API·일시 장애)에는 최근 ARCHIVE_POST_COUNT_LIMIT 건만 보게 되어, 한도 밖에만 글이 있는 분류가 사이트맵과 페이지 양쪽에서 빠집니다(페이지는 404). 양쪽이 같은 기준으로 판단하므로 색인 어긋남은 이때도 생기지 않습니다.

유형별 모음 페이지({유형주소}/categories/{slug})를 만드는 collectCollectionCategories 아직 최근 글 배치만 씁니다. 서버 집계 API는 collection_key 유형 범위를 지정할 수 있으므로 같은 방식으로 옮길 예정입니다.

어긋남이 잠깐 보일 수 있는 경우: 사이트맵과 모음 페이지는 서로 다른 요청에서 각자 설정을 읽습니다. 한쪽만 조회에 실패하면 그 사이에는 "검색에서 뺐는데 사이트맵엔 남아 있는" 상태가 생길 수 있습니다. 사이트맵이 다시 만들어질 때 자동으로 맞춰지며, 걸리는 시간의 상한 = 사이트맵의 revalidate입니다 (Site Kit 기본 60초). 조회 실패 때 주소를 빼지 않고 남기는 쪽을 택한 이유는, 일시적인 장애 한 번으로 이미 검색에 올라간 주소를 사이트맵에서 지우는 손실이 더 크기 때문입니다.

다국어 (hreflang) — ADR-0052 A안, 콘텐츠 다국어

번역 글이 있는 사이트는 어드민 설정 > 블로그 > 사이트맵의 "다국어 로케일"에 언어 코드를 입력합니다(예: ko, ja, en, 첫 번째 = 기본 locale). 사이트맵의 blog 섹션(GET /sitemap/blog.xml)이 글 단위 실제 번역 데이터로 hreflang 대체 언어 링크(<xhtml:link rel="alternate">)를 계산합니다:

  • 공개 API(GET /v1/cms/public/posts)가 글마다 translations(발행된 언어판 목록, 자기 자신 포함)⁠를 내려줍니다. 언어판이 없는 글(자기 자신 1건뿐)⁠은 hreflang alternates 자체가 생략됩니다 — 존재하지 않는 URL 을 검색엔진에 알리는 사고를 원천 차단합니다(옛 slug 1:1 path-prefix 가정 제거).
  • 기본 locale = prefix 없는 경로 + x-default. 나머지는 경로 접두사 (ko 기본이면 ja 언어판은 /ja/blog/{그-언어판-실제-slug}) — 언어판마다 독립된 slug를 가질 수 있습니다(동일 slug 강제 아님).
  • 실제 번역 라우트를 그 경로(/ja/...)에 서빙하는 것은 사이트 쪽 책임 입니다 — starter 참조 구현은 app/[locale]/blog/·app/[locale]/blog/[slug]/· app/[locale]/[slug]/(기본 locale 은 prefix 없는 기존 경로 그대로).
  • static/categories/authors 섹션은 hreflang alternates 를 붙이지 않습니다 — v1 은 블로그 글 + CMS [slug] 페이지만 번역 대상이라(nav 정적 경로·카테고리 아카이브·작가 아카이브는 locale 라우트 자체가 없음), 옛 방식대로 path-prefix 를 씌우면 존재하지 않는 URL 의 hreflang 이 됩니다.
  • 로케일을 비워두면(또는 기본 locale 조차 발행 글 0건이면) 단일 언어로 동작합니다(alternates 미부착, fail-closed).

글 상세 <head> hreflang 은 buildPostMetadata(아래 "글 메타데이터" 절 참고)⁠의 locales/translations 옵션으로 계산합니다 — 사이트맵과 같은 원본 데이터 (translations[])를 씁니다.

다중 스트림 (collections) — 공지·블로그 분리

같은 글 풀을 공지 게시판(/notice) + 블로그(/blog) 등 여러 섹션으로 나눠 서로 다른 URL·레이아웃으로 보여줄 수 있습니다. 섹션은 글의 collection_key 정해지고, 카테고리는 섹션 안의 주제(아카이브)입니다. URL 구조·어드민 설정·연동 코드(코드 상수 vs 어드민 fetch)·가드·동적 basePath는 별도 문서 콘텐츠 유형 (Collections) 에 정리되어 있습니다. 여기 sitemap/feed 예시도 collections 넘기면 섹션별로 파생됩니다.

robots.txt

크롤링 제어의 기본. sitemap 위치를 알려주고, 크롤링이 무의미한 경로만 차단합니다. CSS/JS/이미지 경로를 차단하지 마세요 — 구글이 페이지를 렌더링하지 못해 평가가 깨집니다. 색인 제외가 목적이면 robots.txt 차단이 아니라 페이지의 noindex 를 쓰세요 (외부 링크가 있으면 차단해도 색인될 수 있습니다).

// app/robots.ts
import type { MetadataRoute } from "next";

const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: "*", allow: "/", disallow: ["/api/"] }],
    sitemap: `${SITE_URL}/sitemap.xml`,
  };
}

브레드크럼 (BreadcrumbList)

사이트 구조를 검색엔진에 전달하고 검색결과에 경로가 표시됩니다.

빠른 경로RootTaleBlogPost 쓰면 breadcrumb prop 하나로 시각 브레드크럼 + BreadcrumbList JSON-LD 를 함께 얻습니다(opt-in, 기본 off):

<RootTaleBlogPost
  apiKey={apiKey}
  slugOrId={slug}
  breadcrumb={{ siteUrl: SITE_URL }} // siteUrl 없으면 시각 브레드크럼만
/>

카테고리 세그먼트는 글의 첫 category term 을 자동으로 쓰고 (showCategory: false 생략 가능), 링크는 defaultCategoryHref (/blog/categories/{slug})를 씁니다 — blog.md "카테고리 허브 라우트" 참고. 자세한 옵션(homeLabel/listHref/categoryHref/collections 연동)⁠은 blog.md "저자 링크 + 브레드크럼" 절 참고.

커스텀 경로 — 직접 마크업하는 경우 breadcrumbSchema JSON-LD 만 생성하세요(UI 브레드크럼과 구조 일치 권장):

import { breadcrumbSchema } from "@roottale/cms-client/server";

const category = post.terms.find((t) => t.taxonomy === "category");
const crumbs = breadcrumbSchema([
  { name: "홈", url: SITE_URL },
  { name: "블로그", url: `${SITE_URL}/blog` },
  ...(category
    ? [{ name: category.name, url: `${SITE_URL}/blog/categories/${category.slug}` }]
    : []),
  { name: post.title, url: `${SITE_URL}/blog/${post.slug}` },
]);

블로그 목록 페이지네이션 주의

  • 마지막 페이지에 다음(next) 링크를 렌더하지 마세요 — 같은 페이지가 반복 노출되면 크롤 낭비·중복 신호가 됩니다.
  • 필터·정렬로 내용이 바뀌면 URL(쿼리)⁠도 함께 바뀌어야 하고, canonical 은 필터 없는 기본 목록을 가리키게 하세요.
  • 검색결과(사이트 내 검색) 페이지는 noindex 처리하세요 — 특히 결과 0건 페이지가 색인되면 저품질(소프트 404) 신호가 됩니다.

RSS/사이트맵과 웹훅

발행 웹훅의 alsoRevalidate /feed.xml, /sitemap.xml 포함해 글 변경 시 함께 갱신하세요 (revalidation-webhooks.md 참고).

글 메타데이터 (canonical·robots·OG)

블로그 글 상세의 generateMetadata에서 어드민 SEO 패널(metaJson.seo) 값을 buildPostMetadata 한 줄 변환합니다. canonical/robots/openGraph 분기를 직접 쓰면 한 곳이라도 빠뜨려 SEO 가 새기 쉬운데(특히 noindex 누락·canonical 미설정), 이 헬퍼로 표준화합니다.

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};
  return buildPostMetadata(post, {
    siteUrl: process.env.NEXT_PUBLIC_SITE_URL, // self-canonical 기본값 origin
    path: `/blog/${post.slug}`, // 현재 slug 기준
  });
}
  • seo.title/seo.description/seo.ogImage override → 없으면 글 값 fallback
  • canonical: seo.canonical override → 없으면 siteUrl+path self-canonical 자동 생성(모든 글이 자기 자신을 가리키는 canonical 을 갖도록 — 권장). siteUrl/path 안 넘기면 override 가 있을 때만 canonical 출력.
  • seo.noindex/seo.nofollow 중 하나라도 켜지면 robots 출력
  • openGraph(type:article·publishedTime·images) 자동 구성
  • avcd 구조siteUrl 있으면 canonical 해석 성공 여부와 무관하게 alternates.types["application/rss+xml"](기본 /feed.xml, feedPath 옵션으로 override)⁠을 항상 emit합니다. twitter(summary_large_image)도 og 와 같은 소스(seo override → 글 값)⁠로 항상 채워집니다 — card+title 은 og 이미지가 없어도 emit, images og 이미지가 있을 때만.
  • avcd 구조 — og article 확장 — 입력에 modified(→ openGraph.modifiedTimesection(→ openGraph.sectiontags(문자열 배열 → openGraph.tagsauthors(저자 프로필 URL 배열 → openGraph.authors)⁠를 넘기면 그대로 추가됩니다. 전부 선택 — 미지정/빈 배열은 생략(기존 호출부 diff 0).
return buildPostMetadata(post, {
  siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
  path: `/blog/${post.slug}`,
  modified: post.modified, // updatedAt ISO
  section: post.category || undefined,
  tags: post.tags.map((t) => t.name),
  authors: post.authorSlug
    ? [`${SITE_URL}/blog/author/${post.authorSlug}`]
    : undefined,
  feedPath: "/feed.xml", // 기본값 — 다중 스트림이면 섹션별 피드로 override
});

입력은 { title, description?, date?, image?, seo? } 구조면 됩니다(getPostBlogPostMeta 호환). 원본 post 를 쓸 땐 seo: (post.metaJson as { seo?: PostSeoOverrides }).seo 로 넘기세요. path redirect 후의 현재 slug(post.slug) 기준으로 주세요(아래 301 참고).

공지·블로그 다중 스트림 (ADR-0060)

섹션을 나눈 사이트(공지 /notice + 블로그 /blog)⁠는 path 직접 쓰지 말고 collections 넘기세요. 글의 collectionKey 소속 섹션 basePath 를 찾아 canonical 을 공지/블로그로 구분해 해석합니다(공지 글 → /notice/{slug}, 블로그 글 → /blog/{slug}). path /blog/... 하드코딩하면 공지 글이 잘못된 블로그 canonical 을 갖게 됩니다.

import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";
import { COLLECTIONS } from "@/lib/collections"; // collections.md 참고

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};
  return buildPostMetadata(post, {
    siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
    collections: COLLECTIONS, // collectionKey 로 /notice·/blog 자동 구분
  });
}

post.collectionKey(공개 API의 collection_key)⁠가 어느 섹션에도 안 맞으면 canonical 을 생략합니다(섹션 없는 글은 상세·sitemap에서 제외되는 규칙과 동일). 단일 블로그 사이트는 기존처럼 path: "/blog/" + post.slug 주면 됩니다.

다국어 hreflang (ADR-0052 A안, W4-6 PR C1)

번역 사이트는 translations(글 응답의 translations[])⁠와 locales 옵션을 넘기면 <head> 양방향 hreflang(+ x-default)⁠이 자동으로 붙습니다:

import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { locale, slug } = await params; // app/[locale]/blog/[slug]/page.tsx
  const post = await getPost(slug, locale);
  if (!post) return {};
  return buildPostMetadata(
    { ...post, translations: post.translations },
    {
      siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
      path: `/${locale}/blog/${post.slug}`,
      locales: { defaultLocale: "ko" }, // 사이트 활성 locale 의 첫 원소
    },
  );
}
  • pathFor 안 주면 기본값 기본locale → /blog/{slug}, 그 외 → /{locale}/blog/{slug}. 블로그가 아닌 라우트(CMS [slug] 페이지 등)⁠는 pathFor override 필수 — buildHreflangLanguages(translations, siteUrl, { defaultLocale, pathFor }) 를 직접 호출하면 OG type:"article" 없이 plain alternates 조립할 수 있습니다.
  • translations 없거나(구 서버) 자기 자신 1건뿐(번역 없음)이면 languages 자체가 생략됩니다 — 단일 언어 사이트는 기존 canonical-only 동작 그대로.

slug 변경 시 301 리다이렉트

글 slug를 바꿔도 옛 URL이 깨지지 않습니다. API가 slug history로 글을 찾아 현재 slug로 응답하므로, 페이지에서 요청 slug와 비교해 301을 보내세요:

import { notFound, permanentRedirect } from "next/navigation";
import { postRedirectPath } from "@roottale/cms-renderer-next/routes";

const post = await getPost(slug);
if (!post) notFound();
const redirect = postRedirectPath(post, slug);
if (redirect) permanentRedirect(redirect);

301이어야 기존 URL의 검색 순위·백링크가 새 URL로 승계됩니다 (사이트맵·RSS는 항상 현재 slug만 포함).

동적 OG 이미지 (글별 1200×630)

글마다 제목·날짜가 들어간 소셜 공유 카드를 자동 생성합니다 — 대표 이미지를 일일이 만들지 않아도 카카오톡·페이스북·X 공유 시 글 제목이 보이는 카드가 나갑니다. 한글 제목은 Noto Sans KR 부분셋을 런타임에 받아 렌더합니다.

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import {
  createPostOgImage,
  OG_IMAGE_SIZE,
  OG_IMAGE_CONTENT_TYPE,
  type ImageResponseLike,
} from "@roottale/cms-renderer-next/routes";

export const size = OG_IMAGE_SIZE; // { width: 1200, height: 630 }
export const contentType = OG_IMAGE_CONTENT_TYPE; // "image/png"

export default createPostOgImage(
  {
    apiKey: process.env.ROOTTALE_API_KEY!,
    siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
    title: "예시 블로그",
    // 선택 — 브랜드 색 커스텀:
    // backgroundColor: "#10172a", accentColor: "#38bdf8", brandLabel: "예시",
  },
  // Next 16 의 ImageResponse 타입은 패키지 ImageResponseLike 와 미묘하게 달라
  // cast 가 필요하다(패키지는 next 비의존이라 구조적 타입만 안다).
  { ImageResponse: ImageResponse as unknown as ImageResponseLike },
);
  • opengraph-image.tsx 파일 컨벤션이라 별도 meta 태그 없이 Next 가 og:image 자동 주입합니다 (twitter-image.tsx 복제하면 X 카드도).
  • 글이 없거나 API 실패 시 사이트 제목으로 fail-soft 렌더 — 빈 카드가 나가지 않습니다.
  • ImageResponse 호출부에서 주입합니다 — 본 패키지는 next 직접 의존하지 않습니다.

공개 검색 (사이트 내 검색)

searchPosts 발행 글 키워드 검색을 붙일 수 있습니다 (WP ?s= 패리티):

// app/search/page.tsx (Server Component)
import { searchPosts } from "@roottale/cms-client/server";

const hits = await searchPosts({
  apiKey: process.env.ROOTTALE_API_KEY!,
  query: q, // ?q= 쿼리
  limit: 20,
});
// hits: { id, title, slug, excerpt, featuredImageUrl, publishedAt }[]

본문은 미포함 슬림 hit 이므로 카드에서 /blog/{slug} 연결하세요. 검색결과 페이지는 위 체크리스트대로 noindex 처리를 잊지 마세요.

JSON-LD 스키마 헬퍼

@roottale/cms-client/server에서 제공:

함수 용도
articleSchema(input) 블로그 글 상세 페이지 Article/BlogPosting
breadcrumbSchema(items) 빵부스러기
organizationSchema(input) 조직/사업체
localBusinessSchema(profile, opts) 사업장 LocalBusiness (로컬 SEO — 아래 섹션)
websiteSchema(input) 웹사이트
faqSchema(items) FAQ
profilePageSchema(input) 작가 프로필 페이지 (/blog/author/{slug})
collectionPageSchema(input) 글 목록/카테고리 허브 (/blog, /blog/categories/{slug})
import { articleSchema } from "@roottale/cms-client/server";

const jsonLd = articleSchema({
  title: post.title,
  description: post.excerpt,
  url: `${SITE_URL}/blog/${post.slug}`,
  datePublished: post.publishedAt,
  image: post.featuredImageUrl ?? undefined,
});

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>;

avcd 구조 — 확장 옵션(전부 선택):

const jsonLd = articleSchema({
  type: "BlogPosting", // 기본 "Article" — 미지정 시 기존 출력 그대로
  id: `${url}#article`, // 지정 시 @id emit
  headline: post.title,
  url,
  image: post.featuredImageUrl ?? undefined,
  imageWidth: 1200, // 둘 다 있으면 image 를 ImageObject 로 승격
  imageHeight: 630,
  authorName: post.authorName,
  authorUrl: `${SITE_URL}/blog/author/${post.authorSlug}`, // author.@id 연결
  publisherName: "예시 사이트",
  publisherId: `${SITE_URL}#organization`,
  section: category?.name, // → articleSection
  inLanguage: "ko",
});

profilePageSchema/collectionPageSchema 사용법은 각각 위 "작가 아카이브"· "카테고리 허브" 절 참고.

저수준 RSS가 필요하면 generateRssXml / rssItemsFromPosts 직접 사용할 수 있습니다.

로컬 SEO (네이버플레이스·구글 비즈니스)

어드민 운영 > 비즈니스 프로필에서 사업장 정보(이름·업종·주소·좌표· 영업시간·외부 프로필 URL)⁠를 저장하면, 사이트가 fetchBusinessProfile 조회해 LocalBusiness JSON-LD를 자동 렌더할 수 있습니다 — 주소·영업시간을 사이트 코드에 하드코딩할 필요가 없습니다.

layout에 1회 렌더하면 충분합니다:

// app/layout.tsx
import {
  fetchBusinessProfile,
  localBusinessSchema,
} from "@roottale/cms-client/server";

const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;

export default async function RootLayout({ children }) {
  // 어드민에서 미설정이면 null — 렌더를 건너뛴다.
  const business = await fetchBusinessProfile({
    apiKey: process.env.ROOTTALE_API_KEY!,
  }).catch(() => null);

  return (
    <html lang="ko">
      <body>
        {business ? (
          <script
            type="application/ld+json"
            dangerouslySetInnerHTML={{
              __html: JSON.stringify(
                localBusinessSchema(business, { url: SITE_URL }),
              ),
            }}
          />
        ) : null}
        {children}
      </body>
    </html>
  );
}

localBusinessSchema 만드는 것:

  • @type: [업종, "LocalBusiness"] (중복 제거) — 어드민에서 고른 업종 (세무·회계 = AccountingService, 병원·의원 = MedicalClinic 등)
  • address(PostalAddress) / geo(GeoCoordinates) / openingHoursSpecification / priceRange / areaServed
  • alternateName: 띄어쓰기 변형처럼 같은 사업장을 다르게 부르는 이름
  • faxNumber: 팩스를 쓰는 업종(세무·법률 등)⁠의 연락처
  • hasOfferCatalog: 어드민 "취급 업무" 목록 — "무엇을 하는 곳인가"를 명시하는 신호입니다. 명함·간판에 적힌 업무를 그대로 옮기면 됩니다.
  • hasMap: 네이버플레이스 주소(없으면 카카오맵 주소)
  • sameAs: 어드민에 입력한 네이버플레이스·구글 비즈니스 프로필·카카오 채널 등의 URL — 검색엔진이 동일 사업장임을 연결합니다. 지도 딥링크(카카오맵)⁠는 프로필 페이지가 아니므로 hasMap 으로만 나가고 여기엔 포함되지 않습니다.

네이버플레이스(new.smartplace.naver.com)⁠와 구글 비즈니스 프로필(business.google.com) 등록 자체는 공개 API가 없어 사장님이 직접 해야 하며, 어드민 화면에 등록 안내와 프로필 URL 입력란이 있습니다.

Fleet 프로브 (운영 가시성)

RootTale 운영 측이 배포 버전·헬스를 확인할 수 있는 well-known 라우트:

// app/.well-known/roottale.json/route.ts
import { createFleetInfoRoute } from "@roottale/cms-renderer-next/routes";

export const GET = createFleetInfoRoute({ site: "example" });

site에는 사이트 식별용 슬러그를 넣습니다. 필수는 아니지만 운영 지원을 받으려면 추가를 권장합니다.

AEO/GEO — llms.txt

AI 검색·생성엔진(ChatGPT·Claude·Perplexity 등)⁠의 크롤러는 llms.txt 마크다운 인덱스로 사이트 구조와 콘텐츠를 빠르게 파악합니다. 발행 글 목록(최대 100개)⁠을 제목·요약과 함께 자동 포함하므로, AI가 관련 질문에 답할 때 내 사이트의 글이 출처로 인용될 가능성을 높입니다(AEO/GEO).

// app/llms.txt/route.ts
import { createLlmsTxtRoute } from "@roottale/cms-renderer-next/routes";

export const dynamic = "force-dynamic";

export const GET = createLlmsTxtRoute({
  apiKey: process.env.ROOTTALE_API_KEY!,
  apiBase: process.env.ROOTTALE_API_BASE,
  siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
  title: "예시 사이트",
  description: "예시 사이트 설명",
  sections: [
    // (선택) 서비스 소개 등 정적 페이지 링크 그룹 — 블로그 목록 앞에 출력
    {
      title: "주요 페이지",
      links: [
        {
          title: "서비스 소개",
          url: "https://example.com/services",
          note: "제공 서비스 안내",
        },
        { title: "상담 문의", url: "https://example.com/contact" },
      ],
    },
  ],
});

API 조회가 실패해도 항상 200으로 헤더 부분을 반환합니다(빌드 사고 방지). 발행 웹훅의 기본 alsoRevalidate /llms.txt 포함되어 있어, 글을 발행·수정하면 AI 크롤러용 인덱스도 자동으로 갱신됩니다.

에셋 도메인 정합 스캔 (스테이징 잔류 방지)

빌드 산출물의 이미지·스크립트·스타일시트가 스테이징/프리뷰 호스트(예: *.vercel.app, staging.example.com)⁠를 그대로 참조한 채 배포되면 운영 사이트가 죽은 링크나 깨진 자산을 서빙하게 됩니다. scanAssetHosts 렌더된 HTML(+ CSS)⁠에서 절대 URL 에셋 호스트를 추출해 허용목록 밖의 호스트, 그리고 허용목록에 있어도 스테이징 패턴이면 무조건 위반으로 보고합니다.

import { scanAssetHosts } from "@roottale/cms-renderer-next/seo-asset-hosts";

const violations = scanAssetHosts(
  html, // 렌더된 페이지 HTML — 인라인 <style> 블록도 함께 스캔됨
  { siteHost: "www.example.com", cdnHosts: [".cloudfront.net"] },
  [externalCssString], // 선택: 별도로 로드되는 CSS 문자열(@import 1-depth)
);
// violations: { url, host, source, staging? }[] — 0건이면 통과
  • 스캔 대상: <img src>/<img srcset>, <source src>(media)/<source srcset>, <script src>, <video poster>/<video src>, <audio src>, <track src>, <base href>(가장 위험 — 이후 모든 상대 URL이 이 호스트 기준으로 요청됨), <iframe src>, <embed src>/<object data>, <input type="image" src>, <link href>(단 rel 실제로 에셋을 로드하는 값일 때만 — stylesheet/preload/modulepreload/prefetch/ preconnect/dns-prefetch/icon류/manifest. canonical/alternate/ author/me 등은 검사하지 않음), <link imagesrcset>(srcset 문법), 인라인· 외부 CSS의 url()/@import/image-set()(-webkit-image-set() 포함).
  • 절대 URL(https:///http://///host/...)⁠만 판정합니다 — 상대경로· data:/blob: 통과. 속성값의 HTML 엔티티(&amp;, &#NN; 등)⁠는 URL 파싱 전에 디코드하고, URL 안의 백슬래시는 브라우저와 동일하게 슬래시로 정규화합니다. new URL() 파싱이 실패하는 절대 URL은 호스트를 추측하지 않고 그 자체로 위반 처리합니다(fail-closed, host: "(unparseable)").
  • 한계: HTML 태그는 quote-aware 토크나이저로 스캔하지만 완전한 DOM 파서는 아니라 스크립트 문자열 리터럴 안의 가짜 태그는 걸러내지 못하고, CSS @import 1-depth만 보며(중첩 @import 범위 밖), CSS escape 시퀀스 (\3a 등)까지는 디코드하지 않습니다. 런타임에 JS로 삽입되는 요청은 배포 후 실제 네트워크 요청을 검사하는 E2E 프로브로 보완하세요.