Next.js 对接 R2 对象存储

起因
博客上线之后,封面图字段是没有的,后来加了个url字段只能粘贴链接,太麻烦了,就对接了 Cloudflare 的 R2 存储,因为可以白嫖,给我一个博客平台使用足够了。
为什么不用服务端中转
最开始的直觉方案是:表单里放个文件输入,提交到 Server Action,由服务端上传到对象存储,但在 Next.js 里这条路有两个硬限制:
Server Action 的请求体默认上限是 1MB (Next.js 16 文档),超了直接报错;虽然可以用 experimental.serverActions.bodySizeLimit 调大,但是 Vercel 的 Serverless 函数本身还有请求体上线(4.5MB),大图依旧会踩线。
所以选了另一条路:浏览器直接传到对象存储,服务端只负责签发一次性上传凭证,图片字节完全不过服务器。
整体链路
浏览器选图
→ canvas 压缩(长边 1600 / WebP)
→ Server Action:校验管理员身份 + 签发预签名 PUT
→ 浏览器直接 PUT 到 R2
→ 拿到公开 URL,随表单一并入库一、Cloudflare 侧的四步配置
- 建桶:R2 → Create bucket(存储类选"标准",位置选"自动")
- 绑自定义域名:bucket → Settings → Custom Domains,绑一个
cdn.example.com。不要用r2.dev——官方明确说它只适合测试,有速率限制。公网读取只走这个域名,顺便白得一层 Cloudflare 边缘缓存。 - 建 API Token:R2 → Manage API Tokens,权限选 "Object Read & Write" 并限定到该桶。会拿到 Access Key ID / Secret Access Key(Secret 只显示一次),端点形如
https://<account-id>.r2.cloudflarestorage.com。 - 配 CORS(最容易漏,下面第二个坑就跟它有关):
[
{
"AllowedOrigins": ["https://www.example.com", "http://localhost:3000"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["etag"],
"MaxAgeSeconds": 3600
}
]二、服务端:只做签发,不碰字节
依赖就两个(只在服务端用,不会进客户端包):
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner// src/lib/storage.ts
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export const CACHE_CONTROL = "public, max-age=31536000, immutable";
function config() {
const accountId = process.env.R2_ACCOUNT_ID;
// 故意不在模块顶层抛错:next build 会求值这个模块,而 CI 里没有这些变量
if (!accountId) throw new Error("缺少 R2 环境变量");
return {
bucket: process.env.R2_BUCKET!,
publicBaseUrl: process.env.R2_PUBLIC_BASE_URL!.replace(/\/+$/, ""),
client: new S3Client({
region: "auto", // R2 固定 auto
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
}),
};
}
export async function createUploadTarget(key: string, contentType: string) {
const { client, bucket, publicBaseUrl } = config();
const uploadUrl = await getSignedUrl(
client,
new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
CacheControl: CACHE_CONTROL,
}),
{ expiresIn: 300 }, // 5 分钟有效
);
return {
uploadUrl,
publicUrl: `${publicBaseUrl}/${key}`,
// 客户端必须原样带上这两个头,原因见「坑 1」
headers: { "Content-Type": contentType, "Cache-Control": CACHE_CONTROL },
};
}对象键用 UUID 而不是原文件名,有三个理由:不会撞名、不受中文与特殊字符的 URL 编码折磨、内容不可变所以可以放心声明一年缓存。
三、浏览器端:先压缩,再直传
const STEPS = [
{ maxEdge: 1600, quality: 0.82 },
{ maxEdge: 1600, quality: 0.68 },
{ maxEdge: 1280, quality: 0.75 },
];
async function compressImage(file: File): Promise<File> {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
let best: Blob | null = null;
for (const step of STEPS) {
const scale = Math.min(1, step.maxEdge / Math.max(bitmap.width, bitmap.height));
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/webp", step.quality),
);
if (blob && (!best || blob.size < best.size)) best = blob;
if (best && best.size <= 450 * 1024) break; // 体积达标就停手
}
bitmap.close();
// 压不动就老老实实用原图,不阻断上传
return best && best.size < file.size ? new File([best], "cover.webp", { type: "image/webp" }) : file;
}实测效果:一张 1920×1288 的 PNG 截图,3184KB → 261KB(-92%)。
上传本身就是一次普通的 fetch:
const target = await createCoverUploadAction({ type: upload.type, size: upload.size });
await fetch(target.uploadUrl, {
method: "PUT",
headers: target.headers,
body: upload,
});三个真实的坑
坑 1:预签名 URL 只签了 host,Cache-Control 不会自己带上
我在 PutObjectCommand 里明明写了 CacheControl: "public, max-age=31536000, immutable",上传也成功,但线上取图返回的是:
cache-control: max-age=14400这是 Cloudflare 的默认值,不是我要的一年。用 S3 API 查对象元数据,CacheControl 是 undefined。
翻了签名 URL 的查询串才明白:
X-Amz-SignedHeaders=host它只签了 host 这一个头。 ContentType、CacheControl 这类"请求头参数"不在签名范围内——客户端不发,服务端就不写元数据,而且不会报签名错误,非常安静。
修法:把需要携带的头由服务端统一返回,客户端原样带上。改完再查:
HEAD object → CacheControl: "public, max-age=31536000, immutable" ✅
GET public → cache-control: public, max-age=31536000, immutable ✅坑 2:CORS 的 AllowedHeaders 要跟着代码一起改
上面那个修复之后,浏览器直接给你看 net::ERR_FAILED:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.先别怀疑签发逻辑——手动打一次预检最快:
curl -i -X OPTIONS \
-H "Origin: https://www.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type,cache-control" \
"https://<bucket>.<account>.r2.cloudflarestorage.com/covers/probe.webp"结果很干脆:只请求 content-type 时返回 204 正常,一旦加上 cache-control 就是 403——因为加头的同时,CORS 策略里的 AllowedHeaders 还是老的 content-type。
结论:每次给上传请求加自定义头,都要同步检查 CORS 的 AllowedHeaders(写成 ["*"] 一劳永逸,把 AllowedOrigins 限制好就行)。
坑 3:客户端压缩"看起来没生效"
第一次实测,上传的仍是那个 3.2MB 的 PNG,文件名还是 .png。可能是三种情况:
- 页面还在跑旧 bundle(开发环境的 HMR 有时很慢),压缩代码根本没执行
- 浏览器不支持 canvas 的 WebP 编码,
toBlob悄悄回退成 PNG - 压缩结果反而更大,代码按设计回退了原图
三种表现一模一样,没法猜。于是在压缩函数里加了几行开发日志:
log("原图", { sizeKB, width, height });
log("候选", { maxEdge, quality, type: blob.type, sizeKB: Math.round(blob.size / 1024) });再传一次,日志直接说明是第 1 种:硬刷新之后,261KB 的 WebP 就出来了。这几行日志我特意留在代码里——下次出问题不用再猜。
一点取舍:前台为什么用原生 <img>
图片已经在浏览器端压到 200–400KB 的 WebP,又由 Cloudflare 边缘缓存一年,所以前台渲染用的是原生 <img>:
- 走
next/image会多一跳 Vercel 图片优化(对国内访客反而更慢),还占优化额度 - 原生标签配上显式的
width/height(防布局抖动)和loading="lazy"(列表缩略图)就够了 - 列表缩略图、文章头图、
og:image全部复用同一个 URL
小结
- 图片这类"大而静态"的数据,浏览器直传 + 服务端只签发是最省心的架构
- 预签名 URL 的签名范围要实测(看
X-Amz-SignedHeaders),别假设 SDK 里的参数都会生效 - CORS 和请求头是连体婴,改一个必须检查另一个
- 排查"没生效"时,先把可能性用日志收窄,比反复改代码快得多
参考资料
- Next.js 官方文档 · Server Actions(
bodySizeLimit默认 1MB,及如何调大) - Vercel 官方文档 · Functions 限制(Serverless 函数请求体上限,文中"4.5MB 量级"的依据)
- Cloudflare R2 文档 · Public buckets(自定义域名与
r2.dev的定位) - Cloudflare R2 文档 · Bucket CORS(CORS 策略字段与写法,对应"坑 2")
- Cloudflare R2 文档 · API Tokens(S3 凭证与端点格式)
- Cloudflare R2 文档 · S3 API 兼容性(支持哪些 S3 操作与参数)
- Cloudflare R2 文档 · Presigned URLs(R2 侧的预签名上传说明,对应"坑 1"讨论的签名范围)
- AWS 文档 · Sharing objects with presigned URLs(SigV4 签名覆盖范围、
X-Amz-SignedHeaders的语义) - AWS SDK for JavaScript v3 · @aws-sdk/s3-request-presigner(
getSignedUrl的 API 参考) - MDN · createImageBitmap()(浏览器端解码图片,压缩的第一步)
- MDN · HTMLCanvasElement.toBlob()(
image/webp编码与不支持时的回退行为,对应"坑 3")