Thanks @broken badger - I was able to get it working with brute force choices.
Media.ts has disableLocalStorage: true
s3Storage plugin is configured like tihs:
enabled: Boolean(process.env.R2_BUCKET),
disableLocalStorage: true,
collections: {
media: {
generateFileURL: ({ filename, prefix }) => {
const key = [prefix, filename].filter(Boolean).join('/')
return `${process.env.R2_PUBLIC_URL}/${key}`
},
},
},
I ended up adding this to my next.config.js as well. I let Claude take the wheel and it led to this:
// Build remote patterns for Next.js Image optimization
const remotePatterns = []
// Add R2/Cloudflare URL if configured
const r2PublicUrl = process.env.R2_PUBLIC_URL
if (r2PublicUrl) {
try {
const url = new URL(r2PublicUrl)
remotePatterns.push({
hostname: url.hostname,
protocol: url.protocol.replace(':', ''),
})
} catch (e) {
console.warn('Invalid R2_PUBLIC_URL:', r2PublicUrl)
}
}
// Add localhost for development (when no R2 configured)
if (!r2PublicUrl) {
remotePatterns.push({
hostname: 'localhost',
protocol: 'http',
})
}
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns,
},
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
reactStrictMode: true,
redirects,
}