Configure file uploads with Vercel Blob, Cloudflare R2, AWS S3, or UploadThing
NOW.TS comes with a flexible file upload system using adapters. You can easily switch between different storage providers without changing your application code.
The upload system uses an adapter pattern defined in src/lib/files/upload-file.ts:
export type UploadFileAdapter = {
uploadFile: (params: { file: File; path: string }) => Promise<
| { error: null; data: { url: string } }
| { error: Error; data: null }
>;
uploadFiles: (params: { file: File; path: string }[]) => Promise<
{ error: Error | null; data: { url: string } | null }[]
>;
};
The active adapter is imported in src/features/images/upload-image.action.ts.
Vercel Blob is the default and recommended option. It's automatically configured when deploying to Vercel.
Setup:
BLOB_READ_WRITE_TOKEN is automatically added to your environmentFor local development:
.env file:BLOB_READ_WRITE_TOKEN="vercel_blob_..."
Adapter code (already included at src/lib/files/vercel-blob-adapter.ts):
import { put } from "@vercel/blob";
import type { UploadFileAdapter } from "./upload-file";
export const fileAdapter: UploadFileAdapter = {
uploadFile: async (params) => {
try {
const blob = await put(params.file.name, params.file, {
access: "public",
});
return { error: null, data: { url: blob.url } };
} catch (error) {
return {
error: error instanceof Error ? error : new Error("Failed to upload file"),
data: null,
};
}
},
uploadFiles: async (params) => {
const promises = params.map(async (param) => {
try {
const blob = await put(param.file.name, param.file, {
access: "public",
});
return { error: null, data: { url: blob.url } };
} catch (error) {
return {
error: error instanceof Error ? error : new Error("Failed to upload file"),
data: null,
};
}
});
return Promise.all(promises);
},
};
Use Cloudflare R2 or AWS S3 for more control over your storage or to avoid vendor lock-in.
Setup:
pnpm add @aws-sdk/client-s3 mime-types
pnpm add -D @types/mime-types
.env:AWS_ENDPOINT="https://your-account-id.r2.cloudflarestorage.com"
AWS_ACCESS_KEY_ID="your-access-key"
AWS_SECRET_ACCESS_KEY="your-secret-key"
AWS_S3_BUCKET_NAME="your-bucket-name"
R2_URL="https://your-public-bucket-url.com"
src/lib/files/r2-adapter.ts:import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import type { UploadFileAdapter } from "./upload-file";
const s3 = new S3Client({
region: "auto",
endpoint: process.env.AWS_ENDPOINT,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export const fileAdapter: UploadFileAdapter = {
uploadFile: async (params) => {
try {
const fileBuffer = await params.file.arrayBuffer();
const buffer = Buffer.from(fileBuffer);
const uniqueFileName = `${params.path}/${Date.now()}-${params.file.name}`;
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME,
Key: uniqueFileName,
Body: buffer,
ContentType: params.file.type,
});
await s3.send(command);
const url = `${process.env.R2_URL}/${uniqueFileName}`;
return { error: null, data: { url } };
} catch (error) {
return {
error: error instanceof Error ? error : new Error("Upload failed"),
data: null,
};
}
},
uploadFiles: async (params) => {
const results = await Promise.allSettled(
params.map((param) => fileAdapter.uploadFile(param))
);
return results.map((result) => {
if (result.status === "fulfilled") {
return result.value;
}
return {
error: new Error(result.reason?.message || "Upload failed"),
data: null,
};
});
},
};
src/features/images/upload-image.action.ts:// Change this:
import { fileAdapter } from "@/lib/files/vercel-blob-adapter";
// To this:
import { fileAdapter } from "@/lib/files/r2-adapter";
Video tutorials:
UploadThing is a developer-friendly file upload service with a generous free tier.
Setup:
pnpm add uploadthing
.env:UPLOADTHING_TOKEN="your-uploadthing-token"
src/lib/files/uploadthing-adapter.ts:import { UTApi } from "uploadthing/server";
import type { UploadFileAdapter } from "./upload-file";
export const utapi = new UTApi({});
export const fileAdapter: UploadFileAdapter = {
uploadFile: async (params) => {
const response = await utapi.uploadFiles([params.file]);
if (response[0].error) {
return { error: new Error(response[0].error.message), data: null };
}
return { error: null, data: { url: response[0].data.ufsUrl } };
},
uploadFiles: async (params) => {
const response = await utapi.uploadFiles(params.map((param) => param.file));
return response.map((res) => {
if (res.error) {
return { error: new Error(res.error.message), data: null };
}
return { error: null, data: { url: res.data.ufsUrl } };
});
},
};
src/features/images/upload-image.action.ts:import { fileAdapter } from "@/lib/files/uploadthing-adapter";
To switch between adapters, simply change the import in src/features/images/upload-image.action.ts:
// Vercel Blob (default)
import { fileAdapter } from "@/lib/files/vercel-blob-adapter";
// Cloudflare R2 / AWS S3
import { fileAdapter } from "@/lib/files/r2-adapter";
// UploadThing
import { fileAdapter } from "@/lib/files/uploadthing-adapter";
By default, image upload is disabled. To enable it:
src/site-config.tsenableImageUpload to true:export const SiteConfig = {
// ...
features: {
enableImageUpload: true,
// ...
},
};
This enables drag-and-drop and click-to-upload functionality throughout the app.