Next.js Metadata API 핵심 요약
App Router에서는 변하지 않는 정보는 metadata 객체로, 경로 매개변수나 데이터 조회 결과에 따라 달라지는 정보는 generateMetadata로 선언합니다. 현재 App Router의 params는 Promise이므로 params: Promise<{ slug: string }>로 타입을 지정하고 값을 쓰기 전에 await params 해야 합니다.
검증 기준: 2026년 7월 19일 확인한 Next.js 16.2.10 공식 문서, App Router, TypeScript 예제입니다. 버전이 달라지면 프로젝트의 설치 버전과 해당 버전 문서를 먼저 확인하세요.
- Metadata API로 관리할 항목
- root layout과 page 역할 분리
- 정적 metadata 예제
- Promise params를 쓰는 generateMetadata
- 중첩 metadata의 얕은 병합
- icons·verification·Viewport
- 캐시와 스트리밍
- 배포 검증 체크리스트
Metadata API로 무엇을 관리할까
Metadata API는 단순히 검색 제목만 넣는 기능이 아닙니다. 문서 제목과 설명, canonical, Open Graph, X 카드, robots 지시, 아이콘, 사이트 소유권 확인 값을 route segment 단위로 관리합니다. Next.js는 이 값을 해석해 필요한 <title>, <meta>, <link> 요소를 만듭니다.
| 항목 | 주로 두는 위치 | 확인할 점 |
|---|---|---|
metadataBase, title 템플릿 |
root layout.tsx |
사이트 전체 기준 URL과 이름을 한곳에서 관리 |
| 페이지 title·description | 각 page.tsx |
페이지 검색 의도와 실제 본문이 일치하는지 확인 |
| canonical·Open Graph URL | 각 page 또는 동적 함수 | 모든 페이지가 홈 canonical을 상속하지 않도록 페이지별 지정 |
robots·googleBot |
필요한 segment | 사이트맵과 실제 색인 지시가 충돌하지 않는지 확인 |
| 아이콘·인증 | root layout 또는 파일 기반 metadata | 실제 파일과 발급받은 인증값 사용 |
themeColor |
viewport export |
metadata의 구식 필드로 넣지 않음 |
Google은 meta keywords를 검색 색인이나 순위에 사용하지 않는다고 명시합니다. 따라서 키워드 배열을 반복해 채우는 것보다 고유한 title, 본문을 정확히 요약한 description, 일관된 canonical을 우선하세요. description도 검색 결과에 항상 그대로 표시된다는 보장은 없습니다.

root layout과 page의 역할을 분리한다
root layout에는 여러 페이지가 공유하는 기본값만 둡니다. 페이지마다 달라지는 canonical이나 article Open Graph를 root에 고정하면 하위 페이지가 잘못된 URL을 내보낼 수 있습니다. title 템플릿은 layout에 두고, 실제 페이지 title은 page에서 지정하는 구조가 읽기 쉽습니다.
// app/layout.tsx
import type { Metadata, Viewport } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: {
default: 'Acme 개발 문서',
template: '%s | Acme 개발 문서',
},
description: 'Acme 제품과 개발 문서를 제공합니다.',
applicationName: 'Acme',
icons: {
icon: '/favicon.ico',
apple: '/apple-touch-icon.png',
},
verification: {
google: '실제로-발급받은-확인값',
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
'max-video-preview': -1,
},
},
}
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#111827' },
],
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang='ko'>
<body>{children}</body>
</html>
)
}
같은 목적의 <meta> 요소를 <head>에 다시 수동 작성하지 마세요. Metadata API와 수동 태그가 중복되면 어느 파일이 최종값을 만드는지 추적하기 어려워집니다. 단, resource hint처럼 Metadata API가 직접 다루지 않는 항목은 별도 API가 필요할 수 있습니다.
변하지 않는 페이지는 정적 metadata를 사용한다
소개, 가격, 고정 가이드처럼 요청마다 값이 달라지지 않는 페이지는 함수가 필요 없습니다. 정적 객체는 의도가 분명하고 빌드 시점에 결과를 확인하기 쉽습니다.
// app/guides/metadata/page.tsx
import type { Metadata } from 'next'
const title = 'Metadata API 가이드'
const description = '정적 metadata와 generateMetadata 사용 기준을 설명합니다.'
export const metadata: Metadata = {
title,
description,
alternates: {
canonical: '/guides/metadata',
},
openGraph: {
type: 'article',
url: '/guides/metadata',
title,
description,
images: [
{ url: '/images/metadata-guide.png', width: 1200, height: 630, alt: 'Metadata API 가이드' },
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: ['/images/metadata-guide.png'],
},
}
export default function MetadataGuidePage() {
return <main>가이드 본문</main>
}
metadataBase가 상위 layout에 있으면 위의 상대 URL은 절대 URL로 조합됩니다. 상위에 기준 URL이 없는데 절대 URL이 필요한 필드에 상대 경로를 쓰면 빌드 오류가 날 수 있습니다. title 템플릿은 자신이 선언된 layout의 하위 segment에 적용되며 같은 segment의 page title에는 적용되지 않는다는 점도 함께 확인하세요.
동적 페이지는 Promise params를 await한다
상품이나 게시글처럼 URL의 slug로 데이터를 찾는 페이지에서는 generateMetadata를 사용합니다. Next.js 16의 App Router 예제에서 params는 Promise입니다. 예전의 동기식 params 타입과 값을 즉시 읽는 코드는 제거해야 합니다.
// app/posts/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
type Props = {
params: Promise<{ slug: string }>
}
async function getPostBySlug(slug: string) {
const response = await fetch(`https://api.example.com/posts/${encodeURIComponent(slug)}`)
if (response.status === 404) return null
if (!response.ok) throw new Error('게시글을 불러오지 못했습니다.')
return response.json() as Promise<{ title: string; description: string; image: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) notFound()
return {
title: post.title,
description: post.description,
alternates: { canonical: `/posts/${slug}` },
openGraph: {
type: 'article',
url: `/posts/${slug}`,
title: post.title,
description: post.description,
images: [{ url: post.image, alt: post.title }],
},
}
}
export default async function PostPage({ params }: Props) {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) notFound()
return <article><h1>{post.title}</h1></article>
}
metadata 객체와 generateMetadata 함수는 같은 route segment에서 동시에 export할 수 없고 둘 다 Server Component에서만 지원됩니다. 상호작용이 필요하면 page는 Server Component로 두고 버튼이나 폼만 별도 Client Component로 분리하세요.

중첩 객체는 깊게 합쳐지지 않는다
route를 따라 평가된 metadata 객체는 얕게 병합됩니다. 뒤 segment가 openGraph를 새로 선언하면 앞 segment의 openGraph 내부 값이 자동으로 하나씩 보존되는 것이 아니라 해당 중첩 객체가 교체됩니다. robots 같은 다른 중첩 필드도 같은 방식으로 판단해야 합니다.
import type { Metadata, ResolvingMetadata } from 'next'
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata,
): Promise<Metadata> {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) notFound()
const inheritedImages = (await parent).openGraph?.images ?? []
return {
title: post.title,
openGraph: {
title: post.title,
description: post.description,
images: [post.image, ...inheritedImages],
},
}
}
상위 값을 정말 이어받아야 할 때만 parent를 읽어 명시적으로 합치세요. 페이지별 정보가 상위 기본값을 완전히 대체해야 한다면 억지로 합치지 않는 편이 맞습니다.
icons·verification·Viewport를 정확히 나눈다
icons는 favicon, shortcut icon, Apple touch icon을 표현할 수 있고, verification은 Google 등에서 실제 발급받은 소유권 확인값을 담습니다. 예제 문자열을 운영 환경에 그대로 복사하지 말고 서비스별 발급값을 사용하세요. 아이콘은 favicon.ico, icon.png, apple-icon.png 같은 파일 기반 metadata로 관리할 수도 있으며 파일 기반 값이 객체나 함수보다 우선합니다.
themeColor와 colorScheme를 metadata에 넣는 방식은 deprecated입니다. 정적 값은 별도의 viewport: Viewport export로 옮깁니다. 기본 viewport는 Next.js가 자동으로 설정하므로 확대를 막는 userScalable: false 같은 접근성 저하 설정을 관성적으로 추가하지 마세요.
데이터 중복 조회와 스트리밍을 구분한다
generateMetadata, generateStaticParams, layout, page, Server Component에서 동일한 fetch 요청을 사용하면 Next.js가 자동으로 memoize합니다. fetch가 아닌 데이터베이스 함수라면 React cache로 요청 단위 중복 실행을 줄일 수 있습니다. 캐시 정책과 데이터 갱신 정책은 별도로 설계해야 하며, memoize가 영구 데이터 캐시를 뜻하는 것은 아닙니다.
동적으로 렌더링되는 페이지의 metadata는 UI가 먼저 전송된 뒤 스트리밍될 수 있습니다. metadata를 <head>에서 기대하는 봇에는 스트리밍이 비활성화되며, 사전 렌더링 페이지는 빌드 시 해결되므로 스트리밍하지 않습니다. 크롤러별 결과를 추측하지 말고 배포 후 최종 HTML과 공식 URL 검사 도구로 확인하세요.
배포 전에 확인할 체크리스트
- 각 URL의 title과 description이 실제 H1·본문을 정확히 요약하는지 확인합니다.
- canonical이 자기 자신의 최종 200 URL을 가리키고 리디렉션 URL이나 홈으로 잘못 고정되지 않았는지 확인합니다.
- Open Graph의 URL·이미지가 절대 URL로 해석되고 이미지 요청이 200인지 확인합니다.
robots,googleBot, HTTPX-Robots-Tag, 사이트맵의 색인 정책이 서로 충돌하지 않는지 확인합니다.- 같은 segment에 정적
metadata와generateMetadata가 함께 export되지 않았는지 확인합니다. - 동적 route의 모든 코드가 Promise
params를 await하고, 없는 데이터는notFound()로 처리하는지 확인합니다. - 브라우저의 Elements만 보지 말고 페이지 원본, 소셜 미리보기, Search Console URL 검사 결과를 함께 확인합니다.
Client Component만으로 구성한 React SPA도 검색될 수는 있지만, JavaScript로 metadata를 뒤늦게 주입하면 크롤러와 공유 봇마다 처리 시점이 달라질 수 있습니다. Google도 JavaScript로 meta 태그를 주입하거나 바꿀 때 주의하고 충분히 테스트하라고 안내합니다. App Router에서는 서버에서 해결되는 Metadata API를 우선 사용하고 검색 노출을 라이브러리 선택 하나의 결과로 단정하지 마세요.
공식 문서
- Next.js generateMetadata API
- Next.js generateViewport API
- Next.js Metadata and OG images
- Google Search 지원 meta 태그
다음 단계
동적 metadata의 slug가 어디서 오는지 먼저 이해하려면 Next.js 동적 라우트 완전 정리에서 [slug], Promise params, generateStaticParams 흐름을 이어서 확인하세요. 사이트 전체 검색 구조는 Next.js SEO 완전 가이드에서 사이트맵·canonical·렌더링을 함께 점검할 수 있습니다.