Skip to content

Cloudwerk provides integration with Cloudflare’s image services through @cloudwerk/images. Choose the right service for your needs: Hosted Images for storing and serving user uploads, Image Transformer for CDN-style resizing, or the IMAGES binding for in-Worker transform pipelines.

Use CaseRecommended Service
User avatars, product photosHosted Images
Resizing images from external URLsImage Transformer
Processing uploads before storing to R2IMAGES Binding
Thumbnail generation on uploadHosted Images or IMAGES Binding
CDN-style image optimizationImage Transformer

Store and serve images through Cloudflare’s global CDN with automatic variant generation.

  1. Create an image definition:

    // app/images/avatars.ts
    import { defineImage } from '@cloudwerk/images'
    export default defineImage({
    variants: {
    thumbnail: { width: 100, height: 100, fit: 'cover' },
    profile: { width: 400, height: 400, fit: 'cover' },
    large: { width: 800, fit: 'scale-down', quality: 90 },
    },
    })
  2. Configure environment variables:

    Terminal window
    # .dev.vars (local development)
    CF_ACCOUNT_ID=your-account-id
    CF_IMAGES_TOKEN=your-api-token
  3. Use from your routes:

    // app/api/avatar/route.ts
    import { images } from '@cloudwerk/core/bindings'
    import { json } from '@cloudwerk/core'
    export async function POST(request: Request) {
    const formData = await request.formData()
    const file = formData.get('avatar') as File
    const result = await images.avatars.upload(file)
    return json({
    id: result.id,
    thumbnail: images.avatars.url(result.id, 'thumbnail'),
    profile: images.avatars.url(result.id, 'profile'),
    })
    }

Image definitions live in the app/images/ directory:

  • Directoryapp/images/
    • avatars.ts # → images.avatars
    • products.ts # → images.products
    • banners.ts # → images.banners

Naming Convention:

  • Filenames become image client names
  • avatars.tsimages.avatars
  • product-photos.tsimages.productPhotos

Hosted Images requires a Cloudflare account with Images enabled:

  1. Get your Account ID from the Cloudflare dashboard URL or Overview page

  2. Create an API token with Cloudflare Images:Edit permission

  3. Add to environment:

    Terminal window
    # .dev.vars
    CF_ACCOUNT_ID=abc123
    CF_IMAGES_TOKEN=your-token

Variants define how images are transformed when served:

export default defineImage({
variants: {
// Square thumbnail
thumbnail: {
width: 100,
height: 100,
fit: 'cover',
},
// Maintain aspect ratio, max width
medium: {
width: 600,
fit: 'scale-down',
quality: 85,
},
// Optimized for retina displays
retina: {
width: 800,
dpr: 2,
format: 'webp',
},
// Face-centered crop for profiles
profile: {
width: 400,
height: 400,
fit: 'cover',
gravity: 'face',
},
},
})
ModeDescription
coverFill dimensions, cropping if needed
containFit within dimensions, may add letterboxing
scale-downShrink to fit, never enlarge
cropCrop to exact dimensions from center
padFit within dimensions, padding if needed
import { images } from '@cloudwerk/core/bindings'
// Basic upload
const result = await images.avatars.upload(file)
// With custom ID
const result = await images.avatars.upload(file, {
id: `user-${userId}-avatar`,
})
// With metadata
const result = await images.avatars.upload(file, {
metadata: {
userId: '123',
uploadedAt: new Date().toISOString(),
},
})

For large files, use direct uploads to bypass your Worker:

// Server: Generate upload URL
export async function POST(request: Request) {
const directUpload = await images.avatars.getDirectUploadUrl({
id: `user-${userId}-${Date.now()}`,
metadata: { userId },
})
return json({
uploadUrl: directUpload.uploadUrl,
imageId: directUpload.id,
})
}
// Client: Upload directly to Cloudflare
const { uploadUrl, imageId } = await fetch('/api/upload-url', {
method: 'POST',
}).then(r => r.json())
await fetch(uploadUrl, {
method: 'POST',
body: formData,
})
// Image is now available at: images.avatars.url(imageId, 'thumbnail')

Use Cloudflare’s Image Resizing to transform images from any URL via query parameters.

  1. Create a transformer route:

    // app/cdn/images/[...path]/route.ts
    import { createImageTransformer } from '@cloudwerk/images'
    export const GET = createImageTransformer({
    allowedOrigins: ['https://images.mysite.com'],
    presets: {
    thumbnail: { width: 100, height: 100, fit: 'cover' },
    hero: { width: 1920, height: 1080, fit: 'cover' },
    },
    defaults: {
    format: 'auto',
    quality: 85,
    },
    })
  2. Transform images via URL:

    /cdn/images/https://images.mysite.com/photo.jpg?preset=thumbnail
    /cdn/images/https://images.mysite.com/photo.jpg?w=800&h=600

Always restrict allowed origins in production:

export const GET = createImageTransformer({
// Only allow images from these origins
allowedOrigins: [
'https://images.mysite.com',
'https://cdn.mysite.com',
],
// Or use custom validation
validateSource: async (url) => {
// Check against database, etc.
return allowedDomains.has(url.hostname)
},
})

Disable arbitrary parameters for tighter control:

export const GET = createImageTransformer({
allowArbitrary: false, // Only allow named presets
presets: {
sm: { width: 320 },
md: { width: 768 },
lg: { width: 1200 },
xl: { width: 1920 },
},
})

Transformed images are cached at the edge:

export const GET = createImageTransformer({
// Cache for 1 year (default)
cacheControl: 'public, max-age=31536000',
// Or shorter for frequently changing images
cacheControl: 'public, max-age=86400', // 1 day
})

Process images directly in your Worker with the IMAGES binding. Ideal for transforming uploads before storing to R2.

  1. Add binding to wrangler.toml:

    [[images]]
    binding = "IMAGES"
  2. Use in your routes:

    import { getBinding } from '@cloudwerk/core/bindings'
    import type { CloudflareImagesBinding } from '@cloudwerk/images'
    export async function POST(request: Request) {
    const IMAGES = getBinding<CloudflareImagesBinding>('IMAGES')
    const formData = await request.formData()
    const file = formData.get('image') as File
    const response = await IMAGES
    .input(await file.arrayBuffer())
    .transform({ width: 800, quality: 85 })
    .output({ format: 'image/webp' })
    .response()
    return response
    }

Chain multiple transformations:

const result = await IMAGES
.input(imageData)
.transform({ width: 1200 }) // Resize
.transform({ rotate: 90 }) // Rotate
.transform({ brightness: 0.1 }) // Brighten
.output({ format: 'image/webp', quality: 85 })
.response()

Check image dimensions before processing:

const info = await IMAGES.info(imageData)
console.log(info.width, info.height) // e.g., 1920, 1080
console.log(info.format) // e.g., 'image/jpeg'
console.log(info.fileSize) // e.g., 245760

Process uploads and store to R2:

import { getBinding } from '@cloudwerk/core/bindings'
import type { CloudflareImagesBinding } from '@cloudwerk/images'
export async function POST(request: Request) {
const IMAGES = getBinding<CloudflareImagesBinding>('IMAGES')
const BUCKET = getBinding<R2Bucket>('IMAGES_BUCKET')
const formData = await request.formData()
const file = formData.get('image') as File
const id = crypto.randomUUID()
// Generate multiple sizes
const sizes = [
{ name: 'thumb', width: 100, height: 100 },
{ name: 'medium', width: 600 },
{ name: 'large', width: 1200 },
]
await Promise.all(sizes.map(async (size) => {
const transformed = await IMAGES
.input(await file.arrayBuffer())
.transform({ width: size.width, height: size.height, fit: 'cover' })
.output({ format: 'image/webp', quality: 85 })
.arrayBuffer()
await BUCKET.put(`${id}/${size.name}.webp`, transformed, {
httpMetadata: { contentType: 'image/webp' },
})
}))
return json({
id,
variants: sizes.map(s => `${id}/${s.name}.webp`),
})
}

import { images } from '@cloudwerk/core/bindings'
import { json } from '@cloudwerk/core'
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
export async function POST(request: Request) {
const formData = await request.formData()
const file = formData.get('avatar') as File
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return json({ error: 'Invalid file type' }, { status: 400 })
}
// Validate file size
if (file.size > MAX_SIZE) {
return json({ error: 'File too large' }, { status: 400 })
}
const result = await images.avatars.upload(file)
return json({
id: result.id,
url: images.avatars.url(result.id, 'profile'),
})
}
// Generate srcset for responsive images
function getResponsiveSrcSet(imageId: string) {
const sizes = ['thumbnail', 'medium', 'large']
return sizes
.map(size => `${images.products.url(imageId, size)} ${getWidth(size)}w`)
.join(', ')
}
// In your component
export default function ProductImage({ imageId }) {
return (
<img
src={images.products.url(imageId, 'medium')}
srcSet={getResponsiveSrcSet(imageId)}
sizes="(max-width: 600px) 100vw, 600px"
loading="lazy"
/>
)
}
export async function DELETE(request: Request, { params }) {
const user = await db.query.users.findFirst({
where: eq(users.id, params.id),
})
if (user?.avatarId) {
await images.avatars.delete(user.avatarId)
}
await db.delete(users).where(eq(users.id, params.id))
return json({ success: true })
}

import {
ImageUploadError,
ImageNotFoundError,
ImageVariantError,
} from '@cloudwerk/images'
try {
const result = await images.avatars.upload(file)
return json({ id: result.id })
} catch (error) {
if (error instanceof ImageUploadError) {
return json({ error: 'Upload failed' }, { status: 500 })
}
throw error
}
try {
const url = images.avatars.url(id, 'unknown-variant')
} catch (error) {
if (error instanceof ImageVariantError) {
console.log('Available:', error.availableVariants)
}
}