# Tự động tạo OG Image lúc Build với Next.js và Puppeteer

> Hướng dẫn hoàn chỉnh tạo OG image lúc build time bằng Puppeteer. Zero runtime cost, incremental cache bằng hash, và delivery qua CDN — kiến trúc đằng sau 250+ ảnh social preview được tạo tự động.

- URL: https://dantech.academy/blog/nextjs/build-time-og-image-generation-nextjs-puppeteer
- Ngày đăng: 2026-04-05
- Thời gian đọc: 12 phút
- Tags: nextjs, puppeteer, performance, seo

## Bài toán

Mỗi trang trên website cần một OG image. Chia sẻ blog post lên Twitter/LinkedIn mà không có ảnh preview → card trống trơn, nhìn rất nghiệp dư. Nhưng với 250+ trang, không thể tạo ảnh thủ công cho từng cái.

Vậy có những lựa chọn nào?

| Approach                 | Latency đầu tiên     | Chi phí khi scale      | Failure Mode                           |
| ------------------------ | -------------------- | ---------------------- | -------------------------------------- |
| Vercel OG (Edge Runtime) | 3-5s cold start      | ~$0.50/1K lần gọi      | Serverless timeout với layout phức tạp |
| `@vercel/og` (Satori)    | Overhead mỗi request | ~$20/tháng compute     | Tương tự — runtime = có thể lỗi        |
| Runtime Puppeteer        | 2-10s, không ổn định | Chi phí infrastructure | Bảo trì nặng                           |
| **Build-time Puppeteer** | **0ms (đã tạo sẵn)** | **$0 runtime**         | **Static file = không thể crash**      |

Với blog/docs site — nơi content đã biết trước lúc deploy — build-time generation là lựa chọn rõ ràng nhất. Trả chi phí một lần lúc build, rồi serve static PNG từ CDN mãi mãi.

## Tổng quan Kiến trúc

Pipeline gồm 4 lớp:

![OG Image Pipeline Architecture](https://dantech.academy/static/images/og-pipeline-architecture.svg)

**Hai chiến lược tạo ảnh:**

1. **Template-based** — Blog posts, tags, courses, static pages. Render HTML template, Puppeteer chụp ảnh. ~200ms mỗi ảnh.

2. **Hero screenshots** — Trang chủ, landing pages, course pages. Puppeteer truy cập dev server thật và chụp UI thực tế. ~5-8s mỗi ảnh (chờ hydration).

## Bước 1: HTML Template

Template render trang HTML 1200×630 để Puppeteer chụp. Không dùng ảnh, không dùng emoji — chỉ HTML/CSS thuần với Google Fonts.

Những quyết định thiết kế quan trọng:

- **Dynamic font sizing** — tiêu đề tự động scale theo số ký tự
- **Tag-colored accents** — primary tag quyết định màu gradient
- **Google Fonts load qua CDN** — Puppeteer chờ `document.fonts.ready`
- **Không dùng ảnh hay emoji** — CSS thuần, không bao giờ lỗi render

<details>
<summary>📄 Full template code — <code>scripts/og/og-template.ts</code></summary>

```typescript
// scripts/og/og-template.ts

export interface OgTemplateData {
  title: string
  tags: string[]
  readTime: string
  subtitle: string
}

function escapeHtml(str: string): string {
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

function getTitleFontSize(title: string): number {
  const len = title.length
  if (len > 80) return 38
  if (len > 60) return 44
  if (len > 45) return 52
  if (len > 30) return 60
  return 68
}

function getTagColor(tag: string): string {
  const map: Record<string, string> = {
    android: '#3DDC84',
    kotlin: '#7F52FF',
    react: '#61DAFB',
    nextjs: '#FFFFFF',
    typescript: '#3178C6',
    python: '#3776AB',
    ai: '#10B981',
    // Thêm tag → color mappings của bạn
  }
  return map[tag.toLowerCase()] || '#6366F1'
}

export function renderOgTemplate(data: OgTemplateData): string {
  const title = escapeHtml(data.title)
  const titleFontSize = getTitleFontSize(data.title)
  const primaryColor = data.tags.length > 0 ? getTagColor(data.tags[0]) : '#6366F1'

  // Tag pills với accent colors
  const tagPills = data.tags
    .slice(0, 3)
    .map((tag) => {
      const color = getTagColor(tag)
      return `<span style="
      padding: 7px 16px;
      background: ${color}12;
      border: 1px solid ${color}40;
      border-radius: 8px;
      font-size: 14px; font-weight: 700;
      color: ${color};
      text-transform: uppercase;
    ">${escapeHtml(tag)}</span>`
    })
    .join('')

  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;700;900&display=swap" rel="stylesheet">
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      width: 1200px; height: 630px; overflow: hidden;
      font-family: 'Be Vietnam Pro', sans-serif;
      background: #08080C; color: #F2F2F2;
      position: relative;
    }
    /* Accent stripe - màu theo primary tag */
    .accent-stripe {
      position: absolute; left: 0; top: 0;
      width: 5px; height: 100%;
      background: linear-gradient(180deg, ${primaryColor}, transparent);
    }
    /* Gradient mesh tạo chiều sâu */
    .mesh {
      position: absolute; border-radius: 50%;
      filter: blur(120px);
      width: 800px; height: 800px;
      top: -400px; right: -300px;
      background: radial-gradient(circle, ${primaryColor}30, transparent 70%);
    }
    .container {
      position: relative; z-index: 1;
      display: flex; flex-direction: column; justify-content: center;
      height: 100%; padding: 52px 72px 88px;
      gap: 20px;
    }
    .tags-row { display: flex; gap: 8px; }
    .title {
      font-size: ${titleFontSize}px; font-weight: 900;
      line-height: 1.08; max-width: 1000px;
      letter-spacing: -0.02em;
    }
    .footer {
      position: absolute; bottom: 0; left: 0; right: 0;
      height: 56px; display: flex; align-items: center;
      justify-content: space-between; padding: 0 72px;
      border-top: 1px solid rgba(255,255,255,0.06);
    }
    .brand { font-size: 14px; font-weight: 700; color: #94A3B8; }
    .domain { font-size: 14px; color: #475569; }
  </style>
</head>
<body>
  <div class="accent-stripe"></div>
  <div class="mesh"></div>
  <div class="container">
    <div class="tags-row">${tagPills}</div>
    <h1 class="title">${title}</h1>
  </div>
  <div class="footer">
    <span class="brand">YOUR BRAND</span>
    <span class="domain">yourdomain.com</span>
  </div>
</body>
</html>`
}
```

</details>

## Bước 2: Thu thập Content

Generator cần biết trang nào tồn tại. Đọc từ JSON output của Contentlayer và các data sources khác.

Chú ý **quy ước đặt tên key**: `blog__nextjs__my-post-slug`, `tag__react`, `page__blog`. Double underscore thay cho path separator. Tạo cấu trúc filename phẳng, dễ dự đoán trong `public/static/og/`.

<details>
<summary>📄 Content loaders — blog, tags, static pages</summary>

```typescript
// scripts/og/generate-og.ts (phần load content)

interface OgEntry {
  key: string // Cache key và filename (không có .png)
  data: OgTemplateData // Input cho template
}

/** Load blog posts tiếng Anh đã publish từ Contentlayer */
async function loadBlogEntries(): Promise<OgEntry[]> {
  const dir = join(ROOT, '.contentlayer/generated/Blog')
  if (!existsSync(dir)) return []

  const files = await readdir(dir)
  const entries: OgEntry[] = []

  for (const file of files.filter((f) => f.endsWith('.mdx.json'))) {
    const post = JSON.parse(await readFile(join(dir, file), 'utf-8'))
    if (post.language === 'en' && !post.draft) {
      entries.push({
        key: `blog__${post.slug.replace(/\//g, '__')}`,
        data: {
          title: post.title,
          tags: post.tags || [],
          readTime: post.readingTime?.text || '',
          subtitle: '',
        },
      })
    }
  }
  return entries
}

/** Load tag pages từ tag-data.json */
async function loadTagEntries(): Promise<OgEntry[]> {
  const tagFile = join(ROOT, 'app/tag-data.json')
  if (!existsSync(tagFile)) return []
  const tags = JSON.parse(readFileSync(tagFile, 'utf-8'))

  return Object.entries(tags).map(([tag, count]) => ({
    key: `tag__${tag}`,
    data: {
      title: `#${tag}`,
      tags: [],
      readTime: '',
      subtitle: `${count} bài viết`,
    },
  }))
}

/** Static pages với OG content cố định */
function getStaticPageEntries(): OgEntry[] {
  return [
    { key: 'page__blog', data: { title: 'Blog', tags: [], readTime: '', subtitle: 'Bài viết Tech' } },
    { key: 'page__tags', data: { title: 'Tags', tags: [], readTime: '', subtitle: 'Duyệt theo chủ đề' } },
    // ... thêm static pages
  ]
}
```

</details>

## Bước 3: Incremental Cache bằng Hash

Đây là phần quan trọng nhất. Không có cache, mỗi build tạo lại toàn bộ 250+ ảnh (~2 phút). Có cache, chỉ content thay đổi mới tạo lại.

Ý tưởng đơn giản: hash các inputs của template (title, tags, readTime) → so sánh với hash trước đó → skip nếu giống nhau.

```typescript
function computeHash(data: OgTemplateData): string {
  const content = `${data.title}|${data.subtitle}|${data.tags.join(',')}|${data.readTime}`
  return createHash('md5').update(content).digest('hex')
}

// Incremental: chỉ regenerate content đã thay đổi
if (cache[key] === hash && existsSync(outputPath)) {
  continue // Cache hit: ~5ms thay vì ~200ms
}
```

**Kết quả:** Sửa một blog post → chỉ tạo lại một ảnh. Build incremental: +30s thay vì +2 phút.

<details>
<summary>📄 Full cache implementation</summary>

```typescript
import { createHash } from 'node:crypto'

const CACHE_FILE = join(ROOT, '.og-cache.json')

type CacheMap = Record<string, string> // key → MD5 hash

function computeHash(data: OgTemplateData): string {
  const content = `${data.title}|${data.subtitle}|${data.tags.join(',')}|${data.readTime}`
  return createHash('md5').update(content).digest('hex')
}

function loadCache(): CacheMap {
  if (!existsSync(CACHE_FILE)) return {}
  try {
    return JSON.parse(readFileSync(CACHE_FILE, 'utf-8'))
  } catch {
    return {}
  }
}

// Vòng lặp so sánh cache
const cache = loadCache()
const newCache: CacheMap = {}
const toGenerate: OgEntry[] = []

for (const entry of allEntries) {
  const hash = computeHash(entry.data)
  newCache[entry.key] = hash
  const outputPath = join(OUTPUT_DIR, `${entry.key}.png`)

  // Skip nếu hash khớp VÀ file tồn tại trên disk
  if (cache[entry.key] === hash && existsSync(outputPath)) {
    continue // Cache hit: ~5ms thay vì ~200ms
  }
  toGenerate.push(entry)
}
```

</details>

File `.og-cache.json` nên nằm trong `.gitignore` — nó là build artifact local.

## Bước 4: Vòng lặp Puppeteer

Core loop: render HTML template → load vào Puppeteer → chờ fonts → chụp ảnh.

**Chi tiết quan trọng:** Phải chờ `document.fonts.ready`. Nếu không, Puppeteer chụp trước khi Google Fonts load xong → hiện font hệ thống fallback, nhìn rất xấu. Timeout 5 giây là safety net cho CI environments khi font CDN phản hồi chậm.

<details>
<summary>📄 Puppeteer generation code</summary>

```typescript
const browser = await puppeteer.launch({
  headless: true,
  args: ['--no-sandbox', '--disable-setuid-sandbox'],
})

const page = await browser.newPage()
await page.setViewport({ width: 1200, height: 630 })

for (const entry of toGenerate) {
  // 1. Render template HTML
  const html = renderOgTemplate(entry.data)

  // 2. Load vào Puppeteer
  await page.setContent(html, { waitUntil: 'domcontentloaded' })

  // 3. Chờ Google Fonts load (có timeout fallback)
  await Promise.race([page.evaluate(() => document.fonts.ready), new Promise((resolve) => setTimeout(resolve, 5000))])

  // 4. Chụp ảnh
  const outputPath = join(OUTPUT_DIR, `${entry.key}.png`)
  await page.screenshot({ path: outputPath, type: 'png' })
}

await browser.close()
saveCache(newCache)
```

</details>

## Bước 5: Hero Screenshots (Nâng cao, Tuỳ chọn)

Với những trang có hero visual phong phú (landing pages, course pages), template không thể capture được UI thực. Thay vào đó, chụp từ dev server đang chạy.

Cần `yarn dev` đang chạy ở `localhost:3000`. Nếu dev server không chạy, hero screenshots sẽ bị skip — chỉ tạo ảnh template-based.

<details>
<summary>📄 Hero screenshot implementation</summary>

```typescript
const HERO_PAGES = [
  { key: 'page__home', url: '/', delay: 2000 },
  { key: 'page__kotlin-accelerator', url: '/kotlin-accelerator', delay: 5000 },
  {
    key: 'course__kotlin-accelerator',
    url: '/courses/kotlin-accelerator',
    delay: 8000,
    waitForText: 'Your path to', // Chờ content cụ thể
  },
]

async function generateHeroImages(page: Page): Promise<number> {
  // Force dark mode
  await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }])

  for (const hero of HERO_PAGES) {
    await page.goto(`http://localhost:3000${hero.url}`, {
      waitUntil: 'load',
      timeout: 30000,
    })

    // Chờ hydration
    await new Promise((resolve) => setTimeout(resolve, hero.delay))

    // Ẩn dev overlays, header, footer
    await page.evaluate(() => {
      const style = document.createElement('style')
      style.textContent = `
        nextjs-portal, header, footer { display: none !important; }
        * { animation-duration: 0.001s !important; }
      `
      document.head.appendChild(style)
    })

    await page.screenshot({
      path: join(OUTPUT_DIR, `${hero.key}.png`),
      type: 'png',
    })
  }
}

// Kiểm tra localhost dev server có đang chạy không
async function isLocalhostAvailable(): Promise<boolean> {
  try {
    const controller = new AbortController()
    setTimeout(() => controller.abort(), 3000)
    await fetch('http://localhost:3000', { signal: controller.signal })
    return true
  } catch {
    return false
  }
}
```

</details>

## Bước 6: Serving — URL Builder

Page metadata cần resolve đúng URL ảnh OG. Dev dùng từ `public/`, production dùng từ CDN (Supabase Storage).

<details>
<summary>📄 URL builder — <code>lib/og.ts</code></summary>

```typescript
// lib/og.ts

import siteMetadata from '@/data/siteMetadata'

export function buildStaticOgImageUrl(key: string): string {
  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
  if (supabaseUrl && process.env.NODE_ENV === 'production') {
    // Production: Supabase Storage CDN
    return `${supabaseUrl}/storage/v1/object/public/og/${key}.png`
  }
  // Development: file local
  return `${siteMetadata.siteUrl}/static/og/${key}.png`
}

// Key helpers khớp với naming convention của generator
export const ogKey = {
  blog: (slug: string) => `blog__${slug.replace(/\//g, '__')}`,
  tag: (tag: string) => `tag__${tag}`,
  course: (slug: string) => `course__${slug.replace(/\//g, '__')}`,
  page: (name: string) => `page__${name}`,
}
```

</details>

Sử dụng trong Next.js page metadata:

```typescript
// app/blog/[...slug]/page.tsx
import { buildStaticOgImageUrl, ogKey } from '@/lib/og'

export async function generateMetadata({ params }) {
  const slug = params.slug.join('/')
  return {
    openGraph: {
      images: [{ url: buildStaticOgImageUrl(ogKey.blog(slug)) }],
    },
    twitter: {
      card: 'summary_large_image',
      images: [buildStaticOgImageUrl(ogKey.blog(slug))],
    },
  }
}
```

## Bước 7: Upload lên CDN

Sau khi tạo xong, upload lên Supabase Storage để CDN phân phối toàn cầu:

<details>
<summary>📄 Upload script — <code>scripts/og/upload-og.ts</code></summary>

```typescript
// scripts/og/upload-og.ts

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)

const files = readdirSync(OG_DIR).filter((f) => f.endsWith('.png'))

for (const file of files) {
  const fileBuffer = readFileSync(join(OG_DIR, file))

  await supabase.storage.from('og').upload(file, fileBuffer, {
    contentType: 'image/png',
    upsert: true, // Override lần upload trước
  })
}
```

</details>

## Pipeline được đóng gói

Hai npm scripts, chạy tuần tự:

```bash
# Tạo tất cả ảnh template + hero screenshots (nếu dev server đang chạy)
yarn build:og

# Upload lên Supabase Storage CDN
yarn upload:og
```

```json
{
  "scripts": {
    "build:og": "tsx scripts/og/generate-og.ts",
    "upload:og": "tsx scripts/og/upload-og.ts"
  }
}
```

Đừng quên `.gitignore`:

```
public/static/og/
.og-cache.json
```

## Cấu trúc File

```
scripts/og/
├── generate-og.ts    # Generator chính + cache logic
├── og-template.ts    # HTML template renderer
└── upload-og.ts      # Uploader lên Supabase Storage

lib/
└── og.ts             # URL builder + key helpers

public/static/og/     # PNGs được tạo (gitignored)
.og-cache.json        # Hash cache (gitignored)
```

## Hiệu năng thực tế

| Metric                         | Giá trị                       |
| ------------------------------ | ----------------------------- |
| Tạo ảnh template               | ~200ms mỗi ảnh                |
| Hero screenshot                | ~5-8s mỗi ảnh (chờ hydration) |
| Full build (250 ảnh)           | ~2 phút                       |
| Incremental build (1 thay đổi) | ~30 giây                      |
| Cache skip                     | ~5ms mỗi entry                |
| CDN delivery                   | ~100ms toàn cầu               |
| Chi phí runtime                | $0                            |

## Trade-offs

| Được                                | Mất                                  |
| ----------------------------------- | ------------------------------------ |
| Zero runtime cost mãi mãi           | Build ban đầu chậm hơn               |
| CDN delivery tức thì (~100ms)       | Không tạo ảnh dynamic per-user       |
| 100% reliability (static files)     | Cần Node.js trong CI                 |
| Incremental builds qua cache        | Kích thước cố định 1200×630          |
| Template thay đổi = rebuild toàn bộ | Template chỉ đổi khi design thay đổi |

## Khi nào dùng cách này vs. Runtime

**Dùng build-time nếu:**

- Content đã biết trước lúc deploy (blog, docs, courses)
- Ảnh không cần personalization theo user
- Muốn zero chi phí runtime
- Reliability > flexibility

**Dùng runtime nếu:**

- Ảnh cần dữ liệu user-specific (profile cards, certificates)
- Content thay đổi real-time giữa các lần deploy
- Chấp nhận serverless cold-start latency
- Cần nhiều kích thước ảnh dynamic

---

_Infrastructure tốt nhất là loại deploy xong rồi quên. Build-time OG generation với Puppeteer chính là vậy — 250 ảnh được tạo, cache, upload CDN, và serve ở tốc độ static file._
