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:
- Multer reads the file into memory
- Express validates it
- Express uploads the buffer to a Supabase Storage bucket
- 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
- Yarn
- pnpm
- Bun
npm install multer
yarn add multer
pnpm add multer
bun add multer
Use memoryStorage(), so the file stays in memory as a buffer and is never written to the server's disk:
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
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: savefilePathin yourbookstable (for example acover_pathcolumn). 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:
// 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
- Create a
POSTrequest tohttp://localhost:9999/covers/1 - Open Body > form-data
- Add a key named
cover, change its type from Text to File, and select an image - Send the request and open the returned
urlin 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.