UNPKG

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