performance2025-06-12·10 min·28/348

Next.js App Router에서 동적 metadata 설정하기

Next.js App Router에서 generateMetadata와 동적 메타데이터를 활용하여 SEO를 최적화하는 방법을 알아봅니다.

Next.js App Router에서 동적 metadata 설정하기

Introduction

Next.js App Router에서는 metadata 객체나 generateMetadata 함수를 사용하여 페이지의 메타데이터를 설정할 수 있습니다. 동적 메타데이터는 페이지별로 다르게 설정되어야 하는 SEO 정보를 처리하는 데 필수적입니다. 이 글에서는 동적 메타데이터 설정 방법을 다룹니다.

Environment

# 프로젝트 구조
my-app/
├── app/
│   ├── layout.tsx           # 루트 레이아웃
│   ├── page.tsx             # 홈페이지
│   ├── blog/
│   │   ├── [slug]/
│   │   │   ├── page.tsx     # 블로그 포스트
│   │   │   └── opengraph-image.tsx
│   │   └── page.tsx         # 블로그 목록
│   └── api/
│       └── posts/
│           └── route.ts
├── lib/
│   └── seo.ts
└── package.json

# 기술 스택
Next.js: 14.2.5
React: 18.3.1

Problem

정적 메타데이터만으로는 동적 콘텐츠에 대응할 수 없습니다:

// ❌ 정적 메타데이터로는 동적 콘텐츠 대응 불가
export const metadata = {
  title: '블로그 포스트',
  description: '이 블로그 포스트는...',
};

export default function BlogPost() {
  // 실제 콘텐츠는 런타임에 결정됨
  return 
{/* 동적 콘텐츠 */}
; }

Analysis

Next.js App Router의 메타데이터 시스템:

  1. 정적 메타데이터: metadata 객체로 설정
  2. 동적 메타데이터: generateMetadata 함수로 설정
  3. 자동 생성: Open Graph 이미지 자동 생성 가능
  4. SEO 최적화: 검색 엔진과 소셜 미디어 최적화

Solution

1. 기본 메타데이터 설정

// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: {
    default: '내 블로그',
    template: '%s | 내 블로그',
  },
  description: '개발 관련 블로그 포스트',
  keywords: ['Next.js', 'React', 'TypeScript'],
  authors: [{ name: 'Joel Park' }],
  creator: 'Joel Park',
  openGraph: {
    type: 'website',
    locale: 'ko_KR',
    url: 'https://myblog.com',
    siteName: '내 블로그',
  },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    
      {children}
    
  );
}

2. 동적 메타데이터 생성

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';

interface BlogPost {
  slug: string;
  title: string;
  description: string;
  content: string;
  publishedAt: string;
  author: string;
}

async function getBlogPost(slug: string): Promise {
  // API에서 블로그 포스트 가져오기
  const response = await fetch(`https://api.example.com/posts/${slug}`);
  
  if (!response.ok) {
    return null;
  }
  
  return response.json();
}

// 동적 메타데이터 생성
export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise {
  const post = await getBlogPost(params.slug);
  
  if (!post) {
    return {
      title: '포스트를 찾을 수 없습니다',
    };
  }
  
  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author],
      url: `https://myblog.com/blog/${post.slug}`,
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.description,
    },
  };
}

export default async function BlogPostPage({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getBlogPost(params.slug);
  
  if (!post) {
    notFound();
  }
  
  return (
    

{post.title}

{post.content}

); }

3. Open Graph 이미지 동적 생성

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';

export const runtime = 'edge';

export const alt = '블로그 포스트 미리보기';
export const size = {
  width: 1200,
  height: 630,
};

export const contentType = 'image/png';

async function getBlogPost(slug: string) {
  const response = await fetch(`https://api.example.com/posts/${slug}`);
  return response.json();
}

export default async function Image({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getBlogPost(params.slug);
  
  return new ImageResponse(
    (
      

{post.title}

{post.description}

{post.author} • {post.publishedAt}
), { ...size, } ); }

4. 뷰포트 메타데이터

// app/layout.tsx
import type { Metadata, Viewport } from 'next';

export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1,
  maximumScale: 1,
  userScalable: false,
  themeColor: [
    { media: '(prefers-color-scheme: light)', color: '#ffffff' },
    { media: '(prefers-color-scheme: dark)', color: '#000000' },
  ],
};

export const metadata: Metadata = {
  // 기존 메타데이터...
};

5. 페이지별 메타데이터 오버라이드

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';

// 이 페이지의 메타데이터로 루트 레이아웃의 메타데이터 오버라이드
export const metadata: Metadata = {
  title: '블로그 포스트',
  robots: {
    index: false,
    follow: false,
  },
};

export default function BlogPost() {
  return 
{/* 콘텐츠 */}
; }

6._alternates 메타데이터

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise {
  const post = await getBlogPost(params.slug);
  
  return {
    title: post.title,
    alternates: {
      canonical: `https://myblog.com/blog/${post.slug}`,
      languages: {
        'ko-KR': `https://myblog.com/ko/blog/${post.slug}`,
        'en-US': `https://myblog.com/en/blog/${post.slug}`,
      },
    },
    other: {
      'article:published_time': post.publishedAt,
      'article:author': post.author,
    },
  };
}

Lessons Learned

  1. generateMetadata 활용: 동적 콘텐츠에는 반드시 generateMetadata 사용
  2. Open Graph 이미지: 소셜 미디어 공유를 위한 OG 이미지 자동 생성
  3. SEO 최적화: canonical URL, hreflang 설정으로 검색 엔진 최적화
  4. 性能 Consideration: generateMetadata는 서버에서 실행되므로 비용 고려

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.