Skip to Content
WebReactNext.js13. 동적 메타데이터와 OG 이미지

이번 편의 결과물: 책 상세 페이지의 브라우저 탭 제목이 책 제목으로 바뀌고, 링크를 공유하면 미리보기 카드에 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 metadataapp/layout.js, app/books/page.js파일을 불러오는 즉시 값이 정해진다
generateMetadata 함수app/books/[bookId]/page.jsparams를 받아 책을 조회한 뒤 객체를 반환한다

generateMetadatapage.js의 기본 컴포넌트와 마찬가지로 paramsPromise로 받습니다. 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를 직접 호출하면 이 왕복을 건너뛸 수 있으므로, generateMetadataopengraph-image.jsfetch 대신 lib/db.js를 바로 불러 씁니다.

제목 템플릿

app/layout.jsmetadata.title을 문자열 대신 templatedefault를 가진 객체로 바꾸면, 하위 페이지가 반환한 제목이 자동으로 그 틀에 끼워집니다. 예를 들어 책 상세 페이지가 제목으로 “클린 코드”만 반환해도 브라우저 탭에는 “클린 코드 | 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.jsmetadata를 수정합니다.

// 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.jspage.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”로 보입니다.

직접 해보기

  1. opengraph-image.js에서 book.statusdone이면 “완독”이라는 문구를 이미지 아래쪽에 추가로 그려 보세요.
  2. app/books/page.jsmetadataopenGraph 필드를 추가해, 목록 페이지도 고유한 공유 카드 설명을 갖게 만들어 보세요.

정답 보기

{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”로만 보임generateMetadataexport하지 않고 일반 함수로 선언함수 앞에 exportasync를 붙였는지 확인한다
탭 제목에 ”| bookshelf-next”가 안 붙음루트 레이아웃의 title을 문자열 그대로 둠title{ template, default } 객체로 바꾼다
/books/1/opengraph-image 접속 시 빈 화면next/og가 아닌 다른 경로에서 ImageResponse를 importimport { ImageResponse } from 'next/og'인지 확인한다
없는 책 id의 OG 이미지에서 에러 발생bookundefined인데 book.title에 바로 접근삼항 연산자로 book이 없을 때의 값을 먼저 준비한다

확인 문제

문제 14지선다
app/books/page.js에 generateMetadata 대신 정적 metadata 객체를 쓴 이유는
문제 24지선다
app/books/[bookId]/page.js의 generateMetadata에서 params를 다루는 방식은
문제 34지선다
루트 레이아웃의 title을 template과 default를 가진 객체로 바꿨을 때 일어나는 일은
문제 44지선다
app/books/[bookId]/opengraph-image.js 파일이 하는 일은

참고 자료

Last updated on