File Upload Storage for Web and Mobile Apps, Done Right

File upload storage for an app comes down to four decisions: where the bytes live, who may upload, who may read, and how the files get back to users quickly. StitchDB answers all four in one project: files go into buckets, access is decided by policies on storage.objects using the same auth.uid() as your tables, private files are shared with signed URLs, and images can be resized by URL. This guide walks through each decision with working code for the browser and for mobile.

#The mental model

Storage is a set of buckets. Each file is a row in storage.objects plus the bytes in EU object storage. Because the metadata is a Postgres table, access rules are ordinary Row Level Security policies:

QuestionAnswer in StitchDB
Who can download?Anyone with the URL (public bucket), or whoever a select policy allows (private bucket)
Who can upload?Whoever an insert policy allows, in both kinds of bucket
Who can replace a file?upsert needs select and update policies too
Temporary access?Signed URLs for downloads and one-time signed upload URLs
Big files?Resumable uploads (TUS)
Thumbnails?Image resizing by URL

The Storage API lives at https://<project-ref>.p.stitchdb.com/storage/v1/, and Studio's Storage page lets you browse, upload, move and delete files by hand.

#Step 1: choose public or private buckets

A useful rule: if a file would be fine on a public web page, put it in a public bucket; otherwise, private.

  • Public: avatars, product photos, blog images. Fast, cacheable, linkable from <img src>.
  • Private: invoices, documents, user uploads you have not reviewed, anything personal.

Create buckets in Studio, or on a server with the service_role key:

JavaScript
await admin.storage.createBucket('avatars', {
  public: true,
  fileSizeLimit: '2MB',                 // at most your plan's upload size
  allowedMimeTypes: ['image/png', 'image/jpeg', 'image/*'],
})

Set fileSizeLimit and allowedMimeTypes on every bucket. They are checked on the server, so a modified client cannot upload a 500 MB video into your avatar bucket. Bucket names are 2 to 100 characters of lower-case letters, digits, -, _ and ..

#Step 2: write the policies

Uploads always need a policy, even in public buckets. The most common layout gives each user a folder named after their user id:

SQL
create policy "Users manage files in their folder" on storage.objects
  for all to authenticated
  using (bucket_id = 'docs' and (storage.foldername(name))[1] = auth.uid()::text)
  with check (bucket_id = 'docs' and (storage.foldername(name))[1] = auth.uid()::text);

storage.foldername(name) splits the path into folders, so [1] is the first one. A user can then write <their-id>/report.pdf but not <someone-else>/report.pdf.

For uploads tied to the uploader rather than a folder, check the owner:

SQL
create policy "Users upload their own avatar" on storage.objects
  for insert to authenticated
  with check (bucket_id = 'avatars' and owner_id = auth.uid()::text);

Because these are Postgres policies, they can also join your own tables. A team app can allow reading a file when the user is a member of the team whose id is the first folder. Keep the check simple and indexed; it runs for every request. The Row Level Security guide covers membership patterns.

#Step 3: upload from the client

#From the browser

JavaScript
const file = event.target.files[0]
const { data, error } = await stitchdb.storage
  .from('avatars')
  .upload(`${user.id}/avatar.png`, file, { upsert: true })

const { data: { publicUrl } } = stitchdb.storage.from('avatars').getPublicUrl(data.path)

Options are { contentType, cacheControl: '3600', upsert }. Store the returned path in your own table (for example profiles.avatar_path) rather than the full URL, so you can change how you serve it later.

#From a mobile app

The same API works from every SDK. In Flutter:

dart
final bucket = stitchdb.storage.from('avatars');

await bucket.upload('user-1/avatar.png', File('avatar.png'),
    fileOptions: const FileOptions(contentType: 'image/png', upsert: true));
await bucket.uploadBinary('user-1/avatar.png', bytes); // Uint8List, e.g. on the web

In Swift:

swift
let bucket = stitchdb.storage.from("avatars")

try await bucket.upload("user-1/avatar.png", data: imageData, options: FileOptions(contentType: "image/png", upsert: true))

On mobile, shrink photos before upload. A phone camera image can be several megabytes; a profile picture needs a fraction of that, and users on mobile data pay for every byte.

#Large files: resumable uploads

For videos, archives and anything that may not finish in one request, use resumable uploads at /storage/v1/upload/resumable. They follow the TUS protocol, so an interrupted upload continues where it stopped instead of starting over. Studio uses them for large files automatically. Any TUS client works; send the apikey and the user's Authorization header, and the same policies apply.

#Uploads without a user policy

Sometimes the client should upload exactly one file to one path that your server chose, for example after payment. A server with the service_role key creates a one-time upload URL:

JavaScript
// server
const { data } = await admin.storage.from('uploads').createSignedUploadUrl('user-123/report.pdf')
// client
await stitchdb.storage.from('uploads').uploadToSignedUrl('user-123/report.pdf', data.token, file)

This works well from an Edge Function that checks the request first.

#Step 4: serve files back

#Private files: signed URLs

JavaScript
// Private buckets: create a temporary link (expires in 60 seconds)
const { data: signed } = await stitchdb.storage.from('invoices').createSignedUrl('2026/10.pdf', 60)

// Several at once
const { data } = await stitchdb.storage.from('invoices').createSignedUrls(['2026/09.pdf', '2026/10.pdf'], 3600)

Anyone holding the link can download until it expires, so keep expiries short for sensitive files and create links when the user asks for them. Pass { download: true } to make the browser save the file instead of opening it.

#Thumbnails: image resizing

Never send a 4,000-pixel original to a 64-pixel avatar slot. Ask for the size you need:

JavaScript
// Public bucket: a URL you can put in <img src>
const { data } = stitchdb.storage.from('avatars').getPublicUrl('ann.png', {
  transform: { width: 200, height: 200, resize: 'cover', quality: 75 },
})

// Private bucket: a signed URL, or download the resized bytes
const { data: s } = await stitchdb.storage.from('invoices').createSignedUrl('scan.jpg', 3600, { transform: { width: 800 } })
OptionValuesDefault
width, height1 to 2000 pixels; give one to keep the aspect ratioOriginal size
resizecover, contain, fillcover
quality20 to 10080
formatwebp, avif or originWebP for browsers that accept it

Source images can be up to 25 MB and 16.8 megapixels, in PNG, JPEG, WebP, GIF, AVIF, HEIC, BMP or TIFF. Public renders are cached for an hour per size, fit, quality and format, and cached hits do not count toward your monthly transformations. Signed and authenticated renders are made on every request, so cache those in your app if you show them often. More in image resizing.

#Caching and replacing files

Files in public buckets are cached on the servers that answer for your project. The SDKs upload with max-age=3600 unless you pass cacheControl:

JavaScript
await stitchdb.storage.from('avatars').upload('u1.png', file, { cacheControl: '86400' }) // cache for a day
await stitchdb.storage.from('avatars').upload('u1.png', file, { cacheControl: '60', upsert: true }) // changes show within a minute

The catch: after replacing a public file under the same name, users can see the old copy until its cache time runs out. For files that change, either use a short cache time or put a version in the name (avatar-v3.png) or the URL (?v=3). Range requests, files of 100 MB or more, private files and signed URLs are not cached. The X-Cache header shows HIT, MISS, REVALIDATED or STALE.

#Step 5: manage files

JavaScript
const { data: files } = await stitchdb.storage.from('avatars').list('folder', { limit: 100, offset: 0, sortBy: { column: 'name', order: 'asc' } })
await stitchdb.storage.from('avatars').move('old/path.png', 'new/path.png')
await stitchdb.storage.from('avatars').copy('a.png', 'b.png')
await stitchdb.storage.from('avatars').remove(['a.png', 'b.png'])

When a user deletes their account, delete their folder too. A trigger on your profiles table, or a cleanup job, keeps storage from filling up with orphaned files that still count against your quota.

#S3 tools for servers and backups

For backup scripts, rclone or existing code written for S3, every project also has an S3-compatible endpoint at https://<project-ref>.p.stitchdb.com/storage/v1/s3, with path-style addressing and access keys created in Storage → S3 access:

Terminal
aws s3 ls --profile stitchdb --endpoint-url https://<ref>.p.stitchdb.com/storage/v1/s3
aws s3 cp ./backup.tar.gz s3://backups/2026/ --profile stitchdb --endpoint-url https://<ref>.p.stitchdb.com/storage/v1/s3

S3 access keys skip storage policies, like the service_role key, so keep them on servers. Multipart uploads and presigned URLs work; versioning, object lock, bucket policies and ACLs do not. See S3 access.

#Limits

PlanFile storageMax file sizeImage transforms / monthEgress
Free1 GB50 MB1,00025 GB
Pro100 GB100 GB25,0001 TB
Scale250 GB100 GB100,0002 TB

Values from the plans API as of November 2026; see storage limits and pricing. File storage is one amount for your whole account, shared by all projects. StitchDB e-mails you at 80% and at 100%. While you are over the limit, new uploads get 413 in every project until files are removed, unless the spend cap is off, in which case extra usage is billed. When the month's image transforms run out, resized URLs return 429 while originals keep working.

#A checklist before you ship

  • Every bucket has a size limit and allowed MIME types.
  • Upload policies check the user's folder or owner_id.
  • Private data is in private buckets and served through short signed URLs.
  • Lists and feeds use resized images, not originals.
  • Files that change use a versioned name or a short cacheControl.
  • Deleting an account deletes its files.

#FAQ

#Should I store files in a Postgres bytea column instead?

Rarely. Files in the database make backups and queries heavier and cannot be cached like public files. Store the bytes in a bucket and the path in your table.

#Can users upload directly without going through my server?

Yes. The client uploads straight to the Storage API with the user's token, and policies decide. Use signed upload URLs when the server must choose the path.

#Are files included in backups?

The storage overview says stored files are part of daily backups while a project's files are under 1 GB, and Free projects have no daily backups. For bigger buckets, keep your own copy with the S3 endpoint and a tool like rclone.

#Where are files stored?

In EU object storage, which helps if you need to keep personal data in the EU.

Start storing files with policies you can read: create a free project, or read the upload and download docs.