File uploads and storage
Skill param087/saas-starter-skills/skills/file-uploads-and-storage
Production-grade full-stack SaaS skills for AI coding agents — Next.js, Postgres/Drizzle, Auth, Stripe & Vercel patterns for Codex, Claude Code, Cursor & OpenCode.
npx -y skills add param087/saas-starter-skills --skill file-uploads-and-storageAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when handling user file uploads — upload directly to S3/R2/Supabase Storage with presigned URLs, validate type and size, scope objects per tenant, and serve them back securely without proxying bytes through your server.
SKILL.md
3.0 KB, as published. Nobody here has run it
File Uploads & Storage
Overview
Don't stream uploads through your app server — it's slow, memory-hungry, and hits serverless body limits. Instead, issue a presigned URL so the browser uploads directly to object storage (S3, Cloudflare R2, or Supabase Storage), then store only the object key + metadata in your DB. Validate type/size before signing, namespace keys per tenant, and serve private files via short-lived signed download URLs.
When to use
- Users upload avatars, attachments, imports, or documents.
- Files are large or uploads are slow/timing out through your API.
- You need per-tenant access control on stored files.
Presigned upload
// src/app/api/uploads/sign/route.ts
import { z } from "zod";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { requireOrg } from "@/server/auth/active-org";
const Body = z.object({
filename: z.string().max(200),
contentType: z.enum(["image/png", "image/jpeg", "application/pdf"]), // allowlist
size: z.number().int().positive().max(10 * 1024 * 1024), // 10MB cap
});
const s3 = new S3Client({ region: "auto" });
export async function POST(req: Request) {
const { org } = await requireOrg(new URL(req.url).searchParams.get("org")!);
const { filename, contentType } = Body.parse(await req.json());
const key = `orgs/${org.id}/${crypto.randomUUID()}/${filename}`; // tenant-scoped key
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: "uploads", Key: key, ContentType: contentType }),
{ expiresIn: 60 },
);
return Response.json({ url, key });
}
The client PUTs the file to url, then tells your API the key to persist (in a tenant-scoped row).
Serving files back
- Private by default. Buckets block public reads; generate short-lived signed GET URLs on demand.
- Authorize downloads — verify the requester's org owns the
keybefore signing. - Process async — thumbnails/virus-scan/transcode run in
background-jobs, keyed off the upload.
Pitfalls
- Proxying bytes through the API — memory blowups and serverless payload limits; use presigned URLs.
- Trusting client-reported content-type/size — allowlist types and cap size in the signing request; verify after upload.
- Public buckets for private data — leaks every file via a guessable URL. Keep private, sign reads.
- Un-namespaced keys — without
orgs/<id>/..., one tenant can reference another's objects. - Storing the file in Postgres — store the key + metadata; keep bytes in object storage.
Hand-off
Direct-to-storage uploads with tenant-scoped keys. background-jobs post-processes; data-access-layer stores keys; observability-and-errors tracks failures.