이번 편의 결과물: 책 상세 페이지의 브라우저 탭 제목이 책 제목으로 바뀌고, 링크를 공유하면 미리보기 카드에 OG 이미지가 보입니다. · 다루는 개념: generateMetadata로 페이지별 title/description, 책 상세별 동적 메타데이터, ImageResponse로 OG 이미지 생성
지금까지 bookshelf-next의 모든 페이지는 app/layout.js에 고정된 제목 “bookshelf-next”를 그대로 물려받았습니다. 이 편에서는 페이지마다, 그리고 책마다 다른 제목·설명·미리보기 이미지를 만듭니다.
이 편에서 만드는 파일
bookshelf-next/
└── app/
├── layout.js ~ (제목 템플릿 추가)
└── books/
├── page.js ~ (정적 metadata 추가)
└── [bookId]/
├── page.js ~ (generateMetadata 추가)
└── opengraph-image.js + (ImageResponse로 책별 OG 이미지 생성)12편까지 만든 검색·정렬(app/books/page.js)과 수정·삭제 폼·Suspense 스트리밍(app/books/[bookId]/page.js)은 그대로 두고, 그 위에 메타데이터만 추가합니다.
개념 정리
정적 metadata와 generateMetadata
값이 고정된 페이지는 export const metadata 객체 하나로 충분합니다. 값이 데이터에 따라 달라지는 페이지는 generateMetadata 함수를 대신 씁니다.
| 방식 | 쓰는 위치 | 특징 |
|---|---|---|
export const metadata | app/layout.js, app/books/page.js | 파일을 불러오는 즉시 값이 정해진다 |
generateMetadata 함수 | app/books/[bookId]/page.js | params를 받아 책을 조회한 뒤 객체를 반환한다 |
generateMetadata는 page.js의 기본 컴포넌트와 마찬가지로 params를 Promise로 받습니다. 05편에서 다룬 것과 같은 방식으로 await한 뒤 값을 꺼냅니다.
export async function generateMetadata({ params }) {
const { bookId } = await params
}책을 찾지 못하면 기본 컴포넌트는 notFound()를 호출해 404 화면을 띄우지만, generateMetadata는 그 흐름과 별개로 실행되므로 책이 없을 때 보여줄 제목을 직접 반환해 둡니다.
generateMetadata에서는 getBookById를 직접 부른다
app/books/[bookId]/page.js의 기본 컴포넌트는 08편부터 자기 자신의 /api/books/[id] Route Handler를 fetch로 호출합니다. generateMetadata에서도 같은 방식을 쓰면 같은 책 정보를 얻으려고 서버가 자기 자신에게 또 한 번 HTTP 요청을 보내야 합니다. 07편에서 만든 getBookById를 직접 호출하면 이 왕복을 건너뛸 수 있으므로, generateMetadata와 opengraph-image.js는 fetch 대신 lib/db.js를 바로 불러 씁니다.
제목 템플릿
app/layout.js의 metadata.title을 문자열 대신 template과 default를 가진 객체로 바꾸면, 하위 페이지가 반환한 제목이 자동으로 그 틀에 끼워집니다. 예를 들어 책 상세 페이지가 제목으로 “클린 코드”만 반환해도 브라우저 탭에는 “클린 코드 | bookshelf-next”가 보입니다.
ImageResponse와 opengraph-image 파일 컨벤션
next/og가 제공하는 ImageResponse는 JSX와 CSS로 이미지를 즉석에서 그려 PNG로 반환합니다. app/books/[bookId] 폴더에 opengraph-image.js 파일을 두면, Next.js가 자동으로 /books/1/opengraph-image 같은 이미지 전용 라우트를 만들고 그 페이지의 og:image 메타 태그에 연결합니다. 별도의 이미지 파일을 만들거나 업로드할 필요가 없습니다.
실습
1. 루트 레이아웃에 제목 템플릿 적용
app/layout.js의 metadata를 수정합니다.
// bookshelf-next/app/layout.js
import './globals.css'
import SiteHeader from './components/site-header'
export const metadata = {
title: {
template: '%s | bookshelf-next',
default: 'bookshelf-next',
},
description: 'bookshelf를 Next.js App Router로 새로 만드는 연습 프로젝트입니다.',
}
export default function RootLayout({ children }) {
return (
<html lang="ko">
<body>
<SiteHeader />
<main className="page-content">{children}</main>
</body>
</html>
)
}2. 책 목록 페이지에 정적 metadata 추가
app/books/page.js는 12편에서 만든 검색·필터·정렬 그대로 두고, metadata export만 추가합니다. 이 페이지는 검색 조건이 달라져도 제목·설명 자체는 항상 같으므로 generateMetadata가 필요 없습니다.
// bookshelf-next/app/books/page.js
import Link from 'next/link'
import Form from 'next/form'
import { getBooks } from '@/lib/db'
import { BOOK_STATUSES } from '@/lib/book-status'
import BookList from './BookList'
import SearchButton from './SearchButton'
export const metadata = {
title: '책 목록',
description: 'bookshelf-next에 기록된 책 목록입니다.',
}
const STATUS_FILTER_OPTIONS = [{ value: 'all', label: '전체' }, ...BOOK_STATUSES]
const SORT_OPTIONS = [
{ value: 'title-asc', label: '제목 오름차순' },
{ value: 'title-desc', label: '제목 내림차순' },
{ value: 'pages-asc', label: '쪽수 적은 순' },
{ value: 'pages-desc', label: '쪽수 많은 순' },
]
export default async function BooksPage({ searchParams }) {
const { q = '', status = 'all', sort = 'title-asc' } = await searchParams
const books = await getBooks()
const filteredBooks = filterAndSortBooks(books, { q, status, sort })
return (
<section>
<p>
<Link href="/books/new">+ 새 책 등록</Link>
</p>
<Form action="" className="book-filter">
<input type="text" name="q" placeholder="제목·저자 검색" defaultValue={q} />
<select name="status" defaultValue={status}>
{STATUS_FILTER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select name="sort" defaultValue={sort}>
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<SearchButton />
</Form>
<p className="book-filter__result">검색 결과 {filteredBooks.length}권</p>
<BookList books={filteredBooks} />
</section>
)
}
function filterAndSortBooks(books, { q, status, sort }) {
let result = books
const keyword = q.trim().toLowerCase()
if (keyword) {
result = result.filter(
(book) =>
book.title.toLowerCase().includes(keyword) || book.author.toLowerCase().includes(keyword)
)
}
if (status !== 'all') {
result = result.filter((book) => book.status === status)
}
const sorted = [...result]
if (sort === 'pages-asc') {
sorted.sort((a, b) => a.pages - b.pages)
} else if (sort === 'pages-desc') {
sorted.sort((a, b) => b.pages - a.pages)
} else if (sort === 'title-desc') {
sorted.sort((a, b) => b.title.localeCompare(a.title))
} else {
sorted.sort((a, b) => a.title.localeCompare(b.title))
}
return sorted
}3. 책 상세 페이지에 generateMetadata 추가
app/books/[bookId]/page.js는 09편의 수정·삭제 폼과 06편의 Suspense 스트리밍을 그대로 두고, generateMetadata만 추가합니다.
// bookshelf-next/app/books/[bookId]/page.js
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { BASE_URL } from '@/lib/site-url.js'
import { wait } from '@/lib/wait.js'
import { getBookById } from '@/lib/db.js'
import { BOOK_STATUSES } from '@/lib/book-status'
import { updateBookAction, deleteBookAction } from '../actions'
export async function generateMetadata({ params }) {
const { bookId } = await params
const book = await getBookById(Number(bookId))
if (!book) {
return {
title: '책을 찾을 수 없음',
}
}
return {
title: book.title,
description: `${book.author} 저 · ${book.pages}쪽`,
}
}
export default async function BookDetailPage({ params }) {
const { bookId } = await params
const response = await fetch(`${BASE_URL}/api/books/${bookId}`, { cache: 'no-store' })
if (response.status === 404) {
notFound()
}
const book = await response.json()
const updateThisBook = updateBookAction.bind(null, book.id)
const deleteThisBook = deleteBookAction.bind(null, book.id)
return (
<article>
<p>
<Link href="/books">목록으로</Link>
</p>
<h1>{book.title}</h1>
<p>{book.author}</p>
<dl>
<dt>쪽수</dt>
<dd>{book.pages}쪽</dd>
<dt>상태</dt>
<dd>{book.status}</dd>
<dt>메모</dt>
<dd>{book.memo || '메모 없음'}</dd>
</dl>
<form action={updateThisBook} className="edit-form">
<label htmlFor="status">상태</label>
<select id="status" name="status" defaultValue={book.status}>
{BOOK_STATUSES.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
<label htmlFor="memo">메모</label>
<textarea id="memo" name="memo" defaultValue={book.memo}></textarea>
<button type="submit">수정 저장</button>
</form>
<form action={deleteThisBook}>
<button type="submit">이 책 삭제</button>
</form>
<Suspense fallback={<p>추천 정보를 불러오는 중입니다...</p>}>
<ReadingTip pages={book.pages} />
</Suspense>
</article>
)
}
async function ReadingTip({ pages }) {
await wait(1500)
const days = Math.ceil(pages / 40)
return <p>하루 40쪽씩 읽으면 약 {days}일 만에 완독할 수 있습니다.</p>
}generateMetadata는 08편의 fetch 대신 getBookById를 바로 부르지만, 기본 컴포넌트는 그대로 fetch(... { cache: 'no-store' })를 씁니다. 같은 파일 안에서 두 함수가 서로 다른 방식으로 같은 데이터를 조회하는 것이 어색해 보일 수 있지만, 각자 08·09편에서 이미 정한 방식을 그대로 따른 것뿐입니다. 상태 select도 09편의 하드코딩된 option 대신 10편에서 만든 BOOK_STATUSES를 재사용하도록 바꿨습니다.
4. 책별 OG 이미지 만들기
같은 [bookId] 폴더에 opengraph-image.js를 추가합니다.
// bookshelf-next/app/books/[bookId]/opengraph-image.js
import { ImageResponse } from 'next/og'
import { getBookById } from '@/lib/db.js'
export const size = {
width: 1200,
height: 630,
}
export const contentType = 'image/png'
export default async function Image({ params }) {
const { bookId } = await params
const book = await getBookById(Number(bookId))
const title = book ? book.title : '책을 찾을 수 없음'
const author = book ? book.author : ''
const background = book ? book.coverColor : '#1f2937'
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: background,
color: '#ffffff',
}}
>
<div style={{ fontSize: 64, fontWeight: 700 }}>{title}</div>
<div style={{ fontSize: 32, marginTop: 16 }}>{author}</div>
</div>
)
)
}opengraph-image.js는 page.js와 별도로 실행되는 파일이라 그 안의 지역 함수를 가져다 쓸 수 없습니다. 같은 getBookById를 이 파일에서 다시 import해 씁니다.
5. 실행과 확인
npm run dev확인
/books/1에 접속하면 브라우저 탭 제목이 “클린 코드 | bookshelf-next”로 보입니다./books에 접속하면 탭 제목이 “책 목록 | bookshelf-next”로 보입니다.- 페이지 소스 보기를 열어
head태그 안에서og:title,og:image메타 태그를 확인할 수 있습니다. - 주소창에 직접
http://localhost:3000/books/1/opengraph-image를 입력하면 책 제목과 저자가 적힌 PNG 이미지가 보입니다. /books/999처럼 없는 id로 접속하면 탭 제목이 “책을 찾을 수 없음 | bookshelf-next”로 보입니다.
직접 해보기
opengraph-image.js에서book.status가done이면 “완독”이라는 문구를 이미지 아래쪽에 추가로 그려 보세요.app/books/page.js의metadata에openGraph필드를 추가해, 목록 페이지도 고유한 공유 카드 설명을 갖게 만들어 보세요.
정답 보기
{book && book.status === 'done' && (
<div style={{ fontSize: 24, marginTop: 24 }}>완독</div>
)}ImageResponse의 JSX 안에서도 일반 React처럼 조건부 렌더링을 쓸 수 있습니다.
export const metadata = {
title: '책 목록',
description: 'bookshelf-next에 기록된 책 목록입니다.',
openGraph: {
title: '책 목록 | bookshelf-next',
description: '지금까지 읽은 책과 읽고 있는 책을 한눈에 봅니다.',
},
}자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 상세 페이지 탭 제목이 항상 “bookshelf-next”로만 보임 | generateMetadata를 export하지 않고 일반 함수로 선언 | 함수 앞에 export와 async를 붙였는지 확인한다 |
| 탭 제목에 ”| bookshelf-next”가 안 붙음 | 루트 레이아웃의 title을 문자열 그대로 둠 | title을 { template, default } 객체로 바꾼다 |
/books/1/opengraph-image 접속 시 빈 화면 | next/og가 아닌 다른 경로에서 ImageResponse를 import | import { ImageResponse } from 'next/og'인지 확인한다 |
| 없는 책 id의 OG 이미지에서 에러 발생 | book이 undefined인데 book.title에 바로 접근 | 삼항 연산자로 book이 없을 때의 값을 먼저 준비한다 |