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:
| Question | Answer 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:
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:
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:
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
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:
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 webIn 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:
// 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
// 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:
// 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 } })| Option | Values | Default |
|---|---|---|
width, height | 1 to 2000 pixels; give one to keep the aspect ratio | Original size |
resize | cover, contain, fill | cover |
quality | 20 to 100 | 80 |
format | webp, avif or origin | WebP 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:
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 minuteThe 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
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:
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/s3S3 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
| Plan | File storage | Max file size | Image transforms / month | Egress |
|---|---|---|---|---|
| Free | 1 GB | 50 MB | 1,000 | 25 GB |
| Pro | 100 GB | 100 GB | 25,000 | 1 TB |
| Scale | 250 GB | 100 GB | 100,000 | 2 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.