Next.js
Next.js에서 SEO 메타데이터 관리하기
Next.js의 동적 페이지에서 검색 엔진이 올바른 메타데이터를 인식하려면, 빌드와 배포 단계를 제대로 이해해야 합니다.
블로그 포스트를 배포했는데, Google 검색에서 제목이 안 보인다. 또는 SNS에 공유했을 때 미리보기가 없다. 메타데이터가 제대로 가지 않은 것이다.
메타데이터 확인하기
먼저 실제로 HTML에 메타데이터가 있는가?
# 배포 URL의 HTML 소스 확인
curl https://example.com/blog/my-post | grep -i meta
# 브라우저에서는 우클릭 → 페이지 소스 보기
HTML head에 다음이 있는가?
<meta property="og:title" content="제목" />
<meta property="og:description" content="설명" />
<meta property="og:image" content="https://example.com/image.jpg" />
<meta name="description" content="설명" />
<link rel="canonical" href="https://example.com/blog/my-post" />
Next.js Metadata API 사용
Next.js 13+ App Router에서는 generateMetadata를 사용한다.
// app/blog/[slug]/page.tsx
import { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{
url: post.imageUrl,
width: 1200,
height: 630,
}],
type: 'article',
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.imageUrl],
},
};
}
export default function Page({ params }) {
return <BlogPost slug={params.slug} />;
}
빌드 vs 런타임
StaticGeneration (SSG)으로 빌드하면, 빌드 시점의 메타데이터가 구워진다.
// 나쁜 예: 런타임에 메타데이터를 가져온다
// 이미지 URL이 동적이면 빌드 시점에는 모를 수 있다
export async function generateMetadata({
params,
}): Promise<Metadata> {
// 빌드 시점에 여기 코드가 실행된다
const post = await getPostFromAPI(params.slug); // API 호출
return { title: post.title };
}
// 낫다: 빌드 시점에 데이터가 있어야 한다
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(post => ({ slug: post.slug }));
}
빌드 시점에 모든 데이터를 가져올 수 없으면 Dynamic Rendering을 써야 한다 (revalidate 사용).
동적 라우트 확인
[id] 같은 동적 라우트가 있으면, 빌드 시점에 어떤 페이지들을 생성할 것인지 알아야 한다.
// generateStaticParams를 구현해야 빌드할 때 페이지를 미리 생성한다
export async function generateStaticParams() {
const posts = await db.post.findMany();
return posts.map(post => ({
slug: post.slug,
}));
}
이 함수가 없으면 getPostBySlug는 요청 시점에 실행된다. 검색 엔진은 빠르게 스크래핑하므로 데이터를 못 가져올 수 있다.
Sitemap 확인
검색 엔진이 모든 페이지를 발견하려면 sitemap이 있어야 한다.
// app/sitemap.ts
import { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
return posts.map(post => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly',
priority: 0.8,
}));
}
OG 이미지 생성
OG 이미지 URL이 정적 이미지인가, 동적으로 생성되는가?
// 나쁜 예: 동적 이미지 URL
export async function generateMetadata({ params }): Promise<Metadata> {
return {
openGraph: {
images: [{
url: `https://example.com/api/og?slug=${params.slug}`,
}],
},
};
}
// 낫다: 사전에 생성된 이미지
export async function generateMetadata({ params }): Promise<Metadata> {
return {
openGraph: {
images: [{
url: `https://example.com/og/${params.slug}.png`,
}],
},
};
}
검색 엔진이 OG 이미지 URL을 방문할 때, API가 느리거나 실패하면 이미지를 수집하지 못한다.
Canonical URL 설정
SEO 중복을 피하려면 각 페이지의 canonical URL을 명시해야 한다.
export async function generateMetadata({ params }): Promise<Metadata> {
return {
alternates: {
canonical: `https://example.com/blog/${params.slug}`,
},
};
}
배포 후 검증
배포한 후 실제로 검색 엔진이 메타데이터를 인식했는가?
# Google Rich Results Test
https://search.google.com/test/rich-results?url=https://example.com/blog/my-post
# Open Graph Preview
https://www.opengraphcheck.com
"Missing properties" 같은 경고가 있으면 뭐가 빠진 건지 확인한다.
배포 후 재크롤
Google Search Console에 등록하고, "URL 검사"로 크롤링을 요청한다.
# URL 검사
1. Google Search Console 열기
2. "URL 검사" 입력창
3. 포스트 URL 입력
4. "색인 생성 요청" 클릭
24시간 내에 색인 된다.
최종 확인: 개발 환경과 배포 비교
Dev 환경에서는 http://localhost:3000이지만, 배포 환경에서는 https://example.com이어야 한다.
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
export async function generateMetadata(): Promise<Metadata> {
return {
openGraph: {
images: [{
url: `${baseUrl}/og/default.png`, // 환경에 맞게
}],
},
};
}
marginBottom마지막으로, SEO는 빌드 시점부터 시작된다. 배포 후가 아니라 개발할 때부터 메타데이터를 제대로 설정해야 한다.