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을 반환합니다.
사이트맵
// app/sitemap.ts
import { createSitemap } from "@roottale/cms-renderer-next/routes";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
export default createSitemap(
{
apiKey: process.env.ROOTTALE_API_KEY!,
apiBase: process.env.ROOTTALE_API_BASE,
siteUrl: SITE_URL,
title: "예시 사이트",
},
[
// 정적 경로 — 발행 글 URL은 자동 추가됨
{ url: SITE_URL, changeFrequency: "weekly", priority: 1.0 },
{ url: `${SITE_URL}/blog`, changeFrequency: "weekly", priority: 0.7 },
{ url: `${SITE_URL}/contact`, changeFrequency: "monthly", priority: 0.9 },
],
);
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)
사이트 구조를 검색엔진에 전달하고 검색결과에 경로가 표시됩니다. 블로그 글
상세에서 breadcrumbSchema 로 렌더하세요 (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 참고).
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,
} 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: "예시",
},
{ ImageResponse },
);
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 |
breadcrumbSchema(items) | 빵부스러기 |
organizationSchema(input) | 조직/사업체 |
localBusinessSchema(profile, opts) | 사업장 LocalBusiness (로컬 SEO — 아래 섹션) |
websiteSchema(input) | 웹사이트 |
faqSchema(items) | FAQ |
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) }}
/>;
저수준 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/areaServedsameAs: 어드민에 입력한 네이버플레이스·구글 비즈니스 프로필·카카오 채널 등의 URL — 검색엔진이 동일 사업장임을 연결합니다.
네이버플레이스(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 크롤러용 인덱스도 자동으로 갱신됩니다.