Skip to main content

File Storage

In the Multer doc, we saved uploaded files to the server's disk. That breaks as soon as you run more than one server, or deploy somewhere with a temporary disk. Supabase Storage keeps files in a bucket, much like AWS S3.

The flow:

  1. Multer reads the file into memory
  2. Express validates it
  3. Express uploads the buffer to a Supabase Storage bucket
  4. Express returns a URL to the file

Creating a Bucket​

In the dashboard, open Storage > New bucket:

  • Name: book-covers
  • Public bucket: on for files anyone may see (product images, avatars), off for private files (invoices, documents)
  • Allowed MIME types: image/jpeg, image/png, image/webp
  • File size limit: 2 MB

The bucket limits are enforced by Supabase, so they still apply if someone skips your Express checks.

Installing Multer​

npm install multer

Use memoryStorage(), so the file stays in memory as a buffer and is never written to the server's disk:

middlewares/upload.js
import multer from "multer";

const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];

export const uploadImage = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (!ALLOWED_TYPES.includes(file.mimetype)) {
return cb(new Error("Only JPEG, PNG and WEBP images are allowed"));
}
cb(null, true);
},
}).single("cover");

Uploading a File​

routes/cover.js
import { Router } from "express";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { supabaseAdmin } from "../config/supabase.js";
import { uploadImage } from "../middlewares/upload.js";

const router = Router();
const BUCKET = "book-covers";

router.post("/:bookId", (req, res) => {
uploadImage(req, res, async (err) => {
if (err) return res.status(400).json({ message: err.message });
if (!req.file) return res.status(400).json({ message: "cover file is required" });

// Never trust the original file name: generate our own
const ext = path.extname(req.file.originalname).toLowerCase();
const filePath = `books/${req.params.bookId}/${randomUUID()}${ext}`;

const { error } = await supabaseAdmin.storage
.from(BUCKET)
.upload(filePath, req.file.buffer, {
contentType: req.file.mimetype,
upsert: false,
});

if (error) return res.status(500).json({ message: error.message });

const { data } = supabaseAdmin.storage.from(BUCKET).getPublicUrl(filePath);

res.status(201).json({ path: filePath, url: data.publicUrl });
});
});

export default router;

A few things to notice:

  • Generated file names: the original name can contain ../, spaces, or another user's file name. A random UUID avoids path tricks and accidental overwrites.
  • upsert: false: uploading to a path that already exists returns an error instead of replacing the file.
  • Store the path, not the URL: save filePath in your books table (for example a cover_path column). You can always build the URL from the path, and the path still works if you later move the bucket from public to private.

Private Files with Signed URLs​

For a private bucket, getPublicUrl() returns a URL that does not work. Instead, create a signed URL that expires after a number of seconds:

routes/invoice.js (excerpt)
// requireAuth comes from the Auth doc. findInvoicePathForUser is your own
// query that returns the file path only if the invoice belongs to the user.
router.get("/:id/download", requireAuth, async (req, res) => {
// Look up the invoice first, and check that it belongs to req.user
const invoicePath = await findInvoicePathForUser(req.params.id, req.user.id);

if (!invoicePath) return res.status(404).json({ message: "Invoice not found" });

const { data, error } = await supabaseAdmin.storage
.from("invoices")
.createSignedUrl(invoicePath, 60);

if (error) return res.status(500).json({ message: error.message });

res.json({ url: data.signedUrl });
});

The URL works for 60 seconds, then stops. Because the server uses the admin client, you must check ownership before creating the URL, exactly like the S3 presigned URLs.

Deleting Files​

const { error } = await supabaseAdmin.storage
.from("book-covers")
.remove(["books/1/4f1c...e2.webp"]);

remove() takes an array, so you can delete many files in one call. When you delete a book, delete its cover too, or you will pay to store files nobody can reach.

Testing with Postman​

  1. Create a POST request to http://localhost:9999/covers/1
  2. Open Body > form-data
  3. Add a key named cover, change its type from Text to File, and select an image
  4. Send the request and open the returned url in your browser

Conclusion​

In this doc, we uploaded files to Supabase Storage with Multer's memory storage, validated type and size, generated safe file names, and served files through public URLs and short-lived signed URLs.