Ch 07. SEO 최적화 — metadata API
좋은 글을 써도 검색 엔진이 찾지 못하면 아무도 읽지 않습니다. SEO(Search Engine Optimization)는 검색 결과에서 더 잘 보이도록 설정하는 작업입니다. Next.js는 metadata API로 이것을 쉽게 만들어줍니다.
정적 메타데이터
변하지 않는 메타데이터는 객체로 내보냅니다.
// 파일: app/layout.tsximport type { Metadata } from "next";export const metadata: Metadata = { title: { default: "나의 블로그", // 기본 제목 template: "%s | 나의 블로그", // 하위 페이지 제목 형식 }, description: "개발 이야기를 씁니다", keywords: ["Next.js", "React", "TypeScript", "블로그"], authors: [{ name: "홍길동", url: "https://myblog.com" }], creator: "홍길동", openGraph: { type: "website", locale: "ko_KR", url: "https://myblog.com", siteName: "나의 블로그", title: "나의 블로그", description: "개발 이야기를 씁니다", images: [ { url: "https://myblog.com/og-image.png", width: 1200, height: 630, alt: "나의 블로그", }, ], }, twitter: { card: "summary_large_image", title: "나의 블로그", description: "개발 이야기를 씁니다", images: ["https://myblog.com/og-image.png"], },};```text`title.template`의 `%s`는 하위 페이지의 제목으로 대체됩니다. 글 상세 페이지의 제목이 "Next.js 시작하기"라면, 최종 `<title>`은 "Next.js 시작하기 | 나의 블로그"가 됩니다.### generateMetadata — 동적 메타데이터글 상세 페이지처럼 내용에 따라 메타데이터가 달라지는 경우, `generateMetadata` 함수를 사용합니다.```typescript// 파일: app/posts/[slug]/page.tsximport { prisma } from "@/lib/prisma";import type { Metadata } from "next";type Props = { params: Promise<{ slug: string }> };export async function generateMetadata({ params }: Props): Promise<Metadata> { const { slug } = await params; const post = await prisma.post.findUnique({ where: { slug }, include: { author: { select: { name: true } } }, }); if (!post) { return { title: "글을 찾을 수 없습니다", }; } // 마크다운에서 첫 200자를 description으로 사용 const description = post.content.slice(0, 200).replace(/[#*`]/g, ""); return { title: post.title, description, authors: [{ name: post.author.name }], openGraph: { title: post.title, description, type: "article", publishedTime: post.createdAt.toISOString(), authors: [post.author.name], }, twitter: { card: "summary_large_image", title: post.title, description, }, };}```text`generateMetadata`와 페이지 컴포넌트 모두 같은 데이터(post)를 조회합니다. Next.js는 같은 인자로 호출되는 `fetch` 요청을 자동으로 중복 제거(deduplication)합니다. Prisma는 `fetch`를 사용하지 않으므로, 데이터 조회 함수를 분리하고 `cache()`로 감싸면 됩니다.```typescript// 파일: app/posts/[slug]/page.tsx (캐싱 최적화)import { cache } from "react";import { prisma } from "@/lib/prisma";// React cache로 같은 slug의 중복 DB 조회 방지const getPost = cache(async (slug: string) => { return prisma.post.findUnique({ where: { slug }, include: { author: { select: { id: true, name: true } } }, });});```text### sitemap.ts — 사이트맵 자동 생성검색 엔진 크롤러에게 사이트 구조를 알려줍니다.```typescript// 파일: app/sitemap.tsimport { prisma } from "@/lib/prisma";import type { MetadataRoute } from "next";export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const posts = await prisma.post.findMany({ where: { published: true }, select: { slug: true, updatedAt: true }, }); const postEntries = posts.map((post) => ({ url: `https://myblog.com/posts/${post.slug}`, lastModified: post.updatedAt, changeFrequency: "weekly" as const, priority: 0.8, })); return [ { url: "https://myblog.com", lastModified: new Date(), changeFrequency: "daily", priority: 1, }, { url: "https://myblog.com/posts", lastModified: new Date(), changeFrequency: "daily", priority: 0.9, }, ...postEntries, ];}```text`https://myblog.com/sitemap.xml`로 접근하면 XML 형식의 사이트맵이 자동으로 반환됩니다.### robots.ts — 크롤링 규칙 설정어떤 페이지를 크롤링하고 어떤 페이지를 제외할지 설정합니다.```typescript// 파일: app/robots.tsimport type { MetadataRoute } from "next";export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: "*", allow: "/", disallow: ["/dashboard", "/admin", "/api/"], }, ], sitemap: "https://myblog.com/sitemap.xml", };}```text`/dashboard`와 `/admin`은 로그인한 사용자만 볼 수 있으므로 크롤링에서 제외합니다. `https://myblog.com/robots.txt`로 접근하면 확인할 수 있습니다.### OpenGraph 이미지 자동 생성`opengraph-image.tsx`를 만들면 OpenGraph 이미지를 코드로 생성할 수 있습니다.```typescript// 파일: app/posts/[slug]/opengraph-image.tsximport { ImageResponse } from "next/og";import { prisma } from "@/lib/prisma";export const size = { width: 1200, height: 630 };export const contentType = "image/png";export default async function OgImage({ params,}: { params: Promise<{ slug: string }>;}) { const { slug } = await params; const post = await prisma.post.findUnique({ where: { slug }, include: { author: { select: { name: true } } }, }); return new ImageResponse( ( <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: "80px", background: "linear-gradient(135deg, #1e3a5f 0%, #0f2027 100%)", color: "white", }} > <div style={{ fontSize: 56, fontWeight: 700, lineHeight: 1.2 }}> {post?.title ?? "블로그 글"} </div> <div style={{ fontSize: 28, marginTop: 32, opacity: 0.7 }}> {post?.author.name} · 나의 블로그 </div> </div> ), size );}
소셜 미디어에 링크를 공유할 때 자동으로 생성된 이미지가 카드로 표시됩니다. 글마다 제목이 다른 OpenGraph 이미지를 동적으로 만들 수 있습니다.
다음 챕터에서는 개발 과정에서 자주 만나는 에러들과 해결 방법을 정리합니다.