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.
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.
-- 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.
-- 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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-- 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):
// 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.
// 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
}
}-- 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
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
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.
Related Guides
Next.js + Supabase Background Jobs & Async Patterns
Background jobs and async patterns for Next.js + Supabase: database queues, pg_cron, and Edge Functions — no external services.
Supabase Edge Functions in Next.js: Setup to Cron (2026)
From supabase functions new to production: Deno runtime gotchas, built-in SUPABASE_URL and SERVICE_ROLE_KEY env vars, webhook triggers and cron jobs.
Supabase Multi-Tenant Architecture: Best Practices 2026
One codebase, isolated tenants: RLS policies, subdomain routing, and billing for a multi-tenant SaaS on Next.js + Supabase — with schema and code.

