Supabase Storage File Upload: Sicher implementieren

12. Juni 2025 ca. 41 Min. Lesezeit Supabase
Inhalt 65

Zuletzt aktualisiert: August 2026

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 Bucket-Konfiguration und RLS-Policies über Upload und Download bis zur Production-reifen Frontend-Integration.

Was ist Supabase Storage?

Supabase Storage ist ein S3-kompatibles Object Storage System, das nahtlos in dein Supabase-Projekt integriert ist. Es bietet:

  • Row Level Security für sichere File-Zugriffe
  • Automatische Image-Optimierung und Transformation
  • CDN-Integration für schnelle Auslieferung
  • Resumable Uploads für große Dateien
  • Webhook-Integration für automatische Verarbeitung

Schritt 1: Storage Bucket einrichten

Bucket erstellen

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).

RLS Policies konfigurieren

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).

Derselbe Fehlertext – new row violates row-level security policy – taucht auch bei Tabellen-Inserts auf. Das ist eine andere Schicht (PostgREST SELECT nach INSERT). Fix: new row violates row-level security policy. Storage-Uploads brauchen INSERT und SELECT auf storage.objects – siehe Storage 403 RLS beim Upload.

Bucket Policies: Size-Limits, MIME-Types und Zugriff

RLS auf storage.objects entscheidet, wer eine Datei lesen oder schreiben darf. Bucket-Settings entscheiden, was der Bucket akzeptiert. Verwechselst du das, siehst du 413 / 415 und denkst, es sei ein Permission-Problem.

Bucket per SQL anlegen oder updaten

So richte ich einen privaten User-Upload-Bucket ein:

-- Privater Bucket, max. 10 MB, nur Bilder + PDF
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
  'uploads',
  'uploads',
  false,
  10485760,
  ARRAY['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
)
ON CONFLICT (id) DO UPDATE SET
  public = EXCLUDED.public,
  file_size_limit = EXCLUDED.file_size_limit,
  allowed_mime_types = EXCLUDED.allowed_mime_types;

-- Bestehenden öffentlichen Avatar-Bucket enger ziehen
UPDATE storage.buckets
SET
  public = true,
  file_size_limit = 2 * 1024 * 1024,
  allowed_mime_types = ARRAY['image/jpeg', 'image/png', 'image/webp']
WHERE id = 'avatars';

Im Dashboard: Storage → Bucket → Configuration (Public-Toggle, Max File Size, Allowed MIME Types). SQL und Dashboard schreiben dieselbe Zeile in storage.buckets.

Welche Policies brauchst du wirklich?

Operation SQL-Command Wann du sie brauchst
Upload INSERT Immer – sonst 403 bei .upload()
Lesen / listen / Public URL SELECT Immer – Storage nutzt RETURNING *; Listen und Signed URLs machen ebenfalls SELECT
Überschreiben (upsert: true) UPDATE Avatare, Replace-in-Place-Pfade
Löschen DELETE User darf eigene Dateien entfernen

Public Bucket ≠ keine RLS. public = true überspringt nur den Signed-URL-Schritt beim Lesen. Uploads brauchen trotzdem INSERT (+ SELECT), außer du erlaubst bewusst die Rolle anon – bei User-Content solltest du das nicht tun.

Schritt 2: Frontend Implementation

Der Upload-Call (JavaScript)

Das ist die API, nach der die Leute suchen. Du brauchst einen eingeloggten User, wenn deine Policies authenticated + auth.uid() nutzen:

const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not signed in')

const path = `${user.id}/${file.name}`

const { data, error } = await supabase.storage
  .from('uploads')
  .upload(path, file, {
    cacheControl: '3600',
    upsert: false,
    contentType: file.type,
  })

if (error) {
  console.error(error.message, error.statusCode)
  throw error
}

console.log('Stored at', data.path)

React/Next.js Upload Component

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>
  )
}

Usage Beispiel

// 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>
  )
}

Häufige Upload-Szenarien (Copy-Paste)

Das sind die Patterns, die ich in Production am häufigsten brauche. Nimm das, was zu deinem Use Case passt.

1. Avatar-Upload (Überschreiben erlaubt)

Ein fester Pfad pro User – überschreiben mit upsert: true:

const path = `${user.id}/avatar.jpg`

const { data, error } = await supabase.storage
  .from('avatars')
  .upload(path, file, {
    upsert: true,
    contentType: file.type,
    cacheControl: '3600',
  })

if (error) throw error

// CDN-Cache nach Überschreiben bustens
const { data: { publicUrl } } = supabase.storage
  .from('avatars')
  .getPublicUrl(path)

const avatarUrl = `${publicUrl}?t=${Date.now()}`

Wichtig: Upsert braucht zusätzlich eine UPDATE-Policy zu INSERT + SELECT. Siehe auch Storage 403 RLS beim Upload.

2. Dokument- / PDF-Upload (Private Bucket + Signed URL)

const path = `${user.id}/docs/${crypto.randomUUID()}.pdf`

const { error } = await supabase.storage
  .from('documents')
  .upload(path, file, {
    contentType: 'application/pdf',
    upsert: false,
  })

if (error) throw error

// Private Bucket: getPublicUrl nicht für Zugriffskontrolle nutzen
const { data, error: signError } = await supabase.storage
  .from('documents')
  .createSignedUrl(path, 60 * 60) // 1 Stunde

if (signError) throw signError
console.log('Temporäre Download-URL:', data.signedUrl)

3. Mehrere Bilder parallel hochladen

async function uploadImages(files: File[], userId: string) {
  const results = await Promise.allSettled(
    files.map(async (file, index) => {
      const ext = file.name.split('.').pop()
      const path = `${userId}/gallery/${Date.now()}-${index}.${ext}`
      const { data, error } = await supabase.storage
        .from('uploads')
        .upload(path, file, { contentType: file.type })
      if (error) throw error
      return data.path
    })
  )

  const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value)
  const failed = results.filter(r => r.status === 'rejected')
  return { ok, failed }
}

4. Große Dateien mit Resumable Upload (> 6MB)

Standard-.upload() funktioniert bis zu mehreren GB, aber für Zuverlässigkeit ab ~6MB ist die resumable (TUS) API sinnvoll:

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(url, anonKey)

async function uploadLargeFile(file: File, userId: string) {
  const path = `${userId}/videos/${file.name}`

  const { data, error } = await supabase.storage
    .from('uploads')
    .upload(path, file, {
      upsert: false,
      // supabase-js nutzt bei größeren Dateien automatisch resumable Uploads,
      // wenn die Runtime das unterstützt – sonst tus-js-client
    })

  if (error) throw error
  return data.path
}

Für eine Progress-UI bei sehr großen Dateien lohnt sich tus-js-client gegen den TUS-Endpoint (/storage/v1/upload/resumable) mit dem Access-Token des Users.

Public URLs vs Signed URLs

getPublicUrl() gibt immer einen String zurück – auch bei einem privaten Bucket. Das heißt nicht, dass die Datei erreichbar ist. Public URL nur bei einem öffentlichen Bucket; Signed URL, wenn der Bucket privat ist.

Use Case Bucket API
Produktbilder, Avatare, alles was auf ein CDN gehört public = true getPublicUrl(path)
Rechnungen, Ausweise, Chat-Anhänge, alles User-privat public = false createSignedUrl(path, expiresIn)

Public URL nach dem Upload

const { data, error } = await supabase.storage
  .from('avatars')
  .upload(`${user.id}/avatar.jpg`, file, { upsert: true, contentType: file.type })

if (error) throw error

const { data: { publicUrl } } = supabase.storage
  .from('avatars')
  .getPublicUrl(`${user.id}/avatar.jpg`)

// Optional: CDN-Cache nach Überschreiben busten
const avatarUrl = `${publicUrl}?t=${Date.now()}`

Signed URL für eine private Datei

const path = `${user.id}/docs/${file.name}`

await supabase.storage.from('documents').upload(path, file, {
  contentType: file.type,
  upsert: false,
})

// expiresIn in Sekunden. Der Caller muss SELECT-RLS erfüllen (meist der Owner).
const { data, error } = await supabase.storage
  .from('documents')
  .createSignedUrl(path, 60 * 60) // 1 Stunde

if (error) throw error
console.log(data.signedUrl)

// Download-Dateiname erzwingen statt Inline-Preview
const { data: dl } = await supabase.storage
  .from('documents')
  .createSignedUrl(path, 300, { download: file.name })

Mehrere Signed URLs auf einmal

const { data, error } = await supabase.storage
  .from('documents')
  .createSignedUrls(
    ['user-id/a.pdf', 'user-id/b.pdf'],
    60 * 15
  )

if (error) throw error
data.forEach((row) => {
  if (row.error) console.error(row.error)
  else console.log(row.signedUrl)
})

Liefert createSignedUrl 403, darf die Session das Object nicht SELECTen – dieselbe Ursache wie ein fehlgeschlagener Upload. Storage-Policies fixen, oder den Tabellen-Zwilling unter new row violates row-level security policy anschauen.

Dateien aus Supabase Storage herunterladen

Dateien wieder herauszubekommen ist die Hälfte des Jobs, die die meisten Tutorials überspringen. Es gibt drei Wege – welcher der richtige ist, hängt vom Bucket ab:

Methode Bucket Use Case
getPublicUrl(path) public <img>-Tags, CDN-gecachte Assets
createSignedUrl(path, expiresIn) privat Temporärer Browser-Zugriff (Preview, E-Mail-Link)
download(path) privat Datei als Blob in die App holen

Datei als Blob herunterladen

.download() holt das Objekt über den authentifizierten Client. Der Aufrufer muss die SELECT-Policy auf storage.objects passieren – dieselbe Policy, die schon das RETURNING * des Uploads braucht:

const { data, error } = await supabase.storage
  .from('documents')
  .download(`${user.id}/report.pdf`)

if (error) {
  // Typische Form: { status: 400, message: 'Object not found' }
  // – falscher Pfad ODER von RLS blockiert (siehe Problem 9 unten)
  console.error(error.message)
  throw error
}

// data ist ein Blob
const objectUrl = URL.createObjectURL(data)
// z.B. PDF-Preview: <iframe src={objectUrl} />

"Speichern unter"-Download im Browser auslösen

async function saveFile(path: string, filename: string) {
  const { data, error } = await supabase.storage
    .from('documents')
    .download(path)

  if (error) throw error

  const url = URL.createObjectURL(data)
  const a = document.createElement('a')
  a.href = url
  a.download = filename
  document.body.appendChild(a)
  a.click()
  a.remove()
  URL.revokeObjectURL(url)
}

Ohne Blob-Handling geht es auch, wenn eine Signed URL reicht: createSignedUrl(path, 300, { download: 'report.pdf' }) lässt den Browser die Datei speichern statt sie anzuzeigen (siehe Public URLs vs Signed URLs).

Bild verkleinert herunterladen (Transform)

Transformationen funktionieren auch beim Download – praktisch, wenn du das Original gespeichert hast und nur ein Thumbnail brauchst:

const { data, error } = await supabase.storage
  .from('uploads')
  .download(`${user.id}/photo.jpg`, {
    transform: {
      width: 800,
      quality: 75,
    },
  })

Server-side Download (API Route / Edge Function)

// Node: Blob in einen Buffer umwandeln für Weiterverarbeitung
const { data, error } = await supabase.storage
  .from('documents')
  .download(path)

if (error) throw error

const buffer = Buffer.from(await data.arrayBuffer())
// in PDF-Parser pipen, als E-Mail-Anhang, woanders hochladen …

RLS gilt auch für Downloads. .download() und createSignedUrl() brauchen beide SELECT auf storage.objects – ein blockierter Download liefert aber Object not found statt 403, weil Storage nicht verrät, ob eine Datei existiert. Wenn Downloads fehlschlagen: SELECT-Policy aus Schritt 1 prüfen; Hintergrund zur Tabellen-Variante dieses Fehlers: new row violates row-level security policy.

Schritt 3: Advanced Features

Progress Tracking für große Dateien

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)
  }
}

Image Resize vor Upload

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)
}

Multiple File Upload

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)
  }
}

Schritt 4: Server-Side Upload (Next.js API Route)

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' })
  }
}

Schritt 5: File Management

Dateien auflisten

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
}

Dateien löschen

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
}

Image Transformationen

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'
})

Häufige Upload-Fehler

Die meisten Supabase-Storage-Fehler sind ein kleines Set an Statuscodes. Erst den Code mappen – dann Policy, Limit oder URL-Typ fixen.

Code Typische Meldung Fix
400 Invalid JWT / JWT expired Neu einloggen; nicht ohne Session hochladen
400 Object not found (bei .download()) Pfad falsch, oder SELECT-Policy blockiert das Objekt – RLS versteckt seine Existenz
403 new row violates row-level security policy INSERT + SELECT auf storage.objects; Pfad muss zu auth.uid() passen
404 Bucket not found Bucket-Name in .from('…') muss zum Dashboard passen
409 The resource already exists Eindeutiger Pfad oder upsert: true plus UPDATE-Policy
413 The object exceeded the maximum allowed size file_size_limit erhöhen oder clientseitig komprimieren
415 mime type … is not supported MIME-Type in allowed_mime_types ergänzen
function explainStorageError(error) {
  const code = String(error.statusCode ?? '')
  switch (code) {
    case '400':
      return 'JWT fehlt oder ist abgelaufen – einloggen und Upload wiederholen.'
    case '403':
      return 'RLS blockiert INSERT oder das RETURNING-SELECT. INSERT + SELECT auf storage.objects anlegen.'
    case '404':
      return 'Bucket-Name stimmt nicht mit dem Dashboard überein.'
    case '409':
      return 'Pfad existiert. Eindeutigen Pfad oder { upsert: true } plus UPDATE-Policy nutzen.'
    case '413':
      return 'Datei größer als Bucket-/Global-Limit.'
    case '415':
      return 'MIME-Type steht nicht auf der Allow-List des Buckets.'
    default:
      return error.message ?? 'Unbekannter Storage-Fehler'
  }
}

const { error } = await supabase.storage.from('uploads').upload(path, file)
if (error) throw new Error(explainStorageError(error))

Problem 1: 403 / "new row violates row-level security policy"

Diese Meldung gibt es an zwei Stellen. Nicht verwechseln:

Lösung für Uploads: User muss authentifiziert sein, der erste Ordner muss auth.uid() sein, und beide Policies müssen existieren:

-- Debug: Prüfe ob User authentifiziert ist
SELECT auth.uid(), auth.role();

-- Debug: Prüfe Bucket-Konfiguration
SELECT * FROM storage.buckets WHERE id = 'uploads';

Problem 2: Upload funktioniert, aber Bild wird nicht angezeigt

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)
}

Problem 3: Langsame Uploads

Lösungen:

  1. Dateien vor Upload komprimieren
  2. Chunked Upload für große Dateien
  3. CDN aktivieren (automatisch bei Supabase)
  4. Image-Resize clientseitig

Problem 4: 413 "The object exceeded the maximum allowed size"

Ursache: Die Datei ist größer als das Bucket- oder globale File-Size-Limit in den Storage-Settings.

const { data, error } = await supabase.storage
  .from('uploads')
  .upload(path, file)

if (error) {
  // Typische Form:
  // { statusCode: '413', message: 'The object exceeded the maximum allowed size' }
  console.error(error.statusCode, error.message)
}

Lösung: Dashboard → Storage → Configuration / Bucket-Settings → Max file size erhöhen. Zusätzlich clientseitig prüfen:

const MAX_MB = 10
if (file.size > MAX_MB * 1024 * 1024) {
  throw new Error(`Datei zu groß (max. ${MAX_MB}MB)`)
}

Problem 5: 415 "mime type … is not supported"

Ursache: Der Bucket hat eine allowedMimeTypes-Liste und dein Dateityp steht nicht drauf.

// Expliziter contentType hilft bei ungewöhnlichen Extensions
const { error } = await supabase.storage
  .from('uploads')
  .upload(path, file, { contentType: file.type || 'application/octet-stream' })

// error.message ≈ 'mime type application/zip is not supported'

Lösung: In den Bucket-Settings die benötigten MIME-Types ergänzen (z.B. image/jpeg, image/png, application/pdf) – oder die Allow-List leeren, wenn du Typen selbst validierst.

Problem 6: 409 / 400 "The resource already exists"

Ursache: Du lädst auf einen Pfad, der schon existiert, und upsert ist false (Default).

// Option A: jedes Mal eindeutiger Pfad
const path = `${user.id}/${crypto.randomUUID()}-${file.name}`

// Option B: überschreiben
await supabase.storage.from('uploads').upload(path, file, { upsert: true })
// Benötigt UPDATE-Policy auf storage.objects

Problem 7: 404 "Bucket not found"

Lösung: Der Bucket-Name in .from('…') muss exakt zum Dashboard passen. Erst Bucket anlegen, dann Frontend deployen.

SELECT id, name, public, file_size_limit, allowed_mime_types
FROM storage.buckets;

Problem 8: 400 "Invalid JWT" / Upload ohne Session

Ursache: .upload() läuft als anon, weil kein Access-Token da ist (abgelaufene Session, SSR-Cookie nicht weitergereicht, oder Client ohne User-Session erzeugt).

const { data: { session } } = await supabase.auth.getSession()
if (!session) {
  throw new Error('No session – sign in before uploading')
}

console.log('role', session.user.role, 'uid', session.user.id)

const { error } = await supabase.storage
  .from('uploads')
  .upload(`${session.user.id}/${file.name}`, file)

Am Server (Next.js Route Handler) den Client aus den Request-Cookies bauen – ein zweites createClient(url, anonKey) ohne User-JWT trifft immer die Anon-Policies.

Problem 9: 400 "Object not found" beim Download

Ursache: Entweder existiert der Pfad wirklich nicht – oder deine SELECT-Policy blockiert das Objekt. Storage antwortet bewusst mit "Object not found" statt 403, um nicht zu verraten, welche Dateien existieren.

const { data, error } = await supabase.storage
  .from('documents')
  .download('user-id/report.pdf')

// error: { status: 400, message: 'Object not found' }

Lösung: Erst ein Pfad-Problem ausschließen, dann die Policy prüfen:

  1. Kein führender Slash, und der Bucket-Name gehört nicht in den Pfad: .from('documents').download('uid/file.pdf'), nicht .download('documents/uid/file.pdf').
  2. Prüfen, ob das Objekt existiert (der SQL Editor läuft als Service Role und umgeht RLS):
SELECT name FROM storage.objects
WHERE bucket_id = 'documents'
  AND name LIKE 'USER-UUID/%';
  1. Existiert das Objekt, passt die SELECT-Policy aus Schritt 1 nicht – typischerweise weil der erste Ordner nicht die auth.uid() des Users ist oder der Download ohne Session läuft. Gleiche Root Cause wie der Upload-403: new row violates row-level security policy.

Security Best Practices

1. File Type Validation

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)
}

2. File Size Limits

const MAX_FILE_SIZES = {
  image: 5 * 1024 * 1024,    // 5MB
  document: 10 * 1024 * 1024, // 10MB
  video: 100 * 1024 * 1024   // 100MB
}

3. Malware Scanning

Für Production-Apps solltest du einen Malware-Scanner integrieren:

// Example mit ClamAV
const scanFile = async (fileBuffer: Buffer) => {
  // Integration mit Malware-Scanner
  // return scanResult
}

Performance Optimierung

1. Lazy Loading für File Listen

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>
  )
}

2. Image Caching Strategy

// Service Worker für aggressive Caching
const cacheImages = async (urls: string[]) => {
  const cache = await caches.open('supabase-images-v1')
  await cache.addAll(urls)
}

FAQ: Häufige Fragen zu Supabase Storage Uploads

Wie lade ich eine Datei mit JavaScript / React in Supabase Storage hoch?

Mit supabase.storage.from('bucket').upload(path, file), nachdem der User eingeloggt ist. Lege Dateien unter {user.id}/… ab, wenn deine RLS-Policies den ersten Ordner gegen auth.uid() prüfen.

Warum kommt beim Supabase-Storage-Upload 403 / "new row violates row-level security policy"?

Fast immer fehlende oder falsche RLS auf storage.objects. Du brauchst INSERT und SELECT (Storage nutzt RETURNING *). Ausführlich: Storage 403 RLS beim Upload beheben. Dieselbe Meldung bei einem Tabellen-Insert ist eine andere Schicht: new row violates row-level security policy.

Wie lade ich eine Datei aus Supabase Storage herunter?

Public Bucket: getPublicUrl(path). Privater Bucket: supabase.storage.from('bucket').download(path) liefert die Datei als Blob, oder createSignedUrl(path, expiresIn) für einen temporären Link. Alle drei brauchen eine SELECT-Policy auf storage.objects. Beispiele: Dateien aus Supabase Storage herunterladen.

Wie bekomme ich nach dem Upload eine Public URL?

Mit getPublicUrl(path) – aber nur, wenn der Bucket öffentlich ist. Bei privaten Buckets createSignedUrl(path, expiresIn) verwenden. Beispiele: Public URLs vs Signed URLs.

Wie lade ich in einen privaten Supabase-Bucket hoch?

public = false lassen, RLS auf den Owner beschränken, mit User-Session uploaden und Zugriff über Signed URLs teilen (nicht über Public URLs).

Wie setze ich das File-Size-Limit in Supabase Storage?

In den Project-Storage-Settings (global) und optional pro Bucket. Ein 413-Fehler heißt: die Datei überschreitet eines dieser Limits.

Wie erlaube ich nur Bilder (MIME-Types) in einem Bucket?

allowedMimeTypes am Bucket setzen (z.B. image/jpeg, image/png, image/webp). Nicht erlaubte Types liefern 415.

Wie überschreibe ich / upsert eine Datei in Supabase Storage?

{ upsert: true } übergeben und eine UPDATE-Policy anlegen, die zu deinen INSERT-Pfadregeln passt.

Wie lade ich mehrere Dateien parallel hoch?

Über die FileList mappen und einzelne .upload()-Calls mit Promise.all / Promise.allSettled ausführen. Besser eindeutige Pfade, damit ein Fehler nicht alles blockiert.

Kann ich ohne Login (anon) hochladen?

Nur wenn du bewusst Policies für die Rolle anon anlegst – Size/Type solltest du trotzdem serverseitig prüfen. Für User-Content: authenticated + Ordner = auth.uid().

Supabase Storage oder direkter S3-Upload?

Supabase Storage, wenn du RLS, Auth und Image Transforms in einem Stack willst. Presigned S3 nur bei speziellem Throughput oder bestehender AWS-Pipeline.

Fazit

Supabase Storage bietet eine mächtige und flexible Lösung für File Uploads. Die wichtigsten Punkte:

  1. RLS Policies sind kritisch - ohne sie funktioniert nichts
  2. Bucket Policies (Size-Limit, MIME-Types, Public-Flag) sind getrennt von RLS – 413/415 sind keine Permission-Fehler
  3. Public vs Signed URLs - getPublicUrl für öffentliche Buckets, createSignedUrl für private Dateien
  4. Downloads brauchen auch SELECT - .download() liefert einen Blob; ein blockierter Download sagt "Object not found", nicht 403
  5. Client-side Validierung verbessert UX, aber Server-side ist Pflicht
  6. Image-Transformationen sparen Bandbreite und verbessern Performance
  7. Chunked Upload für große Dateien implementieren
  8. Security-First Approach mit File-Type und Size Validation

Mit diesen Implementierungen hast du eine production-ready File Upload Lösung, die sicher, performant und benutzerfreundlich ist.

Weitere Ressourcen

Hast du Probleme mit deinem File Upload? Schreib einen Kommentar und ich helfe gerne weiter!

Kommentare