Next.js + Cloudflare Workers 上的 OG Image 完全指南:从零到生产

OG Image(Open Graph Image)是文章被分享到 Twitter / Facebook / Telegram / 微信时显示的那张大图。它不参与 SEO 排名,但几乎是社交流量唯一的视觉钩子——同样一篇文章,配一张 1200×630 的精心封面,和直接显示一行灰色 favicon,点击率能差出几倍。

在这篇博客里,我从零搭起这套机制的过程(Next.js 16 + OpenNext + Cloudflare Workers + WordPress headless),连同三次返工,总结成文。它包含两部分:

  • 教程:怎么从零做一套静态兜底 + 每篇文章动态生成的 OG image 体系;

  • 最佳实践 / 避坑:在 Cloudflare Workers 这个特定运行时下,我们可能踩到哪些坑。

如果你只是普通 Next.js 部署到某个服务器上,前半部分对你也适用;如果你想用 Cloudflare Worker,后半部分大概率能帮你省掉半天时间。

1. OG Image 的两层结构

任何一个生产级网站的 OG 体系都应该是两层

  1. 站点级静态图片兜底:准备一张 PNG 图片,放在 public/og-image.png,写在根 layout.tsxmetadata.openGraph.images 里。这样所有未单独配置的页面(首页、列表页、关于页……)都会拿它当封面,永远不会失败

  2. 文章级动态生成:具体文章页,独立生成 OG Image,体现标题 + 封面,由一个 API route 在请求时实时渲染。

这两层不是「二选一」,而是「先有第一层,再叠第二层」。第二层挂掉时第一层兜底,整个站点的社交分享体验不会因为某个动态路由报错而集体翻车。

1.1 站点级静态兜底

src/app/layout.tsx

export const metadata: Metadata = {
  metadataBase: new URL('https://meathill.com'),
  openGraph: {
    type: 'website',
    locale: 'zh_CN',
    url: 'https://meathill.com',
    siteName: '山维空间',
    title: '山维空间 - Meathill 的技术博客',
    description: '...',
    images: [
      {
        url: '/og-image.png',
        width: 1200,
        height: 630,
        alt: '山维空间 - Meathill 的技术博客',
      },
    ],
  },
  twitter: {
    card: 'summary_large_image',
    creator: '@meathill1',
    title: '山维空间 - Meathill 的技术博客',
    description: '...',
    images: ['/og-image.png'],
  },
};

几个值得展开的点:

  • metadataBase 必填。Next.js 会用它把所有相对 URL(比如 /og-image.png)展开成绝对 URL,因为社交平台抓取时不带域名。漏写就会被各家爬虫沉默地忽略。推荐用环境变量配置,可以参考:Cloudflare Worker + Next.js 使用环境变量最佳实践(2026终极版)

  • twitter.card: 'summary_large_image' 是大图卡片,对应 1200×630 的视觉规格。如果不写,Twitter 会退化成只有缩略图的小卡片。

  • images 是数组,可以写多张。第一张是默认。建议单独提供 width / height / alt,少数老旧爬虫会要求这些字段。

  • 静态图就放 PNG,不要放 WebP。下面会反复提到「Satori 不支持 WebP」,但更早的问题是:Twitter / 微信的爬虫至今对 WebP 的支持都不一致。老老实实给一张 PNG。

1.2 子页面如何继承与覆盖

Next.js 的 Metadata API 是深合并:每个页面的 generateMetadata() 返回的字段会和上层 layout 的合并。但 openGraph.images 这种数组字段是整体覆盖,不是追加。

我项目里所有列表页(/posts/category/.../tag/.../about/app 等)都只覆盖 titledescription不写 images——这样它们会自动继承根 layout 的 og-image.png

文章详情页(src/app/[locale]/(public)/posts/[...slug]/page.tsx)则覆盖 images 指向动态路由:

const ogImageUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/api/og/post?slug=${cleanSlug}`;
const images = [ogImageUrl];

return {
  title,
  description,
  alternates: { canonical: canonicalUrl, languages: { zh: zhUrl, en: enUrl } },
  openGraph: {
    title, description,
    type: 'article',
    publishedTime: post.date,
    images,
  },
  twitter: {
    card: 'summary_large_image',
    title, description,
    images,
  },
};

注意:

  1. 必须用绝对 URL。爬虫不带 cookie、不在 referer 上下文里,相对 URL 会被解析成爬虫所在域名。process.env.NEXT_PUBLIC_SITE_URL 是项目里统一的站点 URL 来源(虽然在 wrangler.jsoncvars 里记录,但它们应配置在构建时的环境变量中,以便 Next.js 在构建时将其注入到代码中)。

  2. type: 'article' + publishedTime 是 Open Graph 的 article 子规范,主流爬虫会把它当成文章在 timeline 上的发布时间。

  3. twitter.images 必须单独写。Twitter Card 协议自有一套 metadata,虽然 summary_large_image 在 og:image 缺失时会回落,但显式给一份更稳妥。

2. 用 next/og 动态生成文章封面

next/og 是 Next.js 内置的图片渲染机制,底层是 Satori(JSX → SVG)+ resvg(SVG → PNG)。它的核心 API 是 ImageResponse,传一个 React element 给它,返回一个真正的 PNG HTTP 响应。

2.1 路由文件结构

我把文章 OG 图放在 src/app/api/og/post/route.tsx(注意是 .tsx,因为里面要写 JSX)。

import { ImageResponse } from 'next/og';
import { getPost, stripHtml } from '@/lib/wordpress';
import { NextRequest } from 'next/server';

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

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const slug = searchParams.get('slug');

  if (!slug) {
    return new ImageResponse(
      <div style={fallbackStyle}>Missing Slug</div>,
      { ...size },
    );
  }

  const cleanSlug = slug.endsWith('.html') ? slug.replace('.html', '') : slug;
  const post = await getPost(cleanSlug);
  if (!post) {
    return new ImageResponse(
      <div style={fallbackStyle}>Article Not Found</div>,
      { ...size },
    );
  }

  // ... 渲染卡片,见下文
}

sizecontentType 这两个 export 是 Next.js 的文件级 metadata 约定,会被注入到响应头,部分场景(比如 opengraph-image.tsx 文件约定)会自动替你写到 HTML 里。

OG 卡片就按照我们平时做网页的方式写 HTML 就行,Satori 会帮我们渲染 SVG:

<div style={{
  width: '100%', height: '100%',
  display: 'flex', flexDirection: 'column',
  alignItems: 'center', justifyContent: 'center',
  position: 'relative',
  background: 'black',                         // 失败兜底色
}}>
  {/* 第 1 层:背景图(封面或渐变兜底) */}
  {featuredMedia ? (
    <img src={featuredMedia} alt={title} style={{
      position: 'absolute', width: '100%', height: '100%',
      objectFit: 'cover', objectPosition: 'center',
    }} />
  ) : (
    <div style={{
      position: 'absolute', width: '100%', height: '100%',
      background: 'linear-gradient(to bottom right, #4F46E5, #9333EA)',
    }} />
  )}

  {/* 第 2 层:半透明黑色蒙版,让任何封面下白字都可读 */}
  <div style={{
    position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
    background: 'rgba(0, 0, 0, 0.6)',
  }} />

  {/* 第 3 层:标题 */}
  <div style={{
    display: 'flex', flexDirection: 'column',
    alignItems: 'center', justifyContent: 'center',
    zIndex: 10, padding: '40px', textAlign: 'center', maxWidth: '80%',
  }}>
    <div style={{
      fontSize: 64, fontWeight: 900, lineHeight: 1.2,
      textShadow: '0 2px 10px rgba(0,0,0,0.5)',
    }}>{title}</div>
  </div>

  {/* 第 4 层:右下角 Brand */}
  <div style={{
    position: 'absolute', bottom: 40, right: 50,
    fontSize: 32, fontWeight: 600,
    color: 'rgba(255, 255, 255, 0.8)',
    zIndex: 10, display: 'flex', alignItems: 'center',
  }}>@meathill1</div>
</div>

这里也有几个暗坑:

  • 每个有子元素的容器都要显式写 display: 'flex'。Satori 默认会把元素当成 inline,flex 子项不会换行。漏写会 throw expected flex container

  • 不能用 gap,Satori 不实现这个属性。要靠 margin 或多套一层 wrapper。

  • 不能用 CSS variable 和媒体查询。Satori 是一次性渲染到 1200×630 固定画布,没有视口、没有运行时主题切换。

  • 字体:默认走系统字体兜底,中文经常糊。生产环境建议在 ImageResponse 第二个参数里 fonts: [{ name, data, weight }] 注入自定义字体。我目前没注入,因为标题信息密度低且用了 text-shadow 容错,但工业级项目应该注入。

  • text-shadow Satori 是支持的,标题加一点黑色阴影能在浅色封面上保持可读。

3. 封面图导致的三次返工

我希望在博文前面加上图片,我觉得这样可以让博客看起来更舒服。为了能够节省带宽,我会把图片先处理成 webp 再上传,没想到竟然就踩了几个月的坑。

3.1 v1:用字符串 replace 拼 cdn-cgi

最初的实现:

const featuredMedia =
  rawFeaturedMedia && /\.webp(\?|$)/i.test(rawFeaturedMedia)
    ? rawFeaturedMedia.replace(
        /(https?:\/\/[^/]+)(\/.*)/,
        '$1/cdn-cgi/image/format=png,width=1200$2',
      )
    : rawFeaturedMedia;

因为 Satori 不支持 WebP,所以我们需要把图片转换成 PNG,再交给 Satori 渲染。封面图来自 WordPress(https://blog.meathill.com/wp-content/uploads/.../xxx.webp),我希望把 cdn-cgi 段插到原 host 和 path 之间,让 Cloudflare Image Resizing 在边缘把 WebP 转成 PNG。

问题replace 是把 cdn-cgi 拼在源图片所在域名上(blog.meathill.com/cdn-cgi/...),但我的博客是 WordPress + Next.js 前端,所以路由很复杂,有些直接由 WordPress 处理,有些则是由 Next.js 处理。如果都是外网,那就还好;但是内网的话,就经常出错。

更糟的是,从 blog.meathill.com/cdn-cgi/... 发起的 301 会跳向 meathill.com/cdn-cgi/image/https:/blog.meathill.com/...——少了一个斜杠,因为 CF 的 redirect 处理把 https:// 抹成了 https:/。我发现 Cloudflare 处理 URL 的 bug 还挺多的……

3.2 v2:换成 ${origin}/cdn-cgi/...

第二次修:

const origin = new URL(request.url).origin;
const featuredMedia =
  rawFeaturedMedia && rawFeaturedMedia.match(/\.webp(\?|$)/i)
    ? `${origin}/cdn-cgi/image/format=png,width=1200/${rawFeaturedMedia}`
    : rawFeaturedMedia;

把 cdn-cgi 段拼在当前请求 origin 上,也就是主站 meathill.com。这是 Cloudflare Image Resizing 标准用法:cdn-cgi 必须挂在启用了 Image Resizing 的 zone 上,源图片可以来自任何公网 URL。

curl 直接打这个 URL 是工作的,OG 图也确实出来过——一段时间。

但问题再次浮现:OG 图在 Worker 日志里间歇性失败,错误就是文章开头那条 Unsupported image type: unknown。线下手测都正常,生产 Worker 的子请求就挂。

3.3 真正的根因:Worker 不能 fetch 自己的 zone

wrangler.jsonc

"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"]

global_fetch_strictly_public 是一个安全 flag,要求 Worker 的 fetch() 只能打公网,不允许走 Worker 内部回环。听起来像加固,但它的副作用是:

当 Worker 想 fetch 自己所在 zone 的特殊路径(比如 /cdn-cgi/image/...),CF 路由会把这条子请求打回 Worker 自己,而不是交给边缘 Image Resizing 服务。

我的 Worker 不知道怎么处理 /cdn-cgi/image/...,于是落到 Next.js 的 catch-all 兜底(404 / 首页 HTML / 其他),Satori 拿到这堆 HTML 字节看 magic bytes 完全不认识,于是抛 Unsupported image type: unknown

为什么本地测不出来?

  • `pnpm dev`(普通 Next.js dev server)-> 根本没有 cdn-cgi 这个端点,本地 dev 永远走渐变兜底

  • 终端 curl -> 不是 Worker 的子请求,CF 边缘正常处理,返回 PNG

  • 部署后的 Worker 自己 fetch -> 被路由退回,挂

只有最后一种场景会复现。这种「只在生产挂、本地永远好」的 bug 是最难排查的那一类。

3.4 最终方案:用 IMAGES binding

Cloudflare Workers 提供了 Images binding,本质上是把 cdn-cgi Image Resizing 的能力直接以 binding 形式暴露给 Worker——不走子请求、不经过 zone 路由

wrangler.jsonc

"images": { "binding": "IMAGES" }

转码逻辑:

import { getCloudflareContext } from '@opennextjs/cloudflare';

async function loadCoverDataUrl(url: string): Promise<string | null> {
  try {
    const { env } = await getCloudflareContext({ async: true });
    if (!env.IMAGES) return null;

    const upstream = await fetch(url);
    if (!upstream.ok || !upstream.body) return null;

    const out = await env.IMAGES
      .input(upstream.body)
      .transform({ width: 1200 })
      .output({ format: 'image/png' });

    const buf = await out.response().arrayBuffer();
    const b64 = Buffer.from(buf).toString('base64');
    return `data:image/png;base64,${b64}`;
  } catch {
    return null;
  }
}

调用方一行:

const featuredMedia = rawFeaturedMedia ? await loadCoverDataUrl(rawFeaturedMedia) : null;

关键点:把转码后的字节以 data: URL 内嵌到 Satori 的 <img src> Satori 看到 data URL 就地解码,不再发起任何网络 fetch。整条「Worker 子请求 → 被 zone 路由退回」的故障路径被彻底绕开。

副作用是 OG 响应字节会大几百 KB(因为 PNG 内嵌进 SVG 再光栅化),但 OG 图本身就是 OOB 渲染的中等体量响应,next/og 自己也有缓存层,影响可以接受。

四、最佳实践清单

把上面的故事浓缩成一份可以直接照着抄的清单:

4.1 元数据层

  • layout.tsx 必有 metadataBaseopenGraph.imagestwitter.images,作为兜底。

  • 静态兜底图用 PNG,不用 WebP。

  • twitter.card: 'summary_large_image'

  • 子页面的 metadata 不写 images 时自动继承根 layout;要换图就显式覆盖。

  • 文章详情页 openGraph.type: 'article',配 publishedTime

  • 所有 images 用绝对 URL,从环境变量读站点 URL,不要相对路径。

4.2 动态生成层

  • 路由文件 route.tsx(不是 .ts)export sizecontentType

  • 卡片用「背景图 + 蒙版 + 标题 + 品牌」四层结构。

  • 容器都加 display: 'flex',不要用 gap

  • 标题加 text-shadow 容错,应对各种封面色。

  • 中文站点最好注入自定义字体(fonts: [...] 选项)。

  • 任何错误路径都返回一张「兜底卡片」,绝不 throw 让响应变成 500——社交爬虫看到非 200 会放弃整条 og:image,整篇文章在分享界面变成「无封面」。

4.3 Cloudflare Workers 特定

  • 不要在 Worker 里 fetch('https://your-zone.com/cdn-cgi/...'),会被路由打回。

  • 转码用 env.IMAGES binding,不用 cdn-cgi URL。

  • 转码完用 data:image/png;base64,... 内嵌给 Satori,避免二次网络 fetch。

  • 所有可能失败的 fetch / 转码都包 try/catch,失败 return null,让上层走兜底。

  • pnpm preview(OpenNext 起 wrangler)测,不要只用 pnpm dev——dev server 没有 binding。

4.4 验证清单

线上部署后逐一过:

  1. curl -I https://your-site.com/api/og/post?slug=xxx 返回 200 + content-type: image/png

  2. Twitter Card ValidatorFacebook Sharing DebuggerOpenGraph.xyz 三个工具各抓一遍——它们的爬虫行为略有差异,能交叉暴露问题。

  3. 在 Telegram 私聊里粘贴文章 URL,预览卡片是否出图(Telegram 抓取最快、也最严格)。

  4. 拿一篇没有 featured media 的文章测,确认走渐变兜底、依然 200。

  5. 拿一篇 featured media URL 已失效的文章测(或者临时改成 https://example.invalid/foo.webp),确认依然 200 + 渐变兜底。

  6. Worker 日志里搜索 Unsupported image type 等关键字,应该完全没有。

五、收尾

OG image 这块技术栈看上去琐碎,但它直接决定了你的内容在社交渠道的「第一印象命中率」。值得花一两天把它做扎实。

回头看我这三次返工,其实每一次都是「在错误的层级上绕过问题」:

  1. v1:拼错 host,但试着用一个很巧妙的 regex 解决;

  2. v2:拼对了 host,但没意识到 Worker 自己不能打 cdn-cgi;

  3. v3:放弃 cdn-cgi 这条路,直接用更底层的 binding。

教训是:当你发现自己在「绕过运行时」时,往往说明应该换一个 API。 Cloudflare 提供 IMAGES binding 不是为了取代 cdn-cgi,而是给 Worker 这个「不能正常 fetch 自己 zone」的特殊运行时一个直连入口。我看到它躺在 wrangler.jsonc 里好几个月没用,是因为我一直没意识到 cdn-cgi 那条路对 Worker 而言是有限制的。

希望这篇能帮你跳过同样的几个坑。

觉得文章有帮助?

如果我的分享对你有所启发,欢迎通过赞助来支持我持续创作。

❤️ 赞助我

评论