DEV Community

kanta13jp1
kanta13jp1

Posted on

Supabase Storage Guide — File Uploads, CDN Delivery, and Storage RLS

Supabase Storage Guide — File Uploads, CDN Delivery, and Storage RLS

Supabase Storage gives you S3-compatible object storage, a global CDN, and Row Level Security (RLS) policies — all managed through the same Supabase client your Flutter app already uses. In this guide we'll cover bucket setup, Flutter upload code, RLS policy design, signed URLs, image transforms, and CDN cache tuning.

Creating Buckets

Create buckets via the Supabase dashboard or SQL:

-- Public bucket — files accessible by URL without auth
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', true);

-- Private bucket — requires signed URLs or auth
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
  'documents',
  'documents',
  false,
  52428800,            -- 50 MB max file size
  ARRAY['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
);
Enter fullscreen mode Exit fullscreen mode

Use public = true for assets you want cached on the CDN without auth (avatars, OG images). Use public = false for anything user-private (documents, invoices, drafts).

Uploading Files from Flutter

import 'dart:io';
import 'package:image_picker/image_picker.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

class StorageService {
  final _client = Supabase.instance.client;

  /// Pick an image and upload as the current user's avatar
  Future<String?> uploadAvatar() async {
    final picker = ImagePicker();
    final picked = await picker.pickImage(
      source: ImageSource.gallery,
      maxWidth: 1200,
      maxHeight: 1200,
      imageQuality: 85,
    );
    if (picked == null) return null;

    final userId = _client.auth.currentUser!.id;
    final ext = picked.path.split('.').last.toLowerCase();
    final storagePath = 'public/$userId/avatar.$ext';

    await _client.storage.from('avatars').upload(
      storagePath,
      File(picked.path),
      fileOptions: const FileOptions(
        cacheControl: '3600',
        upsert: true,           // overwrite existing avatar
        contentType: 'image/jpeg',
      ),
    );

    // Return the CDN URL for immediate display
    return _client.storage.from('avatars').getPublicUrl(storagePath);
  }

  /// Upload raw bytes — works on Flutter Web too
  Future<String> uploadBytes({
    required String bucket,
    required String path,
    required Uint8List bytes,
    required String mimeType,
  }) async {
    await _client.storage.from(bucket).uploadBinary(
      path,
      bytes,
      fileOptions: FileOptions(contentType: mimeType, upsert: true),
    );
    return _client.storage.from(bucket).getPublicUrl(path);
  }

  /// Batch-upload multiple files and return their paths
  Future<List<String>> uploadMultiple(List<XFile> files) async {
    final userId = _client.auth.currentUser!.id;
    final paths = <String>[];

    await Future.wait(files.map((file) async {
      final ts = DateTime.now().millisecondsSinceEpoch;
      final path = '$userId/${ts}_${file.name}';
      final bytes = await file.readAsBytes();

      await _client.storage.from('documents').uploadBinary(
        path,
        bytes,
        fileOptions: FileOptions(
          contentType: file.mimeType ?? 'application/octet-stream',
        ),
      );
      paths.add(path);
    }));

    return paths;
  }
}
Enter fullscreen mode Exit fullscreen mode

Designing Storage RLS Policies

Storage RLS operates on the storage.objects table. The helper function storage.foldername(name) returns an array of path segments, letting you match against user IDs baked into the path.

-- Policy 1: Authenticated users manage their own avatar folder
CREATE POLICY "Users manage own avatars"
ON storage.objects
FOR ALL
TO authenticated
USING (
  bucket_id = 'avatars'
  AND (storage.foldername(name))[1] = 'public'
  AND (storage.foldername(name))[2] = (auth.uid())::text
)
WITH CHECK (
  bucket_id = 'avatars'
  AND (storage.foldername(name))[1] = 'public'
  AND (storage.foldername(name))[2] = (auth.uid())::text
);

-- Policy 2: Anyone can read from the public avatars bucket
CREATE POLICY "Avatars are publicly readable"
ON storage.objects
FOR SELECT
TO public
USING (bucket_id = 'avatars');

-- Policy 3: Private documents accessible by owner only
CREATE POLICY "Documents owner only"
ON storage.objects
FOR ALL
TO authenticated
USING (
  bucket_id = 'documents'
  AND (storage.foldername(name))[1] = (auth.uid())::text
)
WITH CHECK (
  bucket_id = 'documents'
  AND (storage.foldername(name))[1] = (auth.uid())::text
);

-- Policy 4: Allow team members to access shared folders
CREATE POLICY "Team shared folder access"
ON storage.objects
FOR SELECT
TO authenticated
USING (
  bucket_id = 'documents'
  AND EXISTS (
    SELECT 1 FROM public.team_members tm
    WHERE tm.user_id = auth.uid()
      AND tm.team_id = (storage.foldername(name))[1]::uuid
  )
);
Enter fullscreen mode Exit fullscreen mode

Signed URLs for Private Files

Generate time-limited URLs for private bucket objects without exposing the bucket:

class SignedUrlService {
  final _client = Supabase.instance.client;

  /// One-hour download link
  Future<String> getSignedUrl(String path, {int ttlSeconds = 3600}) {
    return _client.storage
        .from('documents')
        .createSignedUrl(path, ttlSeconds);
  }

  /// Batch-generate signed URLs efficiently
  Future<List<SignedUrl>> getSignedUrls(List<String> paths) {
    return _client.storage
        .from('documents')
        .createSignedUrls(paths, 3600);
  }

  /// Generate an upload URL — let clients upload directly to Storage
  /// without routing the bytes through your server
  Future<String> getSignedUploadUrl(String path) async {
    final result = await _client.storage
        .from('documents')
        .createSignedUploadUrl(path);
    return result.signedUrl;
  }
}
Enter fullscreen mode Exit fullscreen mode

Using signed URLs in your Flutter UI:

class PrivateFileViewer extends StatelessWidget {
  const PrivateFileViewer({required this.filePath, super.key});
  final String filePath;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: SignedUrlService().getSignedUrl(filePath),
      builder: (context, snapshot) {
        if (!snapshot.hasData) return const CircularProgressIndicator();
        return Image.network(snapshot.data!);
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Image Transforms (Pro Plan)

Supabase Storage Pro supports on-the-fly image resizing and format conversion at the CDN edge — no extra infrastructure needed.

class ImageTransformService {
  final _client = Supabase.instance.client;

  /// 200×200 WebP thumbnail
  String thumbnail(String path) {
    return _client.storage.from('avatars').getPublicUrl(
      path,
      transform: const TransformOptions(
        width: 200,
        height: 200,
        resize: ResizeOption.cover,
        format: TransformFormat.webp,
        quality: 80,
      ),
    );
  }

  /// Responsive srcset map
  Map<String, String> responsiveSet(String path) {
    return {
      for (final entry in {'xs': 320, 'sm': 640, 'md': 960, 'lg': 1280}.entries)
        entry.key: _client.storage.from('images').getPublicUrl(
          path,
          transform: TransformOptions(
            width: entry.value,
            format: TransformFormat.webp,
            quality: 85,
          ),
        )
    };
  }
}

// Widget usage
class ResponsiveImage extends StatelessWidget {
  const ResponsiveImage({required this.path, super.key});
  final String path;

  @override
  Widget build(BuildContext context) {
    final ts = ImageTransformService();
    final width = MediaQuery.of(context).size.width;
    final url = width < 640 ? ts.thumbnail(path) : ts.responsiveSet(path)['md']!;

    return Image.network(
      url,
      fit: BoxFit.cover,
      errorBuilder: (_, __, ___) => const Icon(Icons.broken_image),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

CDN Cache Tuning

Set Cache-Control at upload time to control how long CDN edge nodes cache the file:

// Long-lived static assets (1 year)
await _client.storage.from('images').upload(
  path,
  file,
  fileOptions: const FileOptions(
    cacheControl: '31536000, immutable',
    upsert: false,
  ),
);

// User-generated content that might change (1 hour)
await _client.storage.from('avatars').upload(
  path,
  file,
  fileOptions: const FileOptions(
    cacheControl: '3600',
    upsert: true,
  ),
);

// Always-fresh (disable CDN cache)
await _client.storage.from('reports').upload(
  path,
  file,
  fileOptions: const FileOptions(
    cacheControl: 'no-cache, no-store',
    upsert: true,
  ),
);
Enter fullscreen mode Exit fullscreen mode

File Management Utilities

class FileManager {
  final _client = Supabase.instance.client;

  Future<List<FileObject>> listUserFiles() async {
    final userId = _client.auth.currentUser!.id;
    return _client.storage.from('documents').list(
      path: userId,
      searchOptions: const SearchOptions(
        limit: 100,
        sortBy: SortBy(column: 'created_at', order: 'desc'),
      ),
    );
  }

  Future<void> deleteFiles(List<String> paths) async {
    await _client.storage.from('documents').remove(paths);
  }

  Future<void> moveFile(String from, String to) async {
    await _client.storage.from('documents').move(from, to);
  }
}
Enter fullscreen mode Exit fullscreen mode

Summary

Use Case Solution
Public CDN assets public = true bucket + getPublicUrl
Private user files RLS policy + createSignedUrl
Image resizing TransformOptions (Pro)
CDN cache control cacheControl in FileOptions
Direct client uploads createSignedUploadUrl
Folder-level access storage.foldername() in RLS

Supabase Storage hits a sweet spot for indie developers: it removes the operational overhead of S3 + CloudFront while giving you the same primitives. The RLS integration means your access rules live in one place alongside your database policies.


Have you hit any tricky RLS edge cases with Supabase Storage, or found a clever use for image transforms? Share your approach in the comments!

Top comments (0)