Skip to main content
Databases & RLS
Supabase
Next.js
TypeScript
パフォーマンス
コスト最適化
セキュリティ

Supabase Storage implementation guide: choosing an upload path, signed URLs, the CDN, and image transformations in production

An implementation guide for getting real production use out of Supabase Storage. Covers choosing between standard and TUS resumable uploads at the 6MB boundary, signed upload URLs that skip your server entirely, the S3-compatible endpoint, picking between public and signed URLs, Smart CDN cache behavior and its 60-second propagation, image transformation limits and cost, and the design that cuts egress by an order of magnitude — all with official-compliant code.

Published
Reading time
16 min read
Author
友田 陽大
Share

Supabase Storage stores a file in one line of upload(). Which is why so many projects ship to production with exactly that line and then hit three walls.

  1. Large files break partway through and start over (standard uploads can't resume)
  2. Every upload passes through a server function, paying for execution time and egress twice
  3. Images are served as-is, and the egress bill spikes

This article is an implementation guide for making Supabase Storage production-grade. Faithful to the official documentation as of 2026-08-16, it covers choosing among the four upload paths, uploading without your server in the loop, delivery and CDN behavior, image transformation limits and cost, and the design that cuts egress by an order of magnitude — with code you can use as-is.

What this article does not cover: Storage access control (RLS on storage.objects, per-user folders via storage.foldername(name), owner scoping) gets a full article of its own in Protecting Supabase Storage with RLS. This article assumes RLS is already designed correctly and focuses on upload, delivery, and cost design on top of it.


1. The model and the buckets: there are three kinds

Supabase Storage now has three bucket types.

TypePurpose (official definition)
Files buckets"Store and serve images, videos, documents, and general-purpose files"
Analytics buckets"Store data in Apache Iceberg tables for data lakes, logs, and Supabase Pipelines"
Vector buckets"Store embeddings and run similarity search for semantic matching, AI, and RAG"

This article covers Files buckets. Their characteristics:

  • "S3 compatible Storage, RESTful API, TUS resumable uploads"
  • "Serve your assets with lightning-fast performance from over 285 cities worldwide"
  • "Resize, compress, and transform media files on the fly"
  • "Manage file permissions with row-level security and custom policies"

That last line is the essence. A file in Supabase Storage is a row in the storage.objects table, and folders are virtual (part of the path string). That's why access control can be written in RLS. Understand this design and every subsequent decision follows naturally.

Creating buckets and setting restrictions

const { data, error } = await supabase.storage.createBucket('avatars', {
  public: true,
  allowedMimeTypes: ['image/*'],
  fileSizeLimit: '1MB',
})

Per the official docs, this configuration lets "your users upload only images to the avatars bucket and the size must not be greater than 1MB." Uploads that don't meet the restrictions are rejected.

The global file size limit depends on the plan:

PlanGlobal limit
Free50 MB
Pro500 GB
Team500 GB
EnterpriseCustom

Per-bucket limits cannot exceed the global limit. The official recommendation:

The global limit should be set to the highest possible file size that your application accepts, with smaller per-bucket limits set as needed.

Read it as a design instruction: global is the ceiling for the whole system, per-bucket is the realistic limit for each use. Don't leave avatars in a state where a 500GB file could be sent. That isn't just a restriction — it's a breakwater against egress cost.


2. Choosing an upload path: there are four

This is the first design decision.

PathWhen to use itImplementation
Standard uploadSmall files, 6MB and undersupabase.storage.from(...).upload()
TUS resumable uploadOver 6MB, flaky networks, progress reporting neededtus-js-client / Uppy
Signed upload URLServer makes the authorization decision; bytes go straight from the clientcreateSignedUploadUrl() + uploadToSignedUrl()
S3-compatible endpointYou already have an S3 toolchain (AWS SDK, CLI, backup tools)AWS SDK S3Client

2-1. Standard upload

const { data, error } = await supabase.storage
  .from('bucket_name')
  .upload('file_path', file, {
    // Overwrite an existing file. false (default) returns 400 Asset Already Exists
    upsert: false,
    // If omitted, Storage assumes the content type from the file extension
    contentType: 'image/jpeg',
  })

The official positioning:

The standard file upload method is ideal for small files that are not larger than 6MB.

Standard uploads can carry up to 5GB, but they cannot resume. The docs recommend "using TUS Resumable Upload for uploading files greater than 6MB in size for better reliability."

contentType is optional, but don't omit it. Relying on extension inference gives you unintended types for files without extensions or with spoofed ones. And remember at the same time that a client-declared contentType is not a trustworthy value — defend in depth with the bucket's allowedMimeTypes and, where needed, server-side inspection of the actual bytes.

2-2. TUS resumable upload

Supabase Storage implements the TUS protocol. Use it via tus-js-client or Uppy.

var upload = new tus.Upload(file, {
    // Use the direct storage hostname (for performance)
    endpoint: `https://${projectId}.storage.supabase.co/storage/v1/upload/resumable`,
    retryDelays: [0, 3000, 5000, 10000, 20000],
    headers: {
        authorization: `Bearer ${session.access_token}`,
        'x-upsert': 'true',
    },
    uploadDataDuringCreation: true,
    removeFingerprintOnSuccess: true,
    metadata: {
        bucketName: bucketName,
        objectName: fileName,
        contentType: 'image/png',
        cacheControl: '3600',
        metadata: JSON.stringify({yourCustomMetadata: true}),
    },
    chunkSize: 6 * 1024 * 1024,
    onError: function (error) {},
    onProgress: function (bytesUploaded, bytesTotal) {},
    onSuccess: function () {},
})

There is one constraint you must not break. The official wording, verbatim:

Chunk size must be set to 6MB (for now) do not change it.

And the concurrency behavior: only one client can upload to the same upload URL at a time, and multiple clients uploading to identical paths receive a 409 Conflict. The first to complete succeeds (or the last, if the x-upsert header is set).

That 409 is not an error to surface as "upload failed" and move on. It means "the same file is being uploaded from another device," and the user needs to be told what's actually happening.

2-3. Signed upload URLs: keep your server thin

"I want the server to decide whether the upload is allowed, but I don't want the bytes going through it" — this is the most practically useful pattern. It spends no function execution time, memory, or egress.

createSignedUploadUrl() issues a time-limited token, and uploadToSignedUrl() uses it to upload. Per the docs, this makes it "easy to upload to Storage from the client directly, without requiring validation from an intermediary server."

In a Next.js Server Action:

// app/actions/upload.ts
"use server";

import { z } from "zod";
import { createClient } from "@/lib/supabase/server";

const requestSchema = z.object({
  // Extension allowlist. Never trust a client-supplied filename
  extension: z.enum(["png", "jpg", "jpeg", "webp"]),
  sizeBytes: z.number().int().positive().max(5 * 1024 * 1024),
});

export type UploadTicket =
  | { readonly ok: true; readonly path: string; readonly token: string }
  | { readonly ok: false; readonly reason: "unauthorized" | "invalid" | "failed" };

/**
 * Issues only the permission to upload. Bytes never pass through here.
 * The server chooses the filename (cutting off path traversal and overwrite collisions).
 */
export async function createUploadTicket(input: unknown): Promise<UploadTicket> {
  const parsed = requestSchema.safeParse(input);
  if (!parsed.success) return { ok: false, reason: "invalid" };

  const supabase = await createClient();
  const { data: claims } = await supabase.auth.getClaims();
  const userId = claims?.claims.sub;
  if (!userId) return { ok: false, reason: "unauthorized" };

  // Leading folder = uid. The convention that pairs with the RLS policy
  const path = `${userId}/${crypto.randomUUID()}.${parsed.data.extension}`;

  const { data, error } = await supabase.storage
    .from("avatars")
    .createSignedUploadUrl(path);

  if (error || !data) return { ok: false, reason: "failed" };
  return { ok: true, path: data.path, token: data.token };
}
"use client";

import { useState, useTransition } from "react";
import { createClient } from "@/lib/supabase/client";
import { createUploadTicket } from "@/app/actions/upload";

export function AvatarUploader() {
  const [pending, startTransition] = useTransition();
  const [status, setStatus] = useState<"idle" | "uploading" | "done" | "error">("idle");

  async function handleFile(file: File) {
    setStatus("uploading");
    const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
    const ticket = await createUploadTicket({ extension, sizeBytes: file.size });
    if (!ticket.ok) {
      setStatus("error");
      return;
    }

    const supabase = createClient();
    // Bytes go browser → Storage directly. No server function in the path
    const { error } = await supabase.storage
      .from("avatars")
      .uploadToSignedUrl(ticket.path, ticket.token, file);

    setStatus(error ? "error" : "done");
  }

  return (
    <div>
      <label htmlFor="avatar" className="block text-sm font-medium">
        Profile image
      </label>
      <input
        id="avatar"
        type="file"
        accept="image/png,image/jpeg,image/webp"
        disabled={pending || status === "uploading"}
        // Don't hide the native input. Keep it focusable even behind custom UI
        onChange={(e) => {
          const file = e.target.files?.[0];
          if (file) startTransition(() => void handleFile(file));
        }}
      />
      {/* Announce state changes to screen readers. role="status" implies aria-live="polite" */}
      <p role="status" aria-live="polite" className="mt-2 text-sm">
        {status === "uploading" && "Uploading"}
        {status === "done" && "Upload complete"}
        {status === "error" && "Upload failed. Please try again"}
      </p>
    </div>
  );
}

The design decisions, made explicit:

  • The server chooses the filename. Using a client-supplied name as the path opens up path traversal (../) and overwriting other users' files. A UUID also eliminates collisions.
  • Extensions are validated against an allowlist. Extensions can lie, but at least the server declares what it is contracting to store. Validating the actual bytes is delegated to the bucket's allowedMimeTypes.
  • The leading folder is the uid. This is the convention that pairs with the "can only write into your own folder" RLS policy. Because the server builds the path, the convention cannot be broken.
  • Upload state is announced via role="status". A purely visual progress indicator looks like "nothing is happening" to a screen reader user.

2-4. The S3-compatible endpoint

If you already have an S3 toolchain, it works as-is.

import { S3Client } from '@aws-sdk/client-s3'

const client = new S3Client({
  forcePathStyle: true,
  region: 'project_region',
  endpoint: 'https://project_ref.storage.supabase.co/storage/v1/s3',
  credentials: {
    accessKeyId: 'your_access_key_id',
    secretAccessKey: 'your_secret_access_key',
  }
})

For local development use http://127.0.0.1:54321/storage/v1/s3 and region: 'local'.

The session-token approach is the notable one. Pass the project ref as accessKeyId, the anon key as secretAccessKey, and a valid JWT access token as sessionToken, and in the official wording:

All S3 operations performed with the Session Token are scoped to the authenticated user. RLS policies on the Storage Schema are respected.

In other words, RLS applies even over the S3 protocol. The usual worry that "going S3-compatible means authorization is bypassed" simply doesn't apply here. You can bring existing S3-based assets along while keeping authorization centralized in RLS.


3. Delivery: public URLs and signed URLs

Public buckets

const { data } = supabase.storage.from('bucket').getPublicUrl('filePath.jpg')
console.log(data.publicUrl)

The URL shape is https://[project_id].supabase.co/storage/v1/object/public/[bucket]/[asset-name].

Understand exactly what a public bucket means. Per the official definition, "anyone who possesses the asset URL can readily access the file." RLS does not apply to reads (it still applies to other operations such as upload and delete).

So the judgement is:

  • Fine in a public bucket: logos, OG images, public documents, static assets you want cached hard at the CDN
  • Must not go there: invoices, contracts, personal photos, arbitrary user-uploaded files

"The URL is unguessable, so it's fine" does not hold. URLs leak through referrers, sharing, logs, and screenshots.

Signed URLs (private buckets)

const { data, error } = await supabase.storage
  .from('bucket')
  .createSignedUrl('private-document.pdf', 3600) // expiry in seconds

The docs state an important property explicitly:

Signed URLs remain valid until their expiry time regardless of any Auth key changes.

Which means once issued, a signed URL cannot be revoked partway. Strip a user's permissions and any already-issued URL still works until it expires. So design accordingly:

  • Make the expiry the shortest the use case allows. A few minutes is plenty for on-screen display. A few hours for a download link sent by email. "One year" is effectively publishing it.
  • Issue them per request. Storing signed URLs in the database and reusing them is the same as abandoning expiry management.

Forcing a download

To make the browser save rather than display, append ?download to the URL. To specify a filename, use ?download=customname.jpg.

const { data, error } = await supabase.storage
  .from('avatars')
  .download('avatar1.png', { download: 'my-custom-name.png' })

4. The CDN: Smart CDN and 60 seconds of propagation

Supabase Storage sits behind a CDN. On Pro and above, Smart CDN is enabled automatically.

The official explanation of what makes it different:

With Smart CDN caching enabled, the asset metadata in your database is synchronized to the edge. This automatically revalidates the cache when the asset is changed or deleted.

And:

[It] achieves a greater cache hit rate by shielding the origin server from asset requests that remain unchanged, even when different query strings are used in the URL.

An ordinary CDN treats a different query string as a separate entry; Smart CDN can treat it as the same asset. Which means an image URL carrying analytics query parameters still doesn't hit the origin.

Design around the 60 seconds

The most important caveat:

When files are updated or deleted, it can take up to 60 seconds for the CDN cache to be invalidated as the asset metadata has to propagate across all the data-centers around the globe.

This is the true cause of "I uploaded it but the old image still shows." Two responses:

  • Design so you never overwrite (recommended). Write to a new path whenever the content changes. Put a content hash or version in the filename and you never wait for CDN invalidation at all. Delete old files asynchronously.
  • If overwriting is unavoidable, accept the delay as a specification. State "changes may take up to a minute to appear" in the UI. A cache-busting query is a last resort (it lowers the Smart CDN hit rate).

Set cacheControl deliberately

The cacheControl option at upload time determines the browser-side cache duration. The default is one hour, which the docs call "generally a reasonable default value."

  • Immutable assets (hashed filenames) → set it long. Hit rate goes up and egress cost goes straight down
  • Frequently changing assets → short. Though as noted at the top of this chapter, a design that never overwrites is better still

5. Image transformations: serve smaller, on the fly

Image transformations are available on Pro and above. The options:

OptionValues
width / heightIntegers from 1 to 2500
resizecover (default — maintains aspect ratio and crops) / contain (maintains aspect ratio, fits within bounds) / fill (no aspect ratio preservation)
quality1 to 100 (defaults to 80)
formatorigin to preserve the original format; otherwise automatic optimization applies

Usable from three methods:

// public URL
supabase.storage.from('bucket').getPublicUrl('image.jpg', {
  transform: { width: 500, height: 600 }
})

// signed URL
supabase.storage.from('bucket').createSignedUrl('image.jpg', 60000, {
  transform: { width: 200, height: 200 }
})

// download
supabase.storage.from('bucket').download('image.jpg', {
  transform: { width: 800, height: 300, resize: 'contain' }
})

Automatic format optimization is quietly powerful. Per the docs, "Storage will automatically find the best format supported by the client" — switching to WebP and similar without any code change. Pass format: 'origin' only when you need the original format preserved.

The limits and costs:

ItemValue
PlanPro and above
Included transformations100 per month (Pro / Team)
Overage$5 per 1,000 origin images
Maximum image size25MB
Maximum resolution50MP
Width / heightIntegers from 1 to 2500

That the billing unit is origin images matters for design. Producing ten sizes from the same source counts as one origin image. So the cost of preparing multiple sizes for responsive images is limited, while the reduction in delivered size lowers egress.

When pairing with Next.js next/image, decide whether to transform on the Supabase side or the next/image side based on where the cache lives and what it costs. Transforming on the Supabase side puts the result on the CDN and doesn't consume Vercel's image optimization allowance.


6. Cost: egress is the main battleground

ItemFreePro
Storage1 GB included100 GB included, then $0.0213/GB
Cached egress5 GB included250 GB included, then $0.03/GB

The official scaling guide is unambiguous:

Images typically make up most of your egress. By keeping them as small as possible, you can cut down on egress and boost your application's performance.

In order of impact:

  1. Shrink delivered size with image transformations. Serving a 2MB photo as an 800px-wide WebP takes it to tens of kilobytes. That alone moves egress by an order of magnitude.
  2. Set cacheControl long. A cache hit is either "stays in the browser = no transfer" or "served from the CDN = cheaper."
  3. Set bucket file size limits. Stop huge files at the door.
  4. Don't destroy the Smart CDN hit rate. Avoid sprinkling cache-busting query strings.

List performance

There's an easily missed performance hole. The official note:

The standard supabase.storage.list() method degrades with large object counts because it retrieves both folder hierarchies and objects simultaneously.

The remedy is a custom Postgres function against storage.objects that applies only the filtering and pagination you need. If you don't need the folder hierarchy, that's faster.

And on RLS performance:

When creating RLS policies against the storage tables you can add indexes to the interested columns to speed up the lookup.

Past a few tens of thousands of files, policy evaluation cost becomes visible (the reasoning in RLS performance optimization applies directly).


7. Pre-production checklist

  • Files over 6MB use TUS resumable uploads (chunk size fixed at 6MB)
  • 409 Conflict (concurrent uploads to the same path) is explained properly in the UI
  • The server decides the upload path (never reuse the client's filename as-is)
  • Extension, MIME type, and size are constrained by both server validation and bucket restrictions
  • Global and per-bucket file size limits are configured
  • No confidential files sit in a public bucket (public buckets don't apply RLS to reads)
  • Signed URL expiries are the shortest the use case allows (they can't be revoked once issued)
  • Signed URLs are not stored in the database and reused
  • Overwrite-based designs are avoided (CDN invalidation takes up to 60 seconds), or the delay is stated in the UI
  • Immutable assets carry a long cacheControl
  • Images are transformed before delivery (images dominate egress)
  • Columns referenced by RLS policies on storage.objects are indexed
  • If list() performance matters at your scale, it's been replaced with a dedicated Postgres function
  • Upload state changes reach screen readers (role="status" / aria-live)

Conclusion: Storage is a delivery path, not a place to put things

Making Supabase Storage production-grade comes down to this.

A file costs you money and causes incidents at the moment it is delivered, not the moment it is stored. So put the center of gravity of the design on the delivery side rather than the upload side.

  • Keep the server thin on upload (issue permission only; bytes go direct)
  • Never overwrite on storage (writing to a new path means you never fight the 60 seconds)
  • Shrink on delivery (transformations and cacheControl change egress by an order of magnitude)
  • Make publishing a deliberate choice (start from the fact that public buckets don't apply RLS to reads)

Underneath all of it sits RLS on storage.objects. A file, too, is a row in a table — and that consistent model is the greatest strength of handling files on Supabase. Platforms that let you protect data, files, and realtime under one authorization philosophy are not that common.

Frequently asked questions

Should I use standard uploads or resumable uploads?
It comes down to file size. The official docs describe the standard file upload as ideal for small files not larger than 6MB, and recommend TUS resumable uploads for files greater than 6MB for better reliability. Standard uploads can carry up to 5GB, but if the connection drops you start over. If you deal with mobile networks or large videos and PDFs, making resumable uploads the default regardless of size gives a steadier experience.
Is it safe to let clients upload directly?
Yes, provided your RLS policies on storage.objects are written correctly. Routing bytes through your server actually wastes function execution time, memory, and egress. Let the server decide only whether the upload is permitted and issue a signed upload URL, then send the bytes straight from the client to Storage. Note that RLS only sees the path and the owner, so constrain file size and MIME type separately through bucket restrictions.
When do I use a public bucket versus a private one?
A public bucket means anyone with the URL can access the file. Use it for logos and OG images — things that don't matter if they leak and that you want cached hard at the CDN. Confidential files (invoices, contracts, personal photos) belong in a private bucket, served through createSignedUrl with a per-request expiry. A signed URL's expiry is fixed at issue time and, per the docs, remains valid until then regardless of any Auth key changes — so keep expiries short.
I overwrote a file but the old image still shows. Why?
CDN caching. Smart CDN syncs asset metadata to the edge and automatically invalidates the cache when an asset changes or is deleted, but the docs note it can take up to 60 seconds for the CDN cache to be invalidated as the metadata propagates across all data centers globally. If immediate reflection is a requirement, don't overwrite the same path — write to a new path (a name containing a hash or version) whenever the content changes.
Where does Storage cost spike?
Egress, not storage volume. The Pro plan includes 100 GB of storage and 250 GB of cached egress, with overage at $0.0213 per GB for storage and $0.03 per GB for cached egress. The official docs also state that images typically make up most of your egress. In order of impact: shrink delivered size with image transformations, set a long cacheControl to raise browser and CDN hit rates, and cap file size at the bucket level to stop huge files at the door.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

Supabase-based applications, from design through production operations

Realtime design (choosing between Broadcast / Presence / Postgres Changes, and staying consistent across reconnects), Auth flows, JWT signing keys and MFA, idempotent webhook handling in Edge Functions, and Storage upload paths and cost design. Built solo on a real mobile + web product with authorization pushed down into the database via RLS — so the app stays reliable, traceable, and easy to change.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading