Metadata·Open Graph·SEO 기본기
Metadata·Open Graph·SEO 기본기
요약: metadata API로 페이지마다 제목·설명·정규 URL·공유 미리보기를 맞춘다.
1. 왜 이 주제가 필요한가
검색 결과 제목과 SNS 카드는 HTML <head>에서 온다. React로 화면을 그려도, 크롤러가 그 값을 못 읽으면 링크는 빈 카드가 된다.
App Router는 next/head 대신 metadata API를 쓴다. 서버가 페이지를 렌더할 때 제목·설명·OG를 같이 넣는다.
SEO(검색 결과에 잘 잡히게 하는 작업) 의 최소선은 고유한 title, 정확한 canonical, 본문이 있는 HTML이다.
2. 한 줄 규칙
루트에 기본 title 템플릿을 두고, 글 상세는 generateMetadata로 그 글의 값을 넣는다.
화면에 보이는 URL과 canonical·sitemap·OG url이 같아야 한다. 쿼리스트링 상세(/view?slug=)는 이 세 곳이 어긋나기 쉽다.
3. 예제
정적 metadata
// app/layout.tsx
import type {Metadata} from 'next';
import type {ReactNode} from 'react';
export const metadata: Metadata = {
metadataBase: new URL('https://blog.example.com'),
title: {
default: 'Kaisa Blog',
template: '%s · Kaisa Blog',
},
description: '웹 기술 노트',
};
export default function RootLayout({children}: {children: ReactNode}) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
하위 페이지가 title: '글 목록'이면 탭 제목은 글 목록 · Kaisa Blog가 된다.
metadataBase는 OG 이미지처럼 상대 경로를 절대 URL로 만들 때 기준이 된다. NEXT_PUBLIC_SITE_URL과 같은 값을 쓰는 편이 안전하다.
// app/posts/page.tsx
import type {Metadata} from 'next';
export const metadata: Metadata = {
title: '글 목록',
description: '발행한 글 목록',
};
export default function PostsPage() {
return <h1>글 목록</h1>;
}
동적 generateMetadata
// app/posts/[slug]/page.tsx
import type {Metadata} from 'next';
import {notFound} from 'next/navigation';
import {getPost} from '@/lib/posts';
type Props = {
params: Promise<{slug: string}>;
};
export async function generateMetadata({params}: Props): Promise<Metadata> {
const {slug} = await params;
const post = getPost(slug);
if (!post) return {};
const url = `https://blog.example.com/posts/${slug}/`;
return {
title: post.title,
description: post.excerpt,
alternates: {canonical: url},
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
url,
images: post.ogImage ? [{url: post.ogImage}] : undefined,
},
};
}
export default async function PostPage({params}: Props) {
const {slug} = await params;
const post = getPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
</article>
);
}
OG(Open Graph, 링크를 붙일 때 보이는 제목·이미지 규격) 는 openGraph 필드다. 카카오·슬랙·페이스북이 이 값을 읽는다.
trailingSlash: true면 canonical에도 끝 /를 붙인다.
sitemap과 robots
// app/sitemap.ts
import type {MetadataRoute} from 'next';
import {getAllSlugs} from '@/lib/posts';
const SITE = 'https://blog.example.com';
export default function sitemap(): MetadataRoute.Sitemap {
const posts = getAllSlugs().map((slug) => ({
url: `${SITE}/posts/${slug}/`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
return [
{url: `${SITE}/`, lastModified: new Date(), priority: 1},
{url: `${SITE}/posts/`, lastModified: new Date(), priority: 0.9},
...posts,
];
}
// app/robots.ts
import type {MetadataRoute} from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: {userAgent: '*', allow: '/', disallow: ['/manager/', '/login/']},
sitemap: 'https://blog.example.com/sitemap.xml',
};
}
로그인·관리자·내부 검색 결과는 색인에서 뺀다.
export const metadata: Metadata = {
robots: {index: false, follow: false},
};
JSON-LD
검색 결과가 글 카드로 나오길 바치면 구조화 데이터를 본문에 넣는다.
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
datePublished: post.publishedAt,
url: `https://blog.example.com/posts/${post.slug}/`,
};
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
/>
<h1>{post.title}</h1>
</article>
);
값은 화면에 보이는 제목·날짜와 같아야 한다.
4. 흔한 실수
| 실수 | 결과 | 대안 |
|---|---|---|
| 모든 페이지가 같은 title | 검색에서 구분 안 됨 | 템플릿 + 페이지별 title |
| canonical이 www / 슬래시와 불일치 | 중복 문서로 잡힘 | metadataBase와 실제 URL 통일 |
| 본문은 CSR, 메타만 정적 | 미리보기는 되는데 본문이 비어 있다 | 서버에서 본문 HTML |
/view?slug= 상세 |
sitemap·OG와 주소가 다름 | /posts/[slug] 경로 |
| 로그인 페이지를 sitemap에 넣음 | 불필요한 색인 | noindex + disallow |
| OG 이미지 상대 경로만 | 카드 이미지 깨짐 | metadataBase 또는 절대 URL |
정리
SEO 기본은 페이지마다 다른 title·description, 하나의 canonical, 본문이 있는 HTML이다.
목록·상세·sitemap·OG가 같은 URL 규칙을 쓰면 된다. 인증이 필요한 화면은 색인하지 않는다.
연습
- 글 상세에
generateMetadata와 canonical을 붙인다.trailingSlash와 일치하는지 확인한다. - sitemap에는 공개 글만 넣고,
/login·/manager는robots와noindex로 막는다. - 카카오톡이나 슬랙에 글 URL을 붙여 OG 제목·이미지가 나오는지 확인한다.
댓글
0아직 댓글이 없습니다. 첫 댓글을 남겨보세요.