React Router로 SPA를 만들면 SEO 처리가 Next.js보다 손이 많이 갑니다. 서버 사이드 렌더링 기본 지원이 없고, 메타 태그·구조화 데이터·사이트맵을 직접 연결해야 합니다.
투자킹(twojaking.com)을 React Router + Cloudflare Pages로 운영하면서 적용한 SEO 구현을 실제 코드 기반으로 정리합니다. 각 기술은 어렵지 않지만, 하나라도 빠뜨리면 효과가 줄고 어디서 누락됐는지 파악하기 어렵습니다.
전체 구조
메타 태그 (title, description, canonical, OG 7개, Twitter Card 5개)
↓
JSON-LD 구조화 데이터 (Organization, WebSite, Article, NewsArticle, BreadcrumbList)
↓
동적 사이트맵 (sitemap.xml — 정적 + DB 동적 혼합)
↓
Google News 사이트맵 (news-sitemap.xml — 48시간 이내 콘텐츠)
↓
RSS 피드 (rss/news.xml, rss/analysis.xml)
↓
Google Indexing API 자동 통보 (콘텐츠 저장 즉시 구글에 알림)
↓
robots.txt + Naver 검색 등록
1. 핵심 유틸리티: generateSeoMeta()
페이지마다 메타 태그를 직접 작성하면 항목이 빠집니다. SeoMetaConfig 인터페이스를 정의하고, generateSeoMeta() 함수 하나로 필요한 태그를 전부 생성합니다.
// app/utils/seo.ts
export interface SeoMetaConfig {
title: string;
description: string;
keywords?: string;
path: string;
image?: string;
type?: 'website' | 'article';
noindex?: boolean;
}
const SITE_URL = 'https://twojaking.com';
const SITE_NAME = '투자킹';
const DEFAULT_IMAGE = `${SITE_URL}/android-chrome-512x512.png`;
export function generateSeoMeta(config: SeoMetaConfig) {
const {
title,
description,
keywords,
path,
image = DEFAULT_IMAGE,
type = 'website',
noindex = false,
} = config;
const url = `${SITE_URL}${path}`;
// 홈(/) 이외 페이지는 "제목 | 사이트명" 형식으로 자동 처리
const fullTitle = path === '/' ? title : `${title} | ${SITE_NAME}`;
const meta = [
{ title: fullTitle },
{ name: 'description', content: description },
];
// keywords는 값이 있을 때만 추가
if (keywords) {
meta.push({ name: 'keywords', content: keywords });
}
// Canonical URL
meta.push({ tagName: 'link', rel: 'canonical', href: url } as any);
// Open Graph (7개)
meta.push(
{ property: 'og:type', content: type },
{ property: 'og:title', content: fullTitle },
{ property: 'og:description', content: description },
{ property: 'og:url', content: url },
{ property: 'og:image', content: image },
{ property: 'og:site_name', content: SITE_NAME },
{ property: 'og:locale', content: 'ko_KR' }
);
// Twitter Card (5개)
meta.push(
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:site', content: '@twojaking' },
{ name: 'twitter:title', content: fullTitle },
{ name: 'twitter:description', content: description },
{ name: 'twitter:image', content: image }
);
// noindex (로그인·프로필 등 개인 페이지)
if (noindex) {
meta.push({ name: 'robots', content: 'noindex' });
}
return meta;
}
함수 한 번 호출로 title, description, canonical, OG 7개, Twitter Card 5개 — 최대 15개 태그가 자동 생성됩니다.
사용 예시
// 정적 페이지
export function meta() {
return generateSeoMeta({
title: '실시간 비트코인 뉴스',
description: '최신 암호화폐 뉴스와 AI 분석을 실시간으로 확인하세요.',
keywords: '비트코인, 코인 뉴스, 암호화폐',
path: '/news',
});
}
// 동적 페이지 (뉴스 기사 상세)
export function meta({ data, params }: Route.MetaArgs) {
if (!data?.article) {
return generateSeoMeta({
title: '뉴스',
description: '투자킹 뉴스',
path: `/news/${params.articleId ?? ''}`,
noindex: true,
});
}
return [
...generateSeoMeta({
title: data.article.title,
description: data.article.summary.slice(0, 150),
keywords: data.article.tags.join(', '),
path: `/news/${data.article.uuid}`,
type: 'article',
}),
{ 'script:ld+json': generateNewsArticleSchema({ ... }) },
{ 'script:ld+json': generateBreadcrumbSchema([...]) },
];
}
canonical URL 설정이 중요합니다. 쿼리 파라미터가 붙은 변형 URL이 생겨도 구글이 대표 URL 하나로 인식합니다.
2. Open Graph + Twitter Card 자동 생성
generateSeoMeta()가 OG와 Twitter Card를 동시에 처리합니다. 별도 설정 없이 모든 페이지에서 SNS 공유 시 썸네일과 설명이 올바르게 표시됩니다.
핵심 포인트:
og:image와twitter:image는DEFAULT_IMAGE로 통일. 페이지별 이미지가 있으면image파라미터로 전달twitter:site에 계정 핸들(@twojaking)을 고정으로 포함og:locale은ko_KR로 고정
콘텐츠 상세 페이지는 type: 'article'로 지정하면 og:type이 article로 설정됩니다.
3. JSON-LD 구조화 데이터
구글 검색 결과에 리치 스니펫(날짜, 출처, 경로 등)이 표시되려면 JSON-LD가 필요합니다.
React Router의 script:ld+json 처리 방식
React Router는 meta() 배열에서 'script:ld+json' 키를 자동으로 직렬화합니다.
// 올바른 방법
{ 'script:ld+json': generateArticleSchema({...}) }
// 절대 사용 금지 — React Router가 이중 직렬화하여 HTML이 깨짐
{ 'script:ld+json': JSON.stringify(generateArticleSchema({...})) }
JavaScript 객체를 그대로 넘기면 됩니다. JSON.stringify는 사용하지 않습니다.
generateArticleSchema() — 블로그·피드 콘텐츠
export function generateArticleSchema(config: {
title: string;
description: string;
url: string;
image?: string;
datePublished: string;
author: string;
}) {
return {
'@context': 'https://schema.org',
'@type': 'Article',
headline: config.title,
description: config.description,
image: config.image || DEFAULT_IMAGE,
datePublished: config.datePublished,
author: {
'@type': 'Person',
name: config.author,
},
publisher: {
'@type': 'Organization',
name: SITE_NAME,
logo: {
'@type': 'ImageObject',
url: `${SITE_URL}/android-chrome-512x512.png`,
},
},
url: config.url,
};
}
generateNewsArticleSchema() — 뉴스 기사
export function generateNewsArticleSchema(config: {
title: string;
description: string;
url: string;
image?: string;
datePublished: string;
dateModified?: string;
author: string;
keywords?: string[];
}) {
return {
'@context': 'https://schema.org',
'@type': 'NewsArticle',
headline: config.title.slice(0, 110), // 구글 권장 110자 제한
description: config.description,
image: config.image || DEFAULT_IMAGE,
datePublished: config.datePublished,
dateModified: config.dateModified || config.datePublished,
keywords: config.keywords?.length ? config.keywords.join(', ') : undefined,
author: {
'@type': 'Organization',
name: config.author,
},
publisher: {
'@type': 'Organization',
name: SITE_NAME,
logo: {
'@type': 'ImageObject',
url: `${SITE_URL}/android-chrome-512x512.png`,
},
},
url: config.url,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': config.url,
},
};
}
Article과 NewsArticle의 차이: NewsArticle은 dateModified, mainEntityOfPage, keywords를 추가로 포함하고 headline을 110자로 제한합니다. Google Search Console에서 "뉴스 기사" 리치 결과로 분류됩니다.
generateBreadcrumbSchema() — 경로 표시
export function generateBreadcrumbSchema(items: Array<{ name: string; url: string }>) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url,
})),
};
}
뉴스 기사 페이지 적용 예시:
{ 'script:ld+json': generateBreadcrumbSchema([
{ name: '홈', url: 'https://twojaking.com' },
{ name: '뉴스', url: 'https://twojaking.com/news' },
{ name: data.article.title, url: `https://twojaking.com/news/${data.article.uuid}` },
]) }
검색 결과 URL 아래에 투자킹 > 뉴스 > 기사 제목 형식으로 경로가 표시됩니다.
전역 스키마 (root.tsx)
Organization과 WebSite 스키마는 모든 페이지에 공통 적용합니다.
// app/root.tsx
export function meta() {
return [
{ 'script:ld+json': generateOrganizationSchema() },
{ 'script:ld+json': generateWebsiteSchema() },
];
}
WebSite 스키마에 SearchAction을 추가하면 구글 검색 결과에 사이트 내 검색 바가 표시될 수 있습니다.
4. 동적 사이트맵 구현
정적 파일로 만든 sitemap.xml은 새 콘텐츠가 추가되어도 갱신이 안 됩니다. React Router의 SSR 라우트로 만들면 요청할 때마다 DB를 조회해 최신 URL을 반환합니다.
// app/routes/sitemap[.xml].tsx
const STATIC_PAGES = [
{ url: '/', changefreq: 'daily', priority: '1.0' },
{ url: '/news', changefreq: 'hourly', priority: '0.9' },
{ url: '/btc', changefreq: 'hourly', priority: '0.8' },
// ...
];
export async function loader() {
let news: Array<{ uuid: string; published_at: string }> = [];
let majorDates: string[] = [];
let blogPosts: Array<{ slug: string; published_at: string; updated_at: string }> = [];
// 뉴스 API + Supabase 블로그 병렬 조회
const [newsResult, blogResult] = await Promise.allSettled([
fetch(`${API_BASE}/api/v1/news/sitemap-urls`, {
signal: AbortSignal.timeout(8000),
}).then(async (res) => {
if (!res.ok) return null;
const json = await res.json();
return json.success ? json.data : null;
}),
supabase
.from('blog_posts')
.select('slug, published_at, updated_at')
.eq('is_published', true)
.order('published_at', { ascending: false }),
]);
if (newsResult.status === 'fulfilled' && newsResult.value) {
news = newsResult.value.news ?? [];
majorDates = newsResult.value.major_dates ?? [];
}
if (blogResult.status === 'fulfilled' && blogResult.value.data) {
blogPosts = blogResult.value.data;
}
const xml = buildSitemapXml(news, majorDates, blogPosts);
return new Response(xml, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=600, s-maxage=600', // 10분 캐시
},
});
}
Cache-Control: s-maxage=600으로 Cloudflare Edge에서 10분간 캐싱합니다. 매 요청마다 DB를 조회하지 않아 부하가 없습니다.
Promise.allSettled()를 사용해 뉴스 API나 Supabase 중 하나가 실패해도 나머지 URL은 정상 반환됩니다.
5. .xml 라우트 예외 처리 — Cloudflare Workers 설정
Cloudflare Pages는 .xml 확장자를 정적 파일로 오인합니다. workers/app.ts에 SSR 라우트로 명시해야 합니다.
// workers/app.ts
const SSR_ROUTES = [
'/sitemap.xml',
'/news-sitemap.xml',
'/rss/news.xml',
'/rss/analysis.xml',
];
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
if (SSR_ROUTES.includes(url.pathname)) {
return handleSSR(request, env); // React Router SSR 처리
}
return env.ASSETS.fetch(request); // 정적 파일
},
};
주의: scripts/create-pages-worker.js에도 동일한 SSR_ROUTES 배열이 있습니다. 새 XML 라우트를 추가할 때는 두 파일 모두 갱신해야 합니다.
6. Google Indexing API 자동 통보
사이트맵을 등록해도 구글이 크롤링하는 데 며칠이 걸릴 수 있습니다. Google Indexing API를 사용하면 콘텐츠 저장 즉시 구글에 알림을 보낼 수 있습니다.
원래 구인·구직 관련 사이트를 위한 API이지만, 실제로는 일반 콘텐츠에도 빠른 인덱싱 효과가 있습니다.
# twojaKing_be/app/application/services/hybrid/google_indexing.py
async def notify_url_updated(url: str) -> None:
token = await _get_access_token(service_account_json)
async with httpx.AsyncClient(timeout=10) as client:
await client.post(
'https://indexing.googleapis.com/v3/urlNotifications:publish',
headers={'Authorization': f'Bearer {token}'},
json={'url': url, 'type': 'URL_UPDATED'},
)
4가지 이벤트에서 자동 통보합니다.
| 이벤트 | URL 포맷 |
|---|---|
| 뉴스 기사 저장 | /news/{uuid} |
| AI 분석 저장 | /{coin}/analysis/{id} |
| 타임라인 이벤트 생성 | /news/events/{event_id} |
| 주요뉴스 일일 페이지 갱신 | /news/major/{YYYY-MM-DD} |
# news_service.py — 뉴스 기사 저장 후
if inserted_uuid:
asyncio.create_task(
notify_url_updated(f'https://twojaking.com/news/{inserted_uuid}')
)
asyncio.create_task()로 백그라운드 실행이라 메인 처리 흐름에 영향이 없습니다. 실패해도 로깅만 하고 넘어갑니다.
일일 할당량이 200건입니다. 뉴스가 하루 수백 건 들어오는 서비스라면 중요도 높은 콘텐츠(AI 분석, 타임라인 이벤트)를 우선 통보하는 방향으로 조정이 필요합니다.
7. noindex 설정 — 개인 페이지 처리
로그인·프로필·설정 페이지는 검색 인덱싱이 불필요합니다. noindex: true를 설정하면 robots: noindex 메타 태그가 자동으로 추가됩니다.
// 로그인 페이지
export function meta() {
return generateSeoMeta({
title: '로그인',
description: '투자킹 로그인',
path: '/login',
noindex: true,
});
}
개인 페이지는 sitemap.xml에도 포함하지 않습니다. noindex 메타 태그만으로 충분합니다.
새 페이지 추가 시 체크리스트
| 작업 | 대상 |
|---|---|
generateSeoMeta() meta 함수 추가 | 모든 페이지 |
app/routes.ts 경로 등록 | 모든 페이지 |
sitemap[.xml].tsx의 STATIC_PAGES 추가 | 정적 페이지 |
| JSON-LD 구조화 데이터 추가 | 콘텐츠 상세 페이지 |
noindex: true 설정 | 로그인·프로필 등 개인 페이지 |
workers/app.ts와 scripts/create-pages-worker.js에 SSR_ROUTES 추가 | .xml 확장자 라우트 |