Dan Tech Academy

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.

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?

ApproachLatency đầu tiênChi phí khi scaleFailure Mode
Vercel OG (Edge Runtime)3-5s cold start~$0.50/1K lần gọiServerless timeout với layout phức tạp
@vercel/og (Satori)Overhead mỗi request~$20/tháng computeTương tự — runtime = có thể lỗi
Runtime Puppeteer2-10s, không ổn địnhChi phí infrastructureBảo trì nặng
Build-time Puppeteer0ms (đã tạo sẵn)$0 runtimeStatic 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

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
📄 Full template code — scripts/og/og-template.ts
// 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>`
}

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/.

📄 Content loaders — blog, tags, static pages
// 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
  ]
}

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.

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.

📄 Full cache implementation
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)
}

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.

📄 Puppeteer generation code
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)

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.

📄 Hero screenshot implementation
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
  }
}

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).

📄 URL builder — lib/og.ts
// 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}`,
}

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

// 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:

📄 Upload script — scripts/og/upload-og.ts
// 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
  })
}

Pipeline được đóng gói

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

# 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
{
  "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ế

MetricGiá 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

ĐượcMất
Zero runtime cost mãi mãiBuild 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 cacheKí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.

Đọc blog thì vui. Đi theo lộ trình thì đến đích.

Kotlin Android Roadmap sắp xếp các bài Android Mastery, OOP và Design Patterns thành 5 cấp độ - từ dòng Kotlin đầu tiên đến app lên store.

Kèm bài viết mới mỗi tuần. Huỷ bất cứ lúc nào.