# 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: 2026-08-16
- Author: 友田 陽大
- Tags: Supabase, Next.js, TypeScript, パフォーマンス, コスト最適化, セキュリティ
- URL: https://tomodahinata.com/en/blog/supabase-storage-production-implementation-guide-uploads-signed-urls-cdn-image-transformations
- Category: Databases & RLS
- Pillar guide: https://tomodahinata.com/en/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## Key points

- There are four upload paths. Standard uploads for 6MB and under, TUS resumable uploads above 6MB, signed upload URLs when you don't want bytes flowing through your server, and the S3-compatible endpoint when you already have an S3 toolchain.
- The TUS chunk size is fixed at 6MB — the docs state plainly not to change it for now. Concurrent uploads to the same path return 409.
- Files in a public bucket are readable by anyone who knows the URL; RLS does not apply to reads. Serve confidential files from a private bucket via time-limited signed URLs.
- Smart CDN is enabled automatically on Pro and above. Updates and deletions can take up to 60 seconds to propagate, so any design built on overwriting must verify cache consistency.
- Image transformations allow width and height from 1 to 2500, quality from 1 to 100 (default 80), and cap at 25MB / 50MP on Pro and above. Since images dominate egress, transformations and cacheControl are the highest-leverage cost controls.

---

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](/blog/supabase-storage-rls-access-control-bucket-folder-policies-guide). 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.

| Type | Purpose (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

```ts
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:

| Plan | Global limit |
| --- | --- |
| Free | 50 MB |
| Pro | 500 GB |
| Team | 500 GB |
| Enterprise | Custom |

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.

| Path | When to use it | Implementation |
| --- | --- | --- |
| **Standard upload** | Small files, 6MB and under | `supabase.storage.from(...).upload()` |
| **TUS resumable upload** | Over 6MB, flaky networks, progress reporting needed | `tus-js-client` / Uppy |
| **Signed upload URL** | Server makes the authorization decision; bytes go straight from the client | `createSignedUploadUrl()` + `uploadToSignedUrl()` |
| **S3-compatible endpoint** | You already have an S3 toolchain (AWS SDK, CLI, backup tools) | AWS SDK `S3Client` |

### 2-1. Standard upload

```ts
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.

```js
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:

```ts
// 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 };
}
```

```tsx
"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.

```javascript
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

```ts
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)

```ts
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`.

```ts
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:

| Option | Values |
| --- | --- |
| `width` / `height` | Integers from 1 to 2500 |
| `resize` | `cover` (default — maintains aspect ratio and crops) / `contain` (maintains aspect ratio, fits within bounds) / `fill` (no aspect ratio preservation) |
| `quality` | 1 to 100 (defaults to 80) |
| `format` | `origin` to preserve the original format; otherwise automatic optimization applies |

Usable from three methods:

```ts
// 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:

| Item | Value |
| --- | --- |
| Plan | Pro and above |
| Included transformations | 100 per month (Pro / Team) |
| Overage | $5 per 1,000 origin images |
| Maximum image size | 25MB |
| Maximum resolution | 50MP |
| Width / height | Integers 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

| Item | Free | Pro |
| --- | --- | --- |
| Storage | 1 GB included | 100 GB included, then $0.0213/GB |
| Cached egress | 5 GB included | 250 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](/blog/supabase-rls-performance-optimization-select-wrap-index-guide) 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.
