---
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 크롤러용 인덱스도 자동으로 갱신됩니다.
