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 体系都应该是两层:
-
站点级静态图片兜底:准备一张 PNG 图片,放在
public/og-image.png,写在根layout.tsx的metadata.openGraph.images里。这样所有未单独配置的页面(首页、列表页、关于页……)都会拿它当封面,永远不会失败。 -
文章级动态生成:具体文章页,独立生成 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 等)都只覆盖 title 和 description,不写 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,
},
};
注意:
-
必须用绝对 URL。爬虫不带 cookie、不在 referer 上下文里,相对 URL 会被解析成爬虫所在域名。
process.env.NEXT_PUBLIC_SITE_URL是项目里统一的站点 URL 来源(虽然在wrangler.jsonc的vars里记录,但它们应配置在构建时的环境变量中,以便 Next.js 在构建时将其注入到代码中)。 -
type: 'article'+publishedTime是 Open Graph 的 article 子规范,主流爬虫会把它当成文章在 timeline 上的发布时间。 -
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 },
);
}
// ... 渲染卡片,见下文
}
size 和 contentType 这两个 export 是 Next.js 的文件级 metadata 约定,会被注入到响应头,部分场景(比如 opengraph-image.tsx 文件约定)会自动替你写到 HTML 里。
2.2 卡片布局:分层 + 蒙版 + Footer
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 子项不会换行。漏写会 throwexpected flex container。 -
不能用
gap,Satori 不实现这个属性。要靠margin或多套一层 wrapper。 -
不能用 CSS variable 和媒体查询。Satori 是一次性渲染到 1200×630 固定画布,没有视口、没有运行时主题切换。
-
字体:默认走系统字体兜底,中文经常糊。生产环境建议在
ImageResponse第二个参数里fonts: [{ name, data, weight }]注入自定义字体。我目前没注入,因为标题信息密度低且用了text-shadow容错,但工业级项目应该注入。 -
text-shadowSatori 是支持的,标题加一点黑色阴影能在浅色封面上保持可读。
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必有metadataBase、openGraph.images、twitter.images,作为兜底。 -
静态兜底图用 PNG,不用 WebP。
-
twitter.card: 'summary_large_image'。 -
子页面的 metadata 不写
images时自动继承根 layout;要换图就显式覆盖。 -
文章详情页
openGraph.type: 'article',配publishedTime。 -
所有
images用绝对 URL,从环境变量读站点 URL,不要相对路径。
4.2 动态生成层
-
路由文件
route.tsx(不是.ts)exportsize和contentType。 -
卡片用「背景图 + 蒙版 + 标题 + 品牌」四层结构。
-
容器都加
display: 'flex',不要用gap。 -
标题加
text-shadow容错,应对各种封面色。 -
中文站点最好注入自定义字体(
fonts: [...]选项)。 -
任何错误路径都返回一张「兜底卡片」,绝不 throw 让响应变成 500——社交爬虫看到非 200 会放弃整条 og:image,整篇文章在分享界面变成「无封面」。
4.3 Cloudflare Workers 特定
-
不要在 Worker 里
fetch('https://your-zone.com/cdn-cgi/...'),会被路由打回。 -
转码用
env.IMAGESbinding,不用 cdn-cgi URL。 -
转码完用
data:image/png;base64,...内嵌给 Satori,避免二次网络 fetch。 -
所有可能失败的 fetch / 转码都包
try/catch,失败 returnnull,让上层走兜底。 -
用
pnpm preview(OpenNext 起 wrangler)测,不要只用pnpm dev——dev server 没有 binding。
4.4 验证清单
线上部署后逐一过:
-
curl -I https://your-site.com/api/og/post?slug=xxx返回 200 +content-type: image/png。 -
用 Twitter Card Validator、Facebook Sharing Debugger、OpenGraph.xyz 三个工具各抓一遍——它们的爬虫行为略有差异,能交叉暴露问题。
-
在 Telegram 私聊里粘贴文章 URL,预览卡片是否出图(Telegram 抓取最快、也最严格)。
-
拿一篇没有 featured media 的文章测,确认走渐变兜底、依然 200。
-
拿一篇 featured media URL 已失效的文章测(或者临时改成
https://example.invalid/foo.webp),确认依然 200 + 渐变兜底。 -
Worker 日志里搜索
Unsupported image type等关键字,应该完全没有。
五、收尾
OG image 这块技术栈看上去琐碎,但它直接决定了你的内容在社交渠道的「第一印象命中率」。值得花一两天把它做扎实。
回头看我这三次返工,其实每一次都是「在错误的层级上绕过问题」:
-
v1:拼错 host,但试着用一个很巧妙的 regex 解决;
-
v2:拼对了 host,但没意识到 Worker 自己不能打 cdn-cgi;
-
v3:放弃 cdn-cgi 这条路,直接用更底层的 binding。
教训是:当你发现自己在「绕过运行时」时,往往说明应该换一个 API。 Cloudflare 提供 IMAGES binding 不是为了取代 cdn-cgi,而是给 Worker 这个「不能正常 fetch 自己 zone」的特殊运行时一个直连入口。我看到它躺在 wrangler.jsonc 里好几个月没用,是因为我一直没意识到 cdn-cgi 那条路对 Worker 而言是有限制的。
希望这篇能帮你跳过同样的几个坑。