🎸
🚀 Beta läuft
PYNGUP: Rebellion gegen toxische Produktivität
Beta auf 100 Plätze begrenzt. Tasks werden zu sozialen Commitments statt einsamer To-Dos.
File Uploads gehören zu den häufigsten Features in modernen Web-Apps, aber die korrekte Implementierung mit Supabase Storage kann trickreich sein. Viele Entwickler kämpfen mit Permission-Fehlern, langsamen Uploads oder unsicheren Konfigurationen.
In diesem Artikel zeige ich dir Schritt für Schritt, wie du File Uploads mit Supabase Storage sicher und performant implementierst - von der Bucket-Konfiguration bis zur Production-reifen Frontend-Integration.
Supabase Storage ist ein S3-kompatibles Object Storage System, das nahtlos in dein Supabase-Projekt integriert ist. Es bietet:
Gehe zu deinem Supabase Dashboard → Storage und erstelle einen neuen Bucket:
-- Option 1: Über das Dashboard (empfohlen für Anfänger)
-- Storage > Create bucket > "uploads" > Public/Private auswählen
-- Option 2: Per SQL
INSERT INTO storage.buckets (id, name, public)
VALUES ('uploads', 'uploads', false);
Wichtig: Setze public nur auf true, wenn alle Dateien öffentlich zugänglich sein sollen (z.B. für Produktbilder).
Das ist der kritische Teil! Ohne korrekte RLS Policies funktioniert der Upload nicht:
-- Policy für Upload (INSERT)
CREATE POLICY "Authenticated users can upload files"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- Policy für eigene Dateien lesen (SELECT)
CREATE POLICY "Users can view own files"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- Policy für eigene Dateien löschen (DELETE)
CREATE POLICY "Users can delete own files"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
-- Policy für eigene Dateien aktualisieren (UPDATE)
CREATE POLICY "Users can update own files"
ON storage.objects
FOR UPDATE
TO authenticated
USING (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
)
WITH CHECK (
bucket_id = 'uploads' AND
(storage.foldername(name))[1] = auth.uid()::text
);
Erklärung: Diese Policies erlauben es Benutzern nur, Dateien in ihren eigenen Ordnern zu verwalten (Ordnername = User ID).
Hier ist eine vollständige, production-ready Upload-Komponente:
// components/FileUpload.tsx
import { useState, useRef } from 'react'
import { createClient } from '@supabase/supabase-js'
import { useUser } from '@/hooks/useUser' // dein Auth Hook
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
interface FileUploadProps {
onUploadComplete: (url: string) => void
allowedTypes?: string[]
maxSize?: number // in MB
bucket?: string
}
export default function FileUpload({
onUploadComplete,
allowedTypes = ['image/jpeg', 'image/png', 'image/webp'],
maxSize = 5,
bucket = 'uploads'
}: FileUploadProps) {
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const { user } = useUser()
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file || !user) return
setError(null)
// Validierung
if (!allowedTypes.includes(file.type)) {
setError(`Dateityp nicht erlaubt. Erlaubt: ${allowedTypes.join(', ')}`)
return
}
if (file.size > maxSize * 1024 * 1024) {
setError(`Datei zu groß. Maximum: ${maxSize}MB`)
return
}
await uploadFile(file)
}
const uploadFile = async (file: File) => {
setUploading(true)
setUploadProgress(0)
try {
// Eindeutigen Dateinamen generieren
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2)}.${fileExt}`
const filePath = `${user.id}/${fileName}`
// Upload mit Progress Tracking
const { data, error } = await supabase.storage
.from(bucket)
.upload(filePath, file, {
cacheControl: '3600',
upsert: false
})
if (error) {
throw error
}
// Public URL generieren
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(filePath)
onUploadComplete(publicUrl)
// Input zurücksetzen
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
} catch (error: any) {
console.error('Upload error:', error)
setError(error.message || 'Upload fehlgeschlagen')
} finally {
setUploading(false)
setUploadProgress(0)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-center w-full">
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<svg className="w-8 h-8 mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="mb-2 text-sm text-gray-500">
<span className="font-semibold">Klicken zum Upload</span> oder Drag & Drop
</p>
<p className="text-xs text-gray-500">
{allowedTypes.join(', ')} (max. {maxSize}MB)
</p>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileSelect}
accept={allowedTypes.join(',')}
disabled={uploading || !user}
/>
</label>
</div>
{uploading && (
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
)}
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md">
{error}
</div>
)}
{uploading && (
<p className="text-sm text-gray-600 text-center">
Uploading... {uploadProgress}%
</p>
)}
</div>
)
}
// pages/profile.tsx
import FileUpload from '@/components/FileUpload'
export default function ProfilePage() {
const handleAvatarUpload = (url: string) => {
console.log('Avatar uploaded:', url)
// Update user profile mit neuer Avatar URL
updateUserProfile({ avatar_url: url })
}
return (
<div>
<h1>Profil bearbeiten</h1>
<FileUpload
onUploadComplete={handleAvatarUpload}
allowedTypes={['image/jpeg', 'image/png']}
maxSize={2}
bucket="avatars"
/>
</div>
)
}
Für echtes Progress Tracking musst du den Upload chunken:
const uploadFileWithProgress = async (file: File) => {
const chunkSize = 1024 * 1024 // 1MB chunks
const totalChunks = Math.ceil(file.size / chunkSize)
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
// Upload chunk...
const progress = ((chunkIndex + 1) / totalChunks) * 100
setUploadProgress(progress)
}
}
const resizeImage = (file: File, maxWidth: number, maxHeight: number): Promise<File> => {
return new Promise((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
const img = new Image()
img.onload = () => {
// Aspect Ratio berechnen
const ratio = Math.min(maxWidth / img.width, maxHeight / img.height)
canvas.width = img.width * ratio
canvas.height = img.height * ratio
// Image auf Canvas zeichnen
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
// Zurück zu File konvertieren
canvas.toBlob((blob) => {
if (blob) {
const resizedFile = new File([blob], file.name, {
type: file.type,
lastModified: Date.now()
})
resolve(resizedFile)
}
}, file.type, 0.9)
}
img.src = URL.createObjectURL(file)
})
}
// Verwendung:
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
// Bild vor Upload resizen
const resizedFile = await resizeImage(file, 1920, 1080)
await uploadFile(resizedFile)
}
const uploadMultipleFiles = async (files: FileList) => {
const uploadPromises = Array.from(files).map(async (file, index) => {
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}-${index}.${fileExt}`
const filePath = `${user.id}/${fileName}`
return supabase.storage
.from('uploads')
.upload(filePath, file)
})
try {
const results = await Promise.all(uploadPromises)
console.log('All files uploaded:', results)
} catch (error) {
console.error('Some uploads failed:', error)
}
}
Für sensible Uploads oder Server-Side Verarbeitung:
// pages/api/upload.ts
import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs'
import { NextApiRequest, NextApiResponse } from 'next'
import formidable from 'formidable'
import fs from 'fs'
export const config = {
api: {
bodyParser: false, // Disable body parsing for file uploads
},
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
const supabase = createServerSupabaseClient({ req, res })
// Check authentication
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (!user) {
return res.status(401).json({ error: 'Unauthorized' })
}
try {
const form = formidable({
maxFileSize: 10 * 1024 * 1024, // 10MB
keepExtensions: true,
})
const [fields, files] = await form.parse(req)
const file = Array.isArray(files.file) ? files.file[0] : files.file
if (!file) {
return res.status(400).json({ error: 'No file provided' })
}
// Read file
const fileBuffer = fs.readFileSync(file.filepath)
// Generate unique filename
const fileExt = file.originalFilename?.split('.').pop()
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2)}.${fileExt}`
const filePath = `${user.id}/${fileName}`
// Upload to Supabase
const { data, error } = await supabase.storage
.from('uploads')
.upload(filePath, fileBuffer, {
contentType: file.mimetype || 'application/octet-stream',
cacheControl: '3600'
})
if (error) {
throw error
}
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('uploads')
.getPublicUrl(filePath)
// Clean up temp file
fs.unlinkSync(file.filepath)
res.status(200).json({
message: 'Upload successful',
url: publicUrl,
path: data.path
})
} catch (error: any) {
console.error('Upload error:', error)
res.status(500).json({ error: error.message || 'Upload failed' })
}
}
const getUserFiles = async () => {
const { data, error } = await supabase.storage
.from('uploads')
.list(user.id, {
limit: 100,
offset: 0,
sortBy: { column: 'created_at', order: 'desc' }
})
if (error) {
console.error('Error listing files:', error)
return []
}
return data
}
const deleteFile = async (filePath: string) => {
const { error } = await supabase.storage
.from('uploads')
.remove([filePath])
if (error) {
console.error('Error deleting file:', error)
return false
}
return true
}
Supabase bietet automatische Image-Transformationen:
// Verschiedene Größen generieren
const getImageUrl = (path: string, options?: {
width?: number
height?: number
quality?: number
format?: 'webp' | 'jpeg' | 'png'
}) => {
const { data } = supabase.storage
.from('uploads')
.getPublicUrl(path, {
transform: {
width: options?.width,
height: options?.height,
quality: options?.quality,
format: options?.format
}
})
return data.publicUrl
}
// Usage:
const thumbnailUrl = getImageUrl('user123/image.jpg', {
width: 200,
height: 200,
quality: 80,
format: 'webp'
})
Lösung: Überprüfe deine RLS Policies. Der User muss authentifiziert sein und die Policy muss den Upload erlauben:
-- Debug: Prüfe ob User authentifiziert ist
SELECT auth.uid(), auth.role();
-- Debug: Prüfe Bucket-Konfiguration
SELECT * FROM storage.buckets WHERE id = 'uploads';
Lösung: Überprüfe die PUBLIC URL und CORS-Einstellungen:
// Korrekte URL generieren
const { data: { publicUrl }, error } = supabase.storage
.from('uploads')
.getPublicUrl(filePath)
if (error) {
console.error('Error getting public URL:', error)
}
Lösungen:
const ALLOWED_FILE_TYPES = {
images: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
documents: ['application/pdf', 'application/msword', 'text/plain'],
videos: ['video/mp4', 'video/webm', 'video/ogg']
}
const validateFileType = (file: File, category: keyof typeof ALLOWED_FILE_TYPES) => {
return ALLOWED_FILE_TYPES[category].includes(file.type)
}
const MAX_FILE_SIZES = {
image: 5 * 1024 * 1024, // 5MB
document: 10 * 1024 * 1024, // 10MB
video: 100 * 1024 * 1024 // 100MB
}
Für Production-Apps solltest du einen Malware-Scanner integrieren:
// Example mit ClamAV
const scanFile = async (fileBuffer: Buffer) => {
// Integration mit Malware-Scanner
// return scanResult
}
const FileList = () => {
const [files, setFiles] = useState([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const loadMoreFiles = async () => {
if (loading || !hasMore) return
setLoading(true)
const newFiles = await getUserFiles(files.length)
if (newFiles.length === 0) {
setHasMore(false)
} else {
setFiles(prev => [...prev, ...newFiles])
}
setLoading(false)
}
return (
<div>
{files.map(file => (
<FileItem key={file.id} file={file} />
))}
{hasMore && (
<button onClick={loadMoreFiles} disabled={loading}>
{loading ? 'Loading...' : 'Load More'}
</button>
)}
</div>
)
}
// Service Worker für aggressive Caching
const cacheImages = async (urls: string[]) => {
const cache = await caches.open('supabase-images-v1')
await cache.addAll(urls)
}
Supabase Storage bietet eine mächtige und flexible Lösung für File Uploads. Die wichtigsten Punkte:
Mit diesen Implementierungen hast du eine production-ready File Upload Lösung, die sicher, performant und benutzerfreundlich ist.
Hast du Probleme mit deinem File Upload? Schreib einen Kommentar und ich helfe gerne weiter!
Nikolai Fischer ist Gründer von Kommune3 (seit 2007) und führender Experte für die Verbindung von Software-Entwicklung und Unternehmertum. Mit 17+ Jahren Erfahrung hat er hunderte von Projekten geleitet und erreichte #1 auf Hacker News. Als Host des Podcasts "Kommit mich" und Gründer von skillution verbindet er technische Expertise mit unternehmerischem Denken. Seine Artikel über moderne Webentwicklung und systematisches Problem-Solving haben tausende von Entwicklern beeinflusst.
Folge Niko auf:
Comments