고급Google
Next.js 기반 SEO 완전 최적화 가이드 (App Router, Metadata API)
핵심 요약 (TL;DR)
가장 강력한 SEO 프레임워크인 Next.js를 활용하여 동적 오픈그래프, 100점짜리 Core Web Vitals, 동적 사이트맵을 구축하는 실전 가이드입니다.
Next.js가 SEO에 최적인 이유
Next.js는 React 기반 풀스택 프레임워크로, SEO에 필요한 모든 기능을 프레임워크 레벨에서 내장하고 있습니다. SPA의 SEO 한계를 극복하기 위해 만들어진 것이 아니라, 처음부터 서버 렌더링을 중심으로 설계된 프레임워크입니다.
Next.js App Router의 SEO 핵심 기능
| 기능 | 설명 | SEO 효과 |
|---|---|---|
| Metadata API | 페이지별 title, description, OG 태그를 서버에서 생성 | 모든 크롤러에서 정확한 메타데이터 인식 |
| Server Components | 기본 컴포넌트가 서버에서 렌더링 → 완성된 HTML 응답 | JS 없이 콘텐츠 전달, 모든 크롤러 호환 |
| SSR/SSG/ISR 하이브리드 | 페이지별로 독립적 렌더링 전략 선택 | 목적에 맞는 최적의 렌더링 |
| Streaming SSR | React Suspense와 함께 청크 단위 HTML 전송 | TTFB 개선, LCP 최적화 |
| 동적 사이트맵 (sitemap.ts) | TypeScript로 프로그래밍 가능한 사이트맵 | CMS/DB에서 자동 생성, 항상 최신 상태 |
| robots.ts | TypeScript로 작성하는 robots.txt | 환경별(dev/staging/prod) 동적 제어 |
| Image Optimization | next/image 컴포넌트로 자동 최적화 | LCP 개선, WebP/AVIF 자동 변환 |
| Font Optimization | next/font로 CLS 없는 폰트 로딩 | CLS 0, 외부 네트워크 요청 제거 |
React 생태계 1위Next.js 채택률전 세계 React 프로젝트의 SSR 프레임워크 점유 1위
Lighthouse 100Vercel 벤치마크Next.js App Router + ISR로 달성 가능
JS 번들 0Server Component서버에서만 실행되므로 클라이언트에 JS 전송 없음
Metadata API 마스터: 페이지별 메타데이터 완전 제어
정적 Metadata
// app/about/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '회사 소개 | 브랜드명',
description: '브랜드명은 2015년부터 SEO 컨설팅을 제공하는 전문 디지털 마케팅 에이전시입니다.',
openGraph: {
title: '회사 소개 | 브랜드명',
description: '10년 경력의 SEO 전문 에이전시',
images: [{ url: '/images/og-about.jpg', width: 1200, height: 630 }],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: '회사 소개 | 브랜드명',
description: '10년 경력의 SEO 전문 에이전시',
},
alternates: {
canonical: 'https://example.com/about',
languages: { 'en': 'https://example.com/en/about' },
},
};
동적 Metadata (generateMetadata)
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { getPost } from '@/lib/api';
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: `${post.title} | 블로그`,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.coverImage, width: 1200, height: 630 }],
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author.name],
},
alternates: {
canonical: `https://example.com/blog/${slug}`,
},
};
}
Layout 메타데이터 상속
App Router에서 layout.tsx에 정의한 메타데이터는 하위 페이지에 자동 상속됩니다. 하위 페이지에서 같은 필드를 정의하면 깊은 병합(Deep Merge)이 아닌 덮어쓰기(Override)됩니다.
// app/layout.tsx — 전역 기본값
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: { default: '브랜드명', template: '%s | 브랜드명' },
description: '기본 설명',
robots: { index: true, follow: true },
};
// app/blog/[slug]/page.tsx — 페이지별 제목 (template 적용)
// title: '게시글 제목' → 실제 출력: '게시글 제목 | 브랜드명'
동적 사이트맵과 robots.ts: 프로그래밍 가능한 SEO 제어
sitemap.ts: 동적 사이트맵 생성
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllPosts, getAllProducts } from '@/lib/api';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const products = await getAllProducts();
const blogEntries = posts.map((post) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.7,
}));
const productEntries = products.map((product) => ({
url: `https://example.com/products/${product.slug}`,
lastModified: new Date(product.updatedAt),
changeFrequency: 'daily' as const,
priority: 0.8,
}));
return [
{ url: 'https://example.com', lastModified: new Date(), priority: 1.0 },
{ url: 'https://example.com/about', lastModified: new Date(), priority: 0.5 },
...blogEntries,
...productEntries,
];
}
대규모 사이트맵 분할 (50,000개 이상)
// app/sitemap.ts — 사이트맵 인덱스 생성
export async function generateSitemaps() {
const totalProducts = await getProductCount();
const sitemapCount = Math.ceil(totalProducts / 50000);
return Array.from({ length: sitemapCount }, (_, i) => ({ id: i }));
}
// 각 사이트맵 청크 생성
export default async function sitemap({ id }: { id: number }) {
const start = id * 50000;
const products = await getProducts({ offset: start, limit: 50000 });
return products.map((p) => ({
url: `https://example.com/products/${p.slug}`,
lastModified: p.updatedAt,
}));
}
robots.ts: 동적 robots.txt
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/admin/', '/api/', '/private/'],
},
{
userAgent: 'GPTBot',
disallow: ['/premium-content/'],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}
JSON-LD 구조화 데이터: 서버 컴포넌트에서 삽입
기본 패턴: Script 태그로 JSON-LD 삽입
// app/blog/[slug]/page.tsx — Server Component
export default async function BlogPost({ params }) {
const { slug } = await params;
const post = await getPost(slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.author.name,
url: post.author.url,
},
publisher: {
'@type': 'Organization',
name: '브랜드명',
logo: { '@type': 'ImageObject', url: 'https://example.com/logo.png' },
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>
<h1>{post.title}</h1>
{/* ... */}
</article>
</>
);
}
핵심 포인트
- Server Component에서 삽입: JSON-LD가 초기 HTML에 포함되어 1단계 크롤에서 즉시 인식. Client Component에서 삽입하면 렌더링 후에만 인식
- dangerouslySetInnerHTML 사용: React가 JSON을 이스케이프하지 않도록 raw로 삽입
- 서비스별 스키마 유형: 블로그(Article), 상품(Product), FAQ(FAQPage), 조직(Organization), 이벤트(Event), 리뷰(Review) 등 적절한 타입 사용
재사용 가능한 JSON-LD 헬퍼
// lib/json-ld.ts
export function createArticleJsonLd(post: Post) {
return {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { '@type': 'Person', name: post.author.name },
};
}
export function createBreadcrumbJsonLd(items: { name: string; url: string }[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, i) => ({
'@type': 'ListItem',
position: i + 1,
name: item.name,
item: item.url,
})),
};
}
ISR·generateStaticParams·이미지 최적화 실전
ISR (Incremental Static Regeneration)
// app/products/[slug]/page.tsx
export const revalidate = 300; // 5분마다 재검증
// 빌드 시 미리 생성할 페이지 (인기 상품 Top 100)
export async function generateStaticParams() {
const topProducts = await getTopProducts({ limit: 100 });
return topProducts.map((p) => ({ slug: p.slug }));
}
// 나머지 상품은 첫 요청 시 On-Demand 생성 후 캐싱
export default async function ProductPage({ params }) {
const { slug } = await params;
const product = await getProduct(slug);
// ...
}
On-Demand Revalidation: 즉시 캐시 무효화
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const { secret, path, tag } = await req.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (path) revalidatePath(path); // 특정 경로 캐시 무효화
if (tag) revalidateTag(tag); // 특정 태그의 모든 캐시 무효화
return NextResponse.json({ revalidated: true });
}
CMS에서 콘텐츠를 업데이트하면 Webhook으로 이 API를 호출하여 해당 페이지만 즉시 재생성합니다. 전체 재빌드가 필요 없습니다.
next/image: 이미지 SEO 최적화
import Image from 'next/image';
<Image
src="/images/product-hero.jpg"
alt="나이키 에어맥스 블랙 - 측면 뷰" {/* 구체적 alt 텍스트 */}
width={1200}
height={630}
priority {/* LCP 요소일 경우 preload */}
sizes="(max-width: 768px) 100vw, 50vw" {/* 반응형 크기 지정 */}
/>
- 자동 WebP/AVIF 변환: 브라우저 지원에 따라 최적 포맷으로 변환
- 자동 lazy loading: viewport에 가까워질 때 로드 (priority 없는 경우)
- 자동 srcset 생성: 다양한 크기의 이미지를 자동 생성하여 기기별 최적 이미지 전달
- CLS 방지: width/height가 지정되어 있으므로 브라우저가 이미지 영역을 미리 확보
Next.js SEO 체크리스트와 흔한 실수
SEO 체크리스트
| 카테고리 | 체크 항목 |
|---|---|
| 메타데이터 | ☑ 모든 공개 페이지에 고유한 title과 description 설정 |
| ☑ metadataBase 설정으로 OG 이미지 URL이 절대 경로로 출력 | |
| ☑ title.template으로 일관된 제목 패턴 ("%s | 브랜드명") | |
| ☑ alternates.canonical로 정규 URL 설정 | |
| ☑ OG:image가 1200×630px 이상이고 절대 URL | |
| 크롤링 | ☑ sitemap.ts로 동적 사이트맵 생성 + GSC에 등록 |
| ☑ robots.ts로 크롤 규칙 설정 (admin, api 차단) | |
| ☑ 불필요한 페이지에 noindex 설정 | |
| 렌더링 | ☑ SEO 핵심 콘텐츠가 Server Component에서 렌더링 |
| ☑ JSON-LD가 초기 HTML에 포함 (Server Component에서 삽입) | |
| ☑ ISR revalidate가 콘텐츠 변경 주기에 맞게 설정 | |
| 성능 | ☑ LCP 요소 이미지에 priority 속성 적용 |
| ☑ next/font로 폰트 최적화 (CLS 방지) | |
| ☑ Client Component 최소화 — 인터랙티브한 부분만 'use client' |
흔한 실수 5가지
| 실수 | 문제 | 해결 |
|---|---|---|
| 'use client'를 남용 | Client Component에서는 generateMetadata 등 서버 기능 사용 불가. JS 번들 비대 | 최소한의 인터랙티브 부분만 Client Component로 분리. 나머지는 Server Component 유지 |
| metadataBase 미설정 | OG:image URL이 상대 경로로 출력 → 소셜 미디어에서 이미지 미표시 | 루트 layout.tsx에 metadataBase 설정 |
| 동적 라우트에 generateStaticParams 미사용 | 404 에러 또는 모든 페이지가 런타임 SSR → 서버 부하 | 인기 페이지는 generateStaticParams로 빌드 시 생성 |
| JSON-LD를 Client Component에서 삽입 | 초기 HTML에 포함되지 않음 → 1단계 크롤에서 미인식 | Server Component에서 script 태그로 삽입 |
| next/image의 alt 텍스트 누락 | 이미지 SEO 점수 하락, 접근성 위반 | 모든 이미지에 구체적이고 키워드를 포함한 alt 텍스트 작성 |
자주 묻는 질문 (FAQ)
Q. Next.js Pages Router와 App Router 중 SEO에 어떤 게 더 좋은가요?
App Router가 SEO에 더 유리합니다. (1) Server Components가 기본이므로 JS 번들이 작고 Core Web Vitals가 우수합니다. (2) Metadata API로 메타데이터를 타입 안전하게 관리할 수 있습니다 (Pages Router의 next/head보다 강력). (3) Streaming SSR로 TTFB를 개선할 수 있습니다. (4) sitemap.ts, robots.ts가 내장되어 별도 패키지 불필요. 단, Pages Router도 SEO 관점에서 나쁘지 않습니다. 이미 Pages Router로 운영 중이라면 App Router 마이그레이션을 SEO만을 위해 급하게 할 필요는 없습니다.
Q. next/head를 App Router에서도 사용할 수 있나요?
아니요. App Router에서
next/head는 더 이상 지원되지 않습니다. 대신 export const metadata 또는 export async function generateMetadata()를 사용해야 합니다. App Router에서 next/head를 사용하면 경고가 발생하고 메타데이터가 올바르게 렌더링되지 않습니다. 이것은 Next.js가 Pages Router → App Router 마이그레이션 시 가장 먼저 변환해야 할 부분입니다.Q. Vercel에 배포하지 않아도 Next.js의 ISR이 동작하나요?
네, 동작합니다. ISR은 Next.js의 핵심 기능이며 자체 호스팅(self-hosted)에서도 동작합니다. 단, 파일 시스템 기반 캐시를 사용하므로 서버리스(Lambda 등) 환경에서는 추가 설정이 필요합니다. Vercel에서의 ISR은 글로벌 CDN 엣지 캐시와 통합되어 더 빠르고 안정적입니다. 자체 호스팅에서는 Redis나 별도 캐시 레이어를 구성하여 유사한 효과를 낼 수 있습니다.
Q. Server Component에서 외부 API를 호출하면 SEO에 영향이 있나요?
TTFB에 영향을 미칩니다. Server Component에서 외부 API를 호출하면 응답을 기다리는 동안 HTML 전송이 지연됩니다. 대응 전략: (1)
fetch의 cache 옵션 활용 — force-cache로 빌드 시 캐싱하거나 next.revalidate로 ISR 적용. (2) Streaming SSR — React Suspense로 핵심 콘텐츠를 먼저 보내고 비핵심을 나중에 전송. (3) 병렬 데이터 페칭 — Promise.all()로 여러 API를 동시 호출. SEO에 중요한 콘텐츠(title, 본문, JSON-LD)는 항상 가장 먼저 전송해야 합니다.Q. CSR이 필요한 인터랙티브 기능(검색, 필터 등)은 어떻게 SEO와 공존시키나요?
Next.js App Router의 Server/Client Component 분리 패턴을 활용합니다: (1) 페이지의 SEO 핵심 콘텐츠(제목, 메타데이터, JSON-LD, 주요 텍스트, 상품 정보)는 Server Component에 유지 → 초기 HTML에 포함. (2) 검색 필터, 장바구니 버튼, 탭 전환 등 인터랙티브 기능만 Client Component(
'use client')로 분리. (3) 검색 결과처럼 SEO + 인터랙티브가 동시에 필요한 경우: URL 파라미터(searchParams)를 활용하여 서버에서 검색 결과를 렌더링하면서도 클라이언트에서 실시간 필터링이 가능합니다.