Developer Guide

Supabase Storage File Uploads in Next.js: 2026 Guide

Upload files from Next.js to Supabase Storage with working code: bucket RLS policies, signed URLs, progressive uploads and image transforms via CDN.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
30 min read

File storage and media handling are critical components of modern web applications. This comprehensive guide teaches you how to implement robust, scalable file management with Supabase Storage and Next.js, from basic uploads to advanced optimization techniques.

Whether you are building a content management system, social platform, or e-commerce site, proper file handling directly impacts user experience, performance, and costs. Poor file management leads to slow uploads, security vulnerabilities, and expensive storage bills.

This guide covers everything from secure upload patterns to advanced image optimization, grounded in the Supabase Storage documentation and next/image.

Supabase Storage Fundamentals#

Understanding Buckets and Policies#

Supabase Storage organizes files into buckets with configurable access policies and security rules.

sql
-- Create buckets for different use cases
INSERT INTO storage.buckets (id, name, public) VALUES 
('avatars', 'avatars', true),
('documents', 'documents', false),
('product-images', 'product-images', true),
('user-uploads', 'user-uploads', false);
 
-- Set up RLS policies for secure access
CREATE POLICY "Users can upload their own avatars" ON storage.objects
FOR INSERT WITH CHECK (
  bucket_id = 'avatars' 
  AND auth.uid()::text = (storage.foldername(name))[1]
);
 
CREATE POLICY "Users can view their own documents" ON storage.objects
FOR SELECT USING (
  bucket_id = 'documents' 
  AND auth.uid()::text = (storage.foldername(name))[1]
);
 
CREATE POLICY "Organization members can access uploads" ON storage.objects
FOR ALL USING (
  bucket_id = 'user-uploads'
  AND EXISTS (
    SELECT 1 FROM user_organizations uo
    WHERE uo.user_id = auth.uid()
    AND uo.organization_id::text = (storage.foldername(name))[1]
  )
);

File Metadata Management#

Store file metadata in your database for better querying and organization.

sql
-- Create table for file metadata
CREATE TABLE file_uploads (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  filename TEXT NOT NULL,
  original_filename TEXT NOT NULL,
  file_size BIGINT NOT NULL,
  mime_type TEXT NOT NULL,
  storage_path TEXT NOT NULL,
  bucket_name TEXT NOT NULL,
  uploaded_by UUID REFERENCES users(id) ON DELETE CASCADE,
  organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  upload_completed_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  
  -- Add constraints for data integrity
  CONSTRAINT valid_file_size CHECK (file_size > 0 AND file_size <= 53687091200), -- 50GB
  CONSTRAINT valid_mime_type CHECK (mime_type ~ '^[a-z]+/[a-z0-9\-\+\.]+$')
);
 
-- Create indexes for common queries
CREATE INDEX idx_file_uploads_user ON file_uploads(uploaded_by);
CREATE INDEX idx_file_uploads_org ON file_uploads(organization_id);
CREATE INDEX idx_file_uploads_bucket ON file_uploads(bucket_name);
CREATE INDEX idx_file_uploads_completed ON file_uploads(upload_completed_at) 
  WHERE upload_completed_at IS NOT NULL;

Secure File Upload Implementation#

Client-Side Upload Component#

Build a robust upload component with progress tracking and error handling.

typescript
// components/FileUpload.tsx
'use client'
 
import { useState, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useAuth } from '@/hooks/useAuth'
 
interface FileUploadProps {
  bucket: string
  path?: string
  accept?: string
  maxSize?: number
  onUploadComplete?: (file: UploadedFile) => void
  onUploadError?: (error: string) => void
}
 
interface UploadedFile {
  id: string
  filename: string
  url: string
  size: number
}
 
export function FileUpload({
  bucket,
  path = '',
  accept = '*/*',
  maxSize = 10 * 1024 * 1024, // 10MB default
  onUploadComplete,
  onUploadError
}: FileUploadProps) {
  const [uploading, setUploading] = useState(false)
  const [progress, setProgress] = useState(0)
  const { user } = useAuth()
  const supabase = createClient()
 
  const validateFile = useCallback((file: File): string | null => {
    if (file.size > maxSize) {
      return `File size must be less than ${Math.round(maxSize / 1024 / 1024)}MB`
    }
 
    if (accept !== '*/*') {
      const acceptedTypes = accept.split(',').map(type => type.trim())
      const isValidType = acceptedTypes.some(type => {
        if (type.startsWith('.')) {
          return file.name.toLowerCase().endsWith(type.toLowerCase())
        }
        return file.type.match(type.replace('*', '.*'))
      })
 
      if (!isValidType) {
        return `File type not allowed. Accepted types: ${accept}`
      }
    }
 
    return null
  }, [accept, maxSize])
 
  const uploadFile = useCallback(async (file: File) => {
    if (!user) {
      onUploadError?.('User not authenticated')
      return
    }
 
    const validationError = validateFile(file)
    if (validationError) {
      onUploadError?.(validationError)
      return
    }
 
    setUploading(true)
    setProgress(0)
 
    try {
      // Generate unique filename
      const fileExt = file.name.split('.').pop()
      const fileName = `${user.id}/${Date.now()}-${Math.random().toString(36).substring(2)}.${fileExt}`
      const filePath = path ? `${path}/${fileName}` : fileName
 
      // Upload file with progress tracking
      const { data, error } = await supabase.storage
        .from(bucket)
        .upload(filePath, file, {
          cacheControl: '3600',
          upsert: false,
          onUploadProgress: (progress) => {
            setProgress((progress.loaded / progress.total) * 100)
          }
        })
 
      if (error) throw error
 
      // Save metadata to database
      const { data: fileRecord, error: dbError } = await supabase
        .from('file_uploads')
        .insert({
          filename: fileName,
          original_filename: file.name,
          file_size: file.size,
          mime_type: file.type,
          storage_path: data.path,
          bucket_name: bucket,
          uploaded_by: user.id,
          upload_completed_at: new Date().toISOString()
        })
        .select()
        .single()
 
      if (dbError) throw dbError
 
      // Get public URL
      const { data: { publicUrl } } = supabase.storage
        .from(bucket)
        .getPublicUrl(data.path)
 
      const uploadedFile: UploadedFile = {
        id: fileRecord.id,
        filename: file.name,
        url: publicUrl,
        size: file.size
      }
 
      onUploadComplete?.(uploadedFile)
    } catch (error) {
      console.error('Upload error:', error)
      onUploadError?.(error instanceof Error ? error.message : 'Upload failed')
    } finally {
      setUploading(false)
      setProgress(0)
    }
  }, [user, bucket, path, validateFile, onUploadComplete, onUploadError, supabase])
 
  return (
    <div className="file-upload">
      <input
        type="file"
        accept={accept}
        onChange={(e) => {
          const file = e.target.files?.[0]
          if (file) uploadFile(file)
        }}
        disabled={uploading}
        className="hidden"
        id="file-input"
      />
      
      <label
        htmlFor="file-input"
        className={`
          cursor-pointer border-2 border-dashed border-gray-300 
          rounded-lg p-6 text-center hover:border-gray-400
          ${uploading ? 'opacity-50 cursor-not-allowed' : ''}
        `}
      >
        {uploading ? (
          <div>
            <div className="mb-2">Uploading... {Math.round(progress)}%</div>
            <div className="w-full bg-gray-200 rounded-full h-2">
              <div 
                className="bg-blue-600 h-2 rounded-full transition-all duration-300"
                style={{ width: `${progress}%` }}
              />
            </div>
          </div>
        ) : (
          <div>
            <div className="text-gray-600 mb-2">Click to upload file</div>
            <div className="text-sm text-gray-400">
              Max size: {Math.round(maxSize / 1024 / 1024)}MB
            </div>
          </div>
        )}
      </label>
    </div>
  )
}

Server-Side Upload Validation#

Implement server-side validation and processing for uploaded files.

typescript
// app/api/files/upload/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextRequest } from 'next/server'
 
const ALLOWED_MIME_TYPES = {
  images: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
  documents: ['application/pdf', 'text/plain', 'application/msword'],
  videos: ['video/mp4', 'video/webm', 'video/quicktime']
}
 
const MAX_FILE_SIZES = {
  images: 10 * 1024 * 1024, // 10MB
  documents: 50 * 1024 * 1024, // 50MB
  videos: 500 * 1024 * 1024 // 500MB
}
 
export async function POST(request: NextRequest) {
  try {
    const supabase = createClient()
    
    // Verify authentication
    const { data: { user }, error: authError } = await supabase.auth.getUser()
    if (authError || !user) {
      return Response.json({ error: 'Unauthorized' }, { status: 401 })
    }
 
    const formData = await request.formData()
    const file = formData.get('file') as File
    const bucket = formData.get('bucket') as string
    const category = formData.get('category') as keyof typeof ALLOWED_MIME_TYPES
 
    if (!file || !bucket || !category) {
      return Response.json({ error: 'Missing required fields' }, { status: 400 })
    }
 
    // Validate file type
    if (!ALLOWED_MIME_TYPES[category]?.includes(file.type)) {
      return Response.json({ 
        error: `Invalid file type. Allowed: ${ALLOWED_MIME_TYPES[category]?.join(', ')}` 
      }, { status: 400 })
    }
 
    // Validate file size
    if (file.size > MAX_FILE_SIZES[category]) {
      return Response.json({ 
        error: `File too large. Max size: ${MAX_FILE_SIZES[category] / 1024 / 1024}MB` 
      }, { status: 400 })
    }
 
    // Generate secure filename
    const fileExt = file.name.split('.').pop()?.toLowerCase()
    const fileName = `${user.id}/${Date.now()}-${crypto.randomUUID()}.${fileExt}`
 
    // Upload to Supabase Storage
    const { data, error } = await supabase.storage
      .from(bucket)
      .upload(fileName, file, {
        cacheControl: '3600',
        upsert: false
      })
 
    if (error) {
      console.error('Storage upload error:', error)
      return Response.json({ error: 'Upload failed' }, { status: 500 })
    }
 
    // Save metadata
    const { data: fileRecord, error: dbError } = await supabase
      .from('file_uploads')
      .insert({
        filename: fileName,
        original_filename: file.name,
        file_size: file.size,
        mime_type: file.type,
        storage_path: data.path,
        bucket_name: bucket,
        uploaded_by: user.id,
        upload_completed_at: new Date().toISOString()
      })
      .select()
      .single()
 
    if (dbError) {
      console.error('Database error:', dbError)
      // Clean up uploaded file
      await supabase.storage.from(bucket).remove([data.path])
      return Response.json({ error: 'Failed to save file metadata' }, { status: 500 })
    }
 
    return Response.json({
      id: fileRecord.id,
      filename: file.name,
      path: data.path,
      size: file.size,
      type: file.type
    })
 
  } catch (error) {
    console.error('Upload API error:', error)
    return Response.json({ error: 'Internal server error' }, { status: 500 })
  }
}

Image Optimization and Processing#

Automatic Image Transformations#

Use Supabase Storage's image transformation API for on-the-fly resizing. The transform option on getPublicUrl() (and createSignedUrl()) supports width/height (1–2500px), quality (20–100, default 80), and resize ('cover' | 'contain' | 'fill', default 'cover'). There is no per-request format enum to pick webp/jpeg/png — Supabase negotiates the output format automatically from the browser's Accept header (serving WebP/AVIF where supported); the only documented format value is 'origin', which opts a request out of that automatic conversion and returns the file's original format instead.

typescript
// lib/image/transformations.ts
import { createClient } from '@/lib/supabase/client'
 
export class ImageTransformations {
  private supabase = createClient()
 
  getOptimizedImageUrl(
    bucket: string, 
    path: string, 
    options: {
      width?: number
      height?: number
      quality?: number
      resize?: 'cover' | 'contain' | 'fill'
    } = {}
  ): string {
    const { data } = this.supabase.storage
      .from(bucket)
      .getPublicUrl(path, {
        transform: {
          width: options.width,
          height: options.height,
          quality: options.quality || 80,
          resize: options.resize || 'cover'
          // No `format` here — Supabase auto-negotiates WebP/AVIF via
          // Accept headers. Pass format: 'origin' only if you need to force
          // the original file format instead of the negotiated one.
        }
      })
 
    return data.publicUrl
  }
 
  generateResponsiveImageUrls(bucket: string, path: string) {
    const sizes = [
      { width: 320, suffix: 'mobile' },
      { width: 768, suffix: 'tablet' },
      { width: 1024, suffix: 'desktop' },
      { width: 1920, suffix: 'large' }
    ]
 
    return sizes.map(size => ({
      url: this.getOptimizedImageUrl(bucket, path, { 
        width: size.width,
        quality: 80
      }),
      width: size.width,
      suffix: size.suffix
    }))
  }
 
  generateImageSrcSet(bucket: string, path: string, baseWidth: number) {
    const densities = [1, 1.5, 2, 3]
    
    return densities
      .map(density => {
        const width = Math.round(baseWidth * density)
        const url = this.getOptimizedImageUrl(bucket, path, { 
          width,
          quality: density > 2 ? 70 : 80
        })
        return `${url} ${density}x`
      })
      .join(', ')
  }
}
 
// Usage in components
// components/OptimizedImage.tsx
'use client'
 
import Image from 'next/image'
import { ImageTransformations } from '@/lib/image/transformations'
 
interface OptimizedImageProps {
  bucket: string
  path: string
  alt: string
  width: number
  height: number
  className?: string
  priority?: boolean
}
 
export function OptimizedImage({
  bucket,
  path,
  alt,
  width,
  height,
  className,
  priority = false
}: OptimizedImageProps) {
  const imageTransforms = new ImageTransformations()
 
  const src = imageTransforms.getOptimizedImageUrl(bucket, path, {
    width,
    height,
    quality: 80
  })
 
  // next/image does not accept a `srcSet` prop directly — it generates its
  // own from `sizes` + the configured deviceSizes/imageSizes in
  // next.config.mjs. Pass the raw src and let next/image handle responsive
  // variants; use generateImageSrcSet() only if you're building a plain <img>
  // outside next/image.
  return (
    <Image
      src={src}
      alt={alt}
      width={width}
      height={height}
      className={className}
      priority={priority}
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
    />
  )
}

Image Processing Pipeline#

Implement server-side image processing for advanced transformations.

typescript
// lib/image/processor.ts
import sharp from 'sharp'
import { createClient } from '@/lib/supabase/server'
 
export class ImageProcessor {
  private supabase = createClient()
 
  async processAndUpload(
    file: File,
    bucket: string,
    path: string,
    options: {
      maxWidth?: number
      maxHeight?: number
      quality?: number
      generateThumbnail?: boolean
      watermark?: boolean
    } = {}
  ) {
    const buffer = Buffer.from(await file.arrayBuffer())
    
    // Process main image
    const processedImage = await this.processImage(buffer, {
      maxWidth: options.maxWidth || 1920,
      maxHeight: options.maxHeight || 1080,
      quality: options.quality || 80,
      watermark: options.watermark
    })
 
    // Upload main image
    const { data: mainUpload, error: mainError } = await this.supabase.storage
      .from(bucket)
      .upload(path, processedImage, {
        contentType: 'image/webp',
        cacheControl: '31536000' // 1 year
      })
 
    if (mainError) throw mainError
 
    const results = { main: mainUpload }
 
    // Generate and upload thumbnail if requested
    if (options.generateThumbnail) {
      const thumbnail = await this.generateThumbnail(buffer)
      const thumbnailPath = path.replace(/\.[^.]+$/, '_thumb.webp')
      
      const { data: thumbUpload, error: thumbError } = await this.supabase.storage
        .from(bucket)
        .upload(thumbnailPath, thumbnail, {
          contentType: 'image/webp',
          cacheControl: '31536000'
        })
 
      if (thumbError) throw thumbError
      results.thumbnail = thumbUpload
    }
 
    return results
  }
 
  private async processImage(
    buffer: Buffer,
    options: {
      maxWidth: number
      maxHeight: number
      quality: number
      watermark?: boolean
    }
  ): Promise<Buffer> {
    let pipeline = sharp(buffer)
      .resize(options.maxWidth, options.maxHeight, {
        fit: 'inside',
        withoutEnlargement: true
      })
      .webp({ quality: options.quality })
 
    // Add watermark if requested
    if (options.watermark) {
      const watermarkBuffer = await this.createWatermark()
      pipeline = pipeline.composite([{
        input: watermarkBuffer,
        gravity: 'southeast'
      }])
    }
 
    return pipeline.toBuffer()
  }
 
  private async generateThumbnail(buffer: Buffer): Promise<Buffer> {
    return sharp(buffer)
      .resize(300, 300, {
        fit: 'cover',
        position: 'center'
      })
      .webp({ quality: 70 })
      .toBuffer()
  }
 
  private async createWatermark(): Promise<Buffer> {
    return sharp({
      create: {
        width: 200,
        height: 50,
        channels: 4,
        background: { r: 255, g: 255, b: 255, alpha: 0.5 }
      }
    })
    .png()
    .toBuffer()
  }
}

Advanced File Management#

Resumable Uploads for Large Files#

Supabase Storage does not expose multipart uploads through custom RPC calls — there is no initialize_multipart_upload, upload_part, or complete_multipart_upload function in the Supabase API. For files that need chunking and resume support, Supabase Storage implements the TUS resumable upload protocol via a dedicated endpoint (https://<project-id>.storage.supabase.co/storage/v1/upload/resumable). The supported client is tus-js-client, and Supabase requires the chunk size to stay at exactly 6MB.

typescript
// lib/upload/resumable-uploader.ts
import * as tus from 'tus-js-client'
import { createClient } from '@/lib/supabase/client'
 
export async function uploadLargeFile(
  file: File,
  bucket: string,
  path: string,
  projectId: string,
  onProgress?: (progress: number) => void
): Promise<string> {
  const supabase = createClient()
  const { data: { session } } = await supabase.auth.getSession()
  if (!session) throw new Error('Not authenticated')
 
  return new Promise((resolve, reject) => {
    const upload = new tus.Upload(file, {
      endpoint: `https://${projectId}.storage.supabase.co/storage/v1/upload/resumable`,
      retryDelays: [0, 3000, 5000, 10000, 20000],
      headers: {
        authorization: `Bearer ${session.access_token}`,
        'x-upsert': 'true', // overwrite if the object already exists
      },
      uploadDataDuringCreation: true,
      removeFingerprintOnSuccess: true,
      metadata: {
        bucketName: bucket,
        objectName: path,
        contentType: file.type || 'application/octet-stream',
        cacheControl: '3600',
      },
      chunkSize: 6 * 1024 * 1024, // must be 6MB — Supabase does not allow other sizes
      onError: (error) => reject(error),
      onProgress: (bytesUploaded, bytesTotal) => {
        onProgress?.((bytesUploaded / bytesTotal) * 100)
      },
      onSuccess: () => resolve(path),
    })
 
    // Resume a previous, interrupted upload if one exists for this file
    upload.findPreviousUploads().then((previousUploads) => {
      if (previousUploads.length > 0) {
        upload.resumeFromPreviousUpload(previousUploads[0])
      }
      upload.start()
    })
  })
}

For files under Supabase's 5GB standard-upload limit, the plain .upload() call shown earlier (with its onUploadProgress callback) is simpler and sufficient — reach for resumable/TUS uploads specifically when a file is large enough that a network blip mid-transfer would otherwise force a full restart.

File Cleanup and Lifecycle Management#

Implement automatic cleanup of unused files and lifecycle management.

typescript
// lib/storage/cleanup.ts
export class StorageCleanup {
  private supabase = createClient()
 
  async cleanupOrphanedFiles(bucket: string, olderThanDays = 7) {
    // Find files not referenced in database
    const { data: orphanedFiles } = await this.supabase
      .rpc('find_orphaned_files', {
        bucket_name: bucket,
        older_than_days: olderThanDays
      })
 
    if (!orphanedFiles?.length) return { deleted: 0 }
 
    // Delete orphaned files in batches
    const batchSize = 100
    let totalDeleted = 0
 
    for (let i = 0; i < orphanedFiles.length; i += batchSize) {
      const batch = orphanedFiles.slice(i, i + batchSize)
      const paths = batch.map(file => file.name)
 
      const { error } = await this.supabase.storage
        .from(bucket)
        .remove(paths)
 
      if (!error) {
        totalDeleted += batch.length
      }
    }
 
    return { deleted: totalDeleted }
  }
 
  async archiveOldFiles(bucket: string, archiveBucket: string, olderThanDays = 365) {
    const { data: oldFiles } = await this.supabase
      .rpc('find_old_files', {
        bucket_name: bucket,
        older_than_days: olderThanDays
      })
 
    if (!oldFiles?.length) return { archived: 0 }
 
    let totalArchived = 0
 
    for (const file of oldFiles) {
      try {
        // Copy to archive bucket — call .from() with the SOURCE bucket, and
        // pass the target bucket via the destinationBucket option (files up
        // to 5GB only; see supabase.com/docs/guides/storage/management/copy-move-objects)
        const { error: copyError } = await this.supabase.storage
          .from(bucket)
          .copy(file.name, file.name, { destinationBucket: archiveBucket })
 
        if (copyError) continue
 
        // Delete from original bucket
        const { error: deleteError } = await this.supabase.storage
          .from(bucket)
          .remove([file.name])
 
        if (!deleteError) {
          totalArchived++
        }
      } catch (error) {
        console.error(`Failed to archive file ${file.name}:`, error)
      }
    }
 
    return { archived: totalArchived }
  }
}
 
// Database functions for cleanup operations
sql
-- Function to find orphaned files
CREATE OR REPLACE FUNCTION find_orphaned_files(
  bucket_name TEXT,
  older_than_days INTEGER DEFAULT 7
)
RETURNS TABLE (name TEXT, created_at TIMESTAMPTZ) AS $$
BEGIN
  RETURN QUERY
  SELECT o.name, o.created_at
  FROM storage.objects o
  WHERE o.bucket_id = bucket_name
    AND o.created_at < NOW() - INTERVAL '1 day' * older_than_days
    AND NOT EXISTS (
      SELECT 1 FROM file_uploads f 
      WHERE f.storage_path = o.name
    );
END;
$$ LANGUAGE plpgsql;
 
-- Function to find old files for archiving
CREATE OR REPLACE FUNCTION find_old_files(
  bucket_name TEXT,
  older_than_days INTEGER DEFAULT 365
)
RETURNS TABLE (name TEXT, created_at TIMESTAMPTZ, size BIGINT) AS $$
BEGIN
  RETURN QUERY
  SELECT o.name, o.created_at, o.metadata->>'size'::BIGINT
  FROM storage.objects o
  WHERE o.bucket_id = bucket_name
    AND o.created_at < NOW() - INTERVAL '1 day' * older_than_days;
END;
$$ LANGUAGE plpgsql;

This comprehensive guide provides everything you need to implement robust file storage and media handling with Supabase and Next.js. From secure uploads to advanced image processing, these patterns will help you build scalable, efficient file management systems that provide excellent user experiences while maintaining security and performance.

Remember to monitor storage usage, implement proper cleanup procedures, and optimize images for web delivery to keep costs manageable and performance optimal.

CDN Integration and Performance#

Implementing CDN Caching#

Optimize file delivery with proper CDN configuration and caching strategies.

Cache duration is not a URL query parameter Supabase reads — it's set once, at upload time, via the cacheControl option passed to .upload() (already shown earlier: cacheControl: '3600'), which controls the Cache-Control HTTP header the storage CDN serves for that object. To build transformed URLs, use the storage client's own getPublicUrl({ transform }) — it targets the correct /render/image/public/ endpoint for you, instead of hand-building query strings against the plain /object/public/ endpoint (which does not accept transform parameters at all):

typescript
// lib/cdn/configuration.ts
import { createClient } from '@/lib/supabase/client'
 
export class CDNManager {
  private supabase = createClient()
 
  getCDNUrl(bucket: string, path: string, options: {
    transform?: {
      width?: number
      height?: number
      quality?: number
      resize?: 'cover' | 'contain' | 'fill'
    }
  } = {}): string {
    const { data } = this.supabase.storage
      .from(bucket)
      .getPublicUrl(path, options.transform ? { transform: options.transform } : undefined)
 
    return data.publicUrl
  }
 
  async preloadCriticalImages(images: Array<{ bucket: string, path: string }>) {
    // Preload critical images for better performance
    const preloadPromises = images.map(({ bucket, path }) => {
      const url = this.getCDNUrl(bucket, path, {
        transform: { quality: 80 }
      })
      
      return new Promise((resolve, reject) => {
        const img = new Image()
        img.onload = resolve
        img.onerror = reject
        img.src = url
      })
    })
 
    try {
      await Promise.all(preloadPromises)
    } catch (error) {
      console.warn('Some images failed to preload:', error)
    }
  }
 
  generateImageSizes(bucket: string, path: string) {
    const breakpoints = [320, 640, 768, 1024, 1280, 1920]
    
    return breakpoints.map(width => ({
      width,
      url: this.getCDNUrl(bucket, path, {
        transform: { width, quality: 80 }
      })
    }))
  }
}

Performance Monitoring#

Monitor file delivery performance and optimize based on metrics.

typescript
// lib/monitoring/storage-metrics.ts
export class StorageMetrics {
  private supabase = createClient()
 
  async trackFileAccess(fileId: string, accessType: 'view' | 'download') {
    await this.supabase
      .from('file_access_logs')
      .insert({
        file_id: fileId,
        access_type: accessType,
        user_agent: navigator.userAgent,
        timestamp: new Date().toISOString()
      })
  }
 
  async getStorageUsageStats(organizationId: string) {
    const { data, error } = await this.supabase
      .rpc('get_storage_usage_stats', {
        org_id: organizationId
      })
 
    if (error) throw error
    return data
  }
 
  async getPopularFiles(organizationId: string, days = 30) {
    const { data, error } = await this.supabase
      .rpc('get_popular_files', {
        org_id: organizationId,
        days_back: days
      })
 
    if (error) throw error
    return data
  }
}
sql
-- Storage usage analytics functions
CREATE OR REPLACE FUNCTION get_storage_usage_stats(org_id UUID)
RETURNS TABLE (
  total_files BIGINT,
  total_size BIGINT,
  avg_file_size NUMERIC,
  files_by_type JSONB
) AS $$
BEGIN
  RETURN QUERY
  SELECT 
    COUNT(*) as total_files,
    SUM(file_size) as total_size,
    AVG(file_size) as avg_file_size,
    jsonb_object_agg(
      SPLIT_PART(mime_type, '/', 1),
      file_count
    ) as files_by_type
  FROM (
    SELECT 
      mime_type,
      file_size,
      COUNT(*) OVER (PARTITION BY SPLIT_PART(mime_type, '/', 1)) as file_count
    FROM file_uploads
    WHERE organization_id = org_id
      AND upload_completed_at IS NOT NULL
  ) stats;
END;
$$ LANGUAGE plpgsql;
 
CREATE OR REPLACE FUNCTION get_popular_files(
  org_id UUID,
  days_back INTEGER DEFAULT 30
)
RETURNS TABLE (
  file_id UUID,
  filename TEXT,
  access_count BIGINT,
  last_accessed TIMESTAMPTZ
) AS $$
BEGIN
  RETURN QUERY
  SELECT 
    f.id,
    f.original_filename,
    COUNT(al.id) as access_count,
    MAX(al.timestamp) as last_accessed
  FROM file_uploads f
  LEFT JOIN file_access_logs al ON f.id = al.file_id
  WHERE f.organization_id = org_id
    AND (al.timestamp IS NULL OR al.timestamp > NOW() - INTERVAL '1 day' * days_back)
  GROUP BY f.id, f.original_filename
  ORDER BY access_count DESC, last_accessed DESC
  LIMIT 50;
END;
$$ LANGUAGE plpgsql;

This comprehensive file storage and media handling guide provides production-ready patterns for building scalable, secure, and performant file management systems with Next.js and Supabase. The techniques covered here will help you handle everything from simple image uploads to complex media processing workflows while maintaining optimal performance and user experience.

Further reading: Next.js + Supabase: 10 Production Mistakes + Fixes (2026), Supabase Edge Functions in Next.js: Setup to Cron (2026).

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.