# Meta Tags

> Meta tags, Open Graph, Twitter Cards, and Next.js Metadata API patterns. This skill should be used when the user asks about "meta tags", "Open Graph", "og:image", "Twitter Card", "social sharing preview", "generateMetadata", "metadata API", "canonical URL", "OG image generation", "opengraph-image.tsx", or needs to configure page-level metadata for SEO and social sharing.

- Skill: `agents-store/meta-tags` (Agent Skill)
- Install (CLI): `npx skillmds@latest add agents-store/meta-tags`
- Raw SKILL.md: https://api.skillmd.com/api/skills/agents-store/meta-tags/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- Author: agents-store (https://skillmd.com/u/agents-store)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/agents-store/meta-tags

---


# Meta Tags & Social Sharing

Next.js App Router provides a first-class Metadata API that replaces third-party packages like `next-seo`. Export a `metadata` object or `generateMetadata` function from any `layout.tsx` or `page.tsx`.

## Static Metadata

Use for pages with fixed content (home, about, contact):

```tsx
// app/about/page.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'About Us',        // Becomes "About Us | Site Name" via template
  description: 'Learn about our team and mission. We build tools for developers.',
  openGraph: {
    title: 'About Us',
    description: 'Learn about our team and mission.',
    url: '/about',
    type: 'website',
    images: [{ url: '/og/about.png', width: 1200, height: 630, alt: 'About Us' }],
  },
  twitter: {
    card: 'summary_large_image',
  },
  alternates: {
    canonical: '/about',
  },
}
```

## Dynamic Metadata (generateMetadata)

Use for pages with dynamic content (blog posts, products, profiles):

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

interface Props {
  params: Promise<{ slug: string }>
}

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

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      url: `/blog/${slug}`,
      type: 'article',
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
      authors: [post.author.name],
      images: [{ url: post.image, width: 1200, height: 630, alt: post.title }],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.image],
    },
    alternates: {
      canonical: `/blog/${slug}`,
    },
  }
}
```

The fetch inside `generateMetadata` is automatically memoized with the page component — no duplicate requests.

## Title Template

Set in the root layout. Child pages only provide the page-specific part:

```tsx
// app/layout.tsx
export const metadata: Metadata = {
  title: {
    template: '%s | Acme',    // "About | Acme", "Blog | Acme"
    default: 'Acme',          // Used when child doesn't set title
  },
}

// app/about/page.tsx
export const metadata: Metadata = {
  title: 'About',  // Renders as "About | Acme"
}
```

Nested layouts can override the template for their section:
```tsx
// app/blog/layout.tsx
export const metadata: Metadata = {
  title: {
    template: '%s | Acme Blog',
    default: 'Acme Blog',
  },
}
```

## Open Graph Tags

### Required Properties

| Property | Description | Length |
|----------|-------------|-------|
| `og:title` | Page title for social sharing | 40-60 chars |
| `og:description` | Summary for social cards | 150-200 chars |
| `og:image` | Preview image URL (must be absolute) | - |
| `og:url` | Canonical page URL | - |
| `og:type` | Content type: `website`, `article`, `product` | - |
| `og:site_name` | Your site name | - |

### Image Specifications

| Platform | Size | Ratio | Format |
|----------|------|-------|--------|
| Universal | 1200 x 630 px | 1.91:1 | PNG or JPG |
| Twitter/X large | 1200 x 675 px | 16:9 | PNG or JPG |
| Minimum | 600 x 315 px | 1.91:1 | PNG or JPG |

Use **1200 x 630** for universal compatibility. Keep file size under 300KB (max 8MB). URL must be absolute HTTPS.

## Twitter Cards

```tsx
twitter: {
  card: 'summary_large_image',   // Large image preview
  // card: 'summary',            // Small square thumbnail
  title: 'Page Title',
  description: 'Page description',
  images: ['https://example.com/og.png'],
  creator: '@username',           // Optional: tweet author
  site: '@sitename',              // Optional: site account
}
```

If `twitter` fields are omitted, Next.js falls back to `openGraph` values automatically.

## Dynamic OG Image Generation

Generate OG images on-the-fly with `opengraph-image.tsx`. No external service needed:

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

export const alt = 'Blog post preview'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          width: '100%',
          height: '100%',
          background: '#0a0a0a',
          color: '#ffffff',
          padding: 60,
        }}
      >
        <div style={{ fontSize: 56, fontWeight: 700 }}>{post.title}</div>
        <div style={{ fontSize: 28, color: '#888', marginTop: 20 }}>
          {post.excerpt}
        </div>
      </div>
    ),
    { ...size }
  )
}
```

The image is auto-linked in the page's `<head>` — no manual `openGraph.images` needed when using file-based convention.

### Custom Fonts in OG Images

```tsx
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const font = await readFile(join(process.cwd(), 'assets/Inter-Bold.ttf'))

  return new ImageResponse(
    (<div style={{ fontSize: 48, fontFamily: 'Inter' }}>Title</div>),
    {
      ...size,
      fonts: [{ name: 'Inter', data: font, style: 'normal', weight: 700 }],
    }
  )
}
```

## Twitter Image Convention

Similarly, create `twitter-image.tsx` for a Twitter-specific card image:

```tsx
// app/twitter-image.tsx
import { ImageResponse } from 'next/og'

export const alt = 'Site Name'
export const size = { width: 1200, height: 675 }
export const contentType = 'image/png'

export default function Image() {
  return new ImageResponse(
    (<div style={{ /* ... */ }}>Site Name</div>),
    { ...size }
  )
}
```

## Canonical URLs

Set canonical on every page to prevent duplicate content:

```tsx
alternates: {
  canonical: '/blog/my-post',       // Relative to metadataBase
  // canonical: 'https://example.com/blog/my-post',  // Or absolute
}
```

For paginated content:
```tsx
alternates: {
  canonical: '/blog',
  // No rel=prev/next needed — Google ignores them since 2019
}
```

## Validation Checklist

After implementing metadata, verify:

1. **View source** — check `<title>`, `<meta name="description">`, OG tags in HTML
2. **Facebook Sharing Debugger** — test OG preview (developers.facebook.com/tools/debug/)
3. **Twitter Card Validator** — test card preview (cards-dev.twitter.com/validator)
4. **LinkedIn Post Inspector** — test LinkedIn preview
5. **Google Rich Results Test** — check structured data (separate from meta tags)

## Common Mistakes

| Mistake | Fix |
|---------|-----|
| Missing `metadataBase` | Set in root layout — OG images need absolute URLs |
| Exporting both `metadata` and `generateMetadata` | Use only one per file — both cause a build error |
| Relative `og:image` without `metadataBase` | Always set `metadataBase` or use absolute URLs |
| Description too long (>160 chars) | Keep 150-160 chars — Google truncates longer text |
| Title too long (>60 chars) | Keep 50-60 chars — search engines truncate |
| Same title/description on all pages | Each page needs unique metadata |

