ShipFree includes built-in integration with Cloudflare R2 for object storage, providing S3-compatible storage without egress fees. The storage client supports both public and private buckets with signed URL generation.
Why Cloudflare R2?
Zero Egress Fees No charges for data transfer out
S3 Compatible Works with existing S3 tools and libraries
Global Performance Cloudflare’s global network for fast access
Cost Effective Significantly cheaper than traditional cloud storage
Storage Client
The storage client is implemented in src/lib/storage.ts:
import { AwsClient } from 'aws4fetch'
import { env } from '@/config/env'
class StorageClient {
private client : AwsClient
constructor () {
this . client = new AwsClient ({
accessKeyId: env . R2_ACCESS_KEY_ID || '' ,
secretAccessKey: env . R2_SECRET_ACCESS_KEY || '' ,
service: 's3' ,
region: 'auto' ,
})
}
async upload ({ key , body , opts , bucket = 'public' }) {
// Upload implementation
}
async delete ({ key , bucket = 'public' }) {
// Delete implementation
}
async getSignedUrl ({ key , method , bucket , expiresIn }) {
// Signed URL generation
}
}
export const storage = new StorageClient ()
Configuration
Create R2 Buckets
Create public and private buckets in your Cloudflare dashboard:
Public bucket : For publicly accessible files (images, assets)
Private bucket : For user uploads and sensitive files
Generate API Tokens
Create an R2 API token with read/write permissions.
Set Environment Variables
CLOUDFLARE_ACCOUNT_ID = your_account_id
R2_ACCESS_KEY_ID = your_access_key
R2_SECRET_ACCESS_KEY = your_secret_key
R2_PUBLIC_BUCKET = your-public-bucket
R2_PRIVATE_BUCKET = your-private-bucket
R2_BUCKET_URL = https://your-bucket.your-domain.com
You can get your Cloudflare Account ID from the R2 dashboard URL: https://dash.cloudflare.com/{account_id}/r2
Uploading Files
Upload from File
import { storage } from '@/lib/storage'
// Upload to public bucket
const result = await storage . upload ({
key: 'avatars/user-123.jpg' ,
body: fileBlob ,
bucket: 'public' ,
opts: {
contentType: 'image/jpeg' ,
},
})
console . log ( result . url ) // https://your-bucket.com/avatars/user-123.jpg
Upload from Base64
import { storage } from '@/lib/storage'
// The storage client automatically detects base64 strings
const result = await storage . upload ({
key: 'images/photo.png' ,
body: 'data:image/png;base64,iVBORw0KGgoAAAANS...' ,
bucket: 'public' ,
opts: {
contentType: 'image/png' ,
},
})
Upload from URL
import { storage } from '@/lib/storage'
// Download and upload an image from a URL
const result = await storage . upload ({
key: 'external/downloaded-image.jpg' ,
body: 'https://example.com/image.jpg' ,
bucket: 'public' ,
opts: {
contentType: 'image/jpeg' ,
width: 800 , // Resize on the fly
height: 600 ,
},
})
The storage client uses wsrv.nl for on-the-fly image resizing when uploading from URLs.
Bucket Types
Public Bucket
Use for publicly accessible files:
import { storage } from '@/lib/storage'
await storage . upload ({
key: 'public/logo.png' ,
body: logoFile ,
bucket: 'public' , // Files are publicly accessible
})
Files in the public bucket can be accessed directly via their URL without authentication.
Private Bucket
Use for sensitive user data:
import { storage } from '@/lib/storage'
await storage . upload ({
key: 'users/user-123/document.pdf' ,
body: documentFile ,
bucket: 'private' , // Requires signed URLs to access
})
Files in private buckets cannot be accessed directly. Use signed URLs for temporary access.
Signed URLs
Generate temporary URLs for secure file access:
Upload URLs
import { storage } from '@/lib/storage'
// Generate a signed URL for client-side uploads
const uploadUrl = await storage . getSignedUploadUrl ({
key: 'uploads/file-123.pdf' ,
bucket: 'private' ,
expiresIn: 600 , // 10 minutes
})
// Client can now upload directly to this URL
Download URLs
import { storage } from '@/lib/storage'
// Generate a signed URL for downloading private files
const downloadUrl = await storage . getSignedDownloadUrl ({
key: 'users/user-123/report.pdf' ,
bucket: 'private' ,
expiresIn: 3600 , // 1 hour
})
// Share this URL with authorized users
Deleting Files
import { storage } from '@/lib/storage'
await storage . delete ({
key: 'temp/old-file.jpg' ,
bucket: 'public' ,
})
Server Action Example
Implement file upload in a Server Action:
src/app/actions/upload.ts
'use server'
import { storage } from '@/lib/storage'
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
export async function uploadAvatar ( formData : FormData ) {
const session = await auth . api . getSession ({
headers: await headers (),
})
if ( ! session ) {
throw new Error ( 'Unauthorized' )
}
const file = formData . get ( 'avatar' ) as File
if ( ! file ) {
throw new Error ( 'No file provided' )
}
// Convert to buffer
const bytes = await file . arrayBuffer ()
const buffer = Buffer . from ( bytes )
// Upload to R2
const result = await storage . upload ({
key: `avatars/ ${ session . user . id } .jpg` ,
body: buffer ,
bucket: 'public' ,
opts: {
contentType: file . type ,
},
})
return { url: result . url }
}
Client Component Example
src/components/avatar-upload.tsx
'use client'
import { useState } from 'react'
import { uploadAvatar } from '@/app/actions/upload'
export function AvatarUpload () {
const [ uploading , setUploading ] = useState ( false )
const [ avatarUrl , setAvatarUrl ] = useState < string | null >( null )
const handleUpload = async ( e : React . ChangeEvent < HTMLInputElement >) => {
const file = e . target . files ?.[ 0 ]
if ( ! file ) return
setUploading ( true )
const formData = new FormData ()
formData . append ( 'avatar' , file )
try {
const result = await uploadAvatar ( formData )
setAvatarUrl ( result . url )
} catch ( error ) {
console . error ( 'Upload failed:' , error )
} finally {
setUploading ( false )
}
}
return (
< div >
< input
type = "file"
accept = "image/*"
onChange = { handleUpload }
disabled = { uploading }
/>
{ avatarUrl && < img src = { avatarUrl } alt = "Avatar" /> }
</ div >
)
}
API Route Example
src/app/api/upload/route.ts
import { storage } from '@/lib/storage'
import { NextRequest } from 'next/server'
export async function POST ( req : NextRequest ) {
const formData = await req . formData ()
const file = formData . get ( 'file' ) as File
if ( ! file ) {
return Response . json ({ error: 'No file provided' }, { status: 400 })
}
const bytes = await file . arrayBuffer ()
const buffer = Buffer . from ( bytes )
const result = await storage . upload ({
key: `uploads/ ${ Date . now () } - ${ file . name } ` ,
body: buffer ,
bucket: 'public' ,
opts: {
contentType: file . type ,
},
})
return Response . json ({ url: result . url })
}
Utility Functions
The storage module includes helpful utilities:
Check if URL is Stored
import { isStored } from '@/lib/storage'
const url = 'https://your-bucket.com/image.jpg'
if ( isStored ( url )) {
console . log ( 'This file is stored in R2' )
}
Check if Image is Not Hosted
import { isNotHostedImage } from '@/lib/storage'
const imageString = 'data:image/png;base64,...'
if ( isNotHostedImage ( imageString )) {
// Upload to R2 before saving
const result = await storage . upload ({
key: 'images/new-image.png' ,
body: imageString ,
bucket: 'public' ,
})
}
Image Resizing
When uploading from URLs, you can resize images on the fly:
import { storage } from '@/lib/storage'
await storage . upload ({
key: 'thumbnails/image.jpg' ,
body: 'https://example.com/large-image.jpg' ,
bucket: 'public' ,
opts: {
width: 400 ,
height: 300 ,
contentType: 'image/jpeg' ,
},
})
The storage client uses a proxy service for image transformation. The original image is fetched, resized, and then uploaded to R2.
Error Handling
import { storage } from '@/lib/storage'
try {
const result = await storage . upload ({
key: 'files/document.pdf' ,
body: fileBuffer ,
bucket: 'public' ,
})
console . log ( 'Upload successful:' , result . url )
} catch ( error ) {
console . error ( 'Upload failed:' , error )
// Handle error (retry, show message to user, etc.)
}
Add custom headers to uploads:
await storage . upload ({
key: 'files/document.pdf' ,
body: fileBuffer ,
bucket: 'public' ,
opts: {
contentType: 'application/pdf' ,
headers: {
'Cache-Control' : 'max-age=31536000' ,
'Content-Disposition' : 'attachment; filename="document.pdf"' ,
},
},
})
Best Practices
Use Descriptive Keys Use clear, hierarchical paths like avatars/user-123.jpg
Set Content Types Always specify the correct MIME type for uploads
Validate File Sizes Check file sizes before uploading to prevent abuse
Clean Up Unused Files Implement cleanup jobs to remove orphaned files
Environment Variables
CLOUDFLARE_ACCOUNT_ID = your_account_id
R2_ACCESS_KEY_ID = your_access_key_id
R2_SECRET_ACCESS_KEY = your_secret_access_key
R2_PUBLIC_BUCKET = your-public-bucket-name
R2_PRIVATE_BUCKET = your-private-bucket-name
R2_BUCKET_URL = https://pub-xxx.r2.dev
You can set up a custom domain for your R2 bucket in Cloudflare to use branded URLs instead of the default r2.dev domain.
Further Reading
Cloudflare R2 Docs Official Cloudflare R2 documentation
S3 API Compatibility Learn about S3-compatible API features
Custom Domains Set up custom domains for R2 buckets
Access Control Managing access tokens and permissions