← 전체 글로 돌아가기

Next.js

Next.js generateMetadata에서 헷갈렸던 것들

generateMetadata는 단순해 보이지만 params 접근, async fetch, 레이아웃과의 관계에서 예상과 다르게 동작하는 경우가 있다.

Next.js App Router에서 generateMetadata를 처음 쓸 때 몇 가지 동작이 직관과 달라서 삽질했다. 비슷하게 헷갈릴 수 있는 부분을 정리한다.

함수 시그니처

generateMetadatapage.tsx에서 export async function으로 선언한다. paramssearchParams를 인자로 받는다.

import type { Metadata } from 'next';

type Props = {
  params: { slug: string };
  searchParams: { [key: string]: string | string[] | undefined };
};

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPost(params.slug);

  if (!post) {
    return { title: '글을 찾을 수 없습니다' };
  }

  return {
    title: post.title,
    description: post.excerpt,
  };
}

params.slug는 파일 이름 [slug]/page.tsx와 일치한다. 중첩 동적 경로면 params.category, params.slug 둘 다 받을 수 있다.

데이터 fetch를 두 번 안 해도 된다

generateMetadata 안에서 getPost(slug)를 호출하고, page.tsxPage 컴포넌트에서도 같은 함수를 호출하면 두 번 요청하는 건지 걱정했다. Next.js는 fetch를 자동으로 메모이제이션하기 때문에 같은 URL로 같은 요청을 하면 실제로는 한 번만 나간다.

단, fetch를 쓰는 경우에 한정된다. DB를 직접 조회하는 ORM 호출은 메모이제이션이 안 된다. 이럴 때는 React의 cache()를 쓴다.

import { cache } from 'react';

const getPost = cache(async (slug: string) => {
  return db.post.findUnique({ where: { slug } });
});

이렇게 감싸면 generateMetadataPage 컴포넌트 모두에서 호출해도 실제 DB 쿼리는 한 번만 실행된다.

리소스가 없을 때 notFound()를 언제 쓰는지

generateMetadata에서 notFound()를 호출할 수 있다. 하지만 메타데이터가 없다고 바로 404를 내리기보다는, 빈 메타데이터를 반환하고 Page 컴포넌트에서 notFound()를 호출하는 패턴이 더 깔끔하다.

// page.tsx
export default async function Page({ params }: Props) {
  const post = await getPost(params.slug);
  if (!post) notFound();  // 여기서 404 처리
  return <PostContent post={post} />;
}

레이아웃 메타데이터와의 합성

layout.tsxtitle.template을 설정해두면 page.tsxgenerateMetadata에서 title만 문자열로 반환해도 자동으로 합쳐진다.

// layout.tsx
export const metadata: Metadata = {
  title: { template: '%s | 내 블로그', default: '내 블로그' },
};

// page.tsx의 generateMetadata
return { title: '특정 글 제목' };
// → 실제 title: '특정 글 제목 | 내 블로그'

페이지에서 title을 객체로 넘기면 레이아웃 template을 무시할 수 있다.

return { title: { absolute: '이 페이지만의 제목' } };