C
Next.js/SEO/Lesson 14

Metadata API — Dynamic SEO + OG Cards with generateMetadata

30 min·theory
This chapter
1/2
TypeScript

Metadata API — Dynamic SEO + OG Cards with generateMetadata

💡 Why Learn This? — The Part That Makes a Difference for SEO and Social Sharing

🎯 The title and description in search results, the image and text on KakaoTalk and Twitter share cards — all of these are determined by the meta tags in ``.
💼 The `` component from Pages Router has been standardized into the `metadata` export in App Router.
For dynamic pages (such as blog posts), `generateMetadata({ params })` automatically generates different metadata for each page.
🔗 File conventions — just place `app/icon.tsx`, `app/opengraph-image.tsx`, and `app/twitter-image.tsx` and they are automatically wired to the metadata.
📈 Without knowing this, every page's share card looks identical — giving off an 'amateur' impression.
🏢 실무에서는
100 blog posts — each post needs its own title, summary, and featured image to appear on share cards. One `generateMetadata` function handles all 100 pages automatically. Search engines also pick up exact post titles, which directly affects SEO ranking.

metadata export · generateMetadata · File Conventions

1. Static metadata export

ts
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'CodeMaster — Free Coding Learning',
  description: '170 learning contents, AI code review, mock interviews',
  keywords: ['coding', 'learning', 'AI', 'interview'],
  openGraph: {
    title: 'CodeMaster',
    description: 'Free coding learning platform',
    images: ['/og-image.png'],
  },
  twitter: {
    card: 'summary_large_image',
    images: ['/og-image.png'],
  },
};

Export from the top of each page or layout. Automatically injected into <head>.

2. generateMetadata — For Dynamic Pages

ts
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await fetch(`https://api/posts/${params.slug}`).then(r => r.json());

  return {
    title: `${post.title} | CodeMaster Blog`,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage, width: 1200, height: 630 }],
    },
  };
}

If the same fetch is called in the page body, it is automatically deduped — no extra network cost.

3. title.template in layout.tsx — Automatic Suffix on Every Page

ts
// app/layout.tsx — root
export const metadata: Metadata = {
  title: {
    default: 'CodeMaster',
    template: '%s | CodeMaster',
  },
};

// app/posts/[slug]/page.tsx
export const metadata: Metadata = {
  title: 'Mastering TypeScript',  // Actual render: 'Mastering TypeScript | CodeMaster'
};

4. File Conventions — Automatic Metadata Linking

code
app/
├── icon.ico          ← Automatically becomes <link rel="icon">
├── apple-icon.png    ← Apple Touch Icon
├── opengraph-image.tsx  ← Dynamic OG image (Edge Runtime)
├── twitter-image.png    ← Twitter card image
└── robots.txt        ← Auto-served at /robots.txt

opengraph-image.tsx can dynamically generate PNG via ImageResponse. codemaster40 already uses [src/app/opengraph-image.tsx](src/app/opengraph-image.tsx) for dynamic generation.

5. Commonly Used Fields

ts
export const metadata: Metadata = {
  // Search Engine
  title: '...',
  description: '...',
  keywords: ['...'],
  authors: [{ name: 'Hong Gil-dong', url: 'https://...' }],
  robots: { index: true, follow: true },

  // Standard Links
  alternates: {
    canonical: 'https://example.com/canonical-url',
    languages: { 'ko-KR': '/ko', 'en-US': '/en' },
  },

  // Open Graph (Facebook · KakaoTalk)
  openGraph: {
    type: 'article',
    url: 'https://...',
    title: '...',
    description: '...',
    images: [{ url: '...', width: 1200, height: 630 }],
    siteName: 'CodeMaster',
  },

  // Twitter
  twitter: {
    card: 'summary_large_image',
    title: '...',
    description: '...',
    images: ['...'],
    creator: '@handle',
  },
};
💻 🅰️ Pages Router — next/head Component
// ❌ Pages Router — inside next/head component

// 📁 pages/blog/[slug].tsx
import Head from 'next/head';
import type { GetServerSideProps } from 'next';

interface Post { slug: string; title: string; excerpt: string; coverImage: string; }

export const getServerSideProps: GetServerSideProps<{ post: Post }> = async (ctx) => {
  const post = await fetch(`https://api/posts/${ctx.params!.slug}`).then(r => r.json());
  return { props: { post } };
};

export default function BlogPost({ post }: { post: Post }) {
  return (
    <>
      <Head>
        <title>{post.title} | CodeMaster Blog</title>
        <meta name="description" content={post.excerpt} />
        <meta property="og:title" content={post.title} />
        <meta property="og:description" content={post.excerpt} />
        <meta property="og:image" content={post.coverImage} />
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:image" content={post.coverImage} />
      </Head>
      <article>{/* ... */}</article>
    </>
  );
}

// Disadvantages:
// - Direct <Head> writing on every page — boilerplate
// - OG and Twitter duplicate the same information (double writing)
// - Inside component tree, parent/child priority is confusing
💻 🅱️ App Router — metadata export + generateMetadata
// ✅ App Router — metadata + generateMetadata

import type { Metadata } from 'next';
import { notFound } from 'next/navigation';

interface Post { slug: string; title: string; excerpt: string; coverImage: string; }

// 📁 app/layout.tsx — Root (inherited by all pages)
export const metadata: Metadata = {
  title: {
    default: 'Codemaster — Free Coding Education',
    template: '%s | Codemaster',  // If child page is 'X', then 'X | Codemaster'
  },
  description: '170 learning contents, AI code review, mock interviews',
  metadataBase: new URL('https://codemaster40.com'),
  openGraph: {
    siteName: 'Codemaster',
    locale: 'ko_KR',
  },
  twitter: {
    card: 'summary_large_image',
  },
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return <html lang="ko"><body>{children}</body></html>;
}

// 📁 app/blog/[slug]/page.tsx — Dynamic Meta
export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post: Post | null = await fetch(`https://api/posts/${params.slug}`).then(r => r.json());
  if (!post) return { title: 'Post not found' };

  return {
    title: post.title,                   // Automatically 'Mastering TypeScript | Codemaster'
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage, width: 1200, height: 630 }],
      type: 'article',
      publishedTime: '2026-05-26T00:00:00.000Z',
    },
    twitter: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
    alternates: {
      canonical: `https://codemaster40.com/blog/${post.slug}`,
    },
  };
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  // Same fetch — Next.js automatically dedupes (called only once with generateMetadata)
  const post = await fetch(`https://api/posts/${params.slug}`).then(r => r.json());
  if (!post) notFound();
  return <article>{post.title}</article>;
}

// 📁 app/opengraph-image.tsx — Dynamic OG Image (Edge Runtime)
import { ImageResponse } from 'next/og';

export const runtime = 'edge';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default function OGImage() {
  return new ImageResponse(
    <div style={{ background: '#000', color: '#fff', fontSize: 80 }}>
      Codemaster
    </div>,
    { ...size },
  );
}

💡 💡 Top 5 Metadata Tips

1. Set metadataBase in the root layout

ts
metadataBase: new URL('https://codemaster40.com')

Relative paths for OG images and similar assets are automatically converted to absolute URLs.

2. Consistent branding with title.template
Set template: '%s | CodeMaster' at the root — child pages just write 'TypeScript Basics'.

3. fetch calls in generateMetadata are automatically deduped with page fetches
Same URL is called only once. No extra network cost.

4. Use all 4 file conventions — zero lines of code
Just place app/icon.ico, apple-icon.png, opengraph-image.png, and twitter-image.png — metadata links up automatically.

5. OG images should be 1200×630; Twitter card should be summary_large_image
Standard sizes. Validate at: https://www.opengraph.xyz/, https://cards-dev.twitter.com/validator

⚡ Try It Yourself — Simulate generateMetadata Output

Simulate how metadata from each page is merged to produce the final head.
✏️ JS 코드
📟 Console output
▶ Press the Run button
⚠️ Runs in a browser sandbox — only console.log() is supported; alert/fetch are not.

Review Quiz

If the root layout has `title.template = '%s | CodeMaster'` in App Router, and a child page sets `title: 'Promise<T>'`, what will the rendered `<title>` be?
💡 The `%s` in `title.template` is replaced with the child's title. So 'Promise<T>' + ' | CodeMaster' becomes **'Promise<T> | CodeMaster'**. If you want a child page to be unaffected by the template, specify `title: { absolute: 'Plain Title' }` explicitly. This pattern automatically appends a consistent brand suffix, so every child page only needs to specify the core keyword.
Metadata API — generateMetadata + OG/Twitter - Next.js