@roottale/cms-mcp
Version:
RootTale CMS integration MCP server — bundled integration docs, Next.js example code, and public API lookup tools. Run with: npx @roottale/cms-mcp
327 lines (258 loc) • 11.2 kB
Markdown
---
title: SEO (RSS·사이트맵·JSON-LD)
description: RSS 피드, 사이트맵, JSON-LD 스키마, fleet 프로브 라우트
---
# SEO — RSS·사이트맵·JSON-LD
`@roottale/cms-renderer-next/routes`의 팩토리로 RSS·사이트맵을 한 줄에
구성하고, `@roottale/cms-client/server`의 헬퍼로 JSON-LD를 생성합니다.
## RSS 피드
```ts
// 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을 반환합니다.
## 사이트맵
```ts
// 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 를 쓰세요 (외부 링크가 있으면 차단해도 색인될 수
있습니다).
```ts
// 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 브레드크럼과 구조 일치 권장):
```tsx
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을 보내세요:
```tsx
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 부분셋을 런타임에 받아 렌더합니다.
```tsx
// 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=` 패리티):
```tsx
// 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 |
```tsx
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회 렌더하면 충분합니다:
```tsx
// 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`
- `sameAs`: 어드민에 입력한 네이버플레이스·구글 비즈니스 프로필·카카오 채널
등의 URL — 검색엔진이 동일 사업장임을 연결합니다.
네이버플레이스(new.smartplace.naver.com)와 구글 비즈니스 프로필
(business.google.com) 등록 자체는 공개 API가 없어 사장님이 직접 해야 하며,
어드민 화면에 등록 안내와 프로필 URL 입력란이 있습니다.
## Fleet 프로브 (운영 가시성)
RootTale 운영 측이 배포 버전·헬스를 확인할 수 있는 well-known 라우트:
```ts
// 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](https://llmstxt.org) 마크다운 인덱스로 사이트 구조와 콘텐츠를
빠르게 파악합니다. 발행 글 목록(최대 100개)을 제목·요약과 함께 자동
포함하므로, AI가 관련 질문에 답할 때 내 사이트의 글이 출처로 인용될
가능성을 높입니다(AEO/GEO).
```ts
// 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 크롤러용 인덱스도 자동으로 갱신됩니다.