Skip to content
All posts
Build GuidesNext.jsSaaSSupabase

Next.js File Uploads for SaaS Apps

next.js file uploads need signed storage, metadata, permissions, and a clean UX. Here is the production pattern for SaaS apps.

Build My App Fast · Sep 27, 2026 · 12 min read

Next.js file uploads should usually go straight from the browser to object storage through a signed upload URL, while your Next.js app handles authentication, permissions, validation, and database metadata. That is the safest default for a SaaS product because it avoids pushing large files through your app server, keeps ownership rules explicit, and gives you a clean record of every uploaded file.

A basic upload button is easy. A production upload system is not just an input field. You need to decide where files live, who can read them, how long download links last, how size and file type limits are enforced, and what happens when an upload fails halfway through. If the feature touches billing, teams, user-generated content, AI processing, or sensitive documents, file uploads become part of your app architecture, not a UI detail.

This is the pattern we use when building SaaS apps on Next.js, Supabase, Tailwind, Vercel, and the rest of the modern founder stack.

The production pattern for next.js file uploads

Diagram of next.js file uploads moving from browser to signed storage and database metadata

For most SaaS apps, the right flow looks like this:

  1. The user selects a file in the browser.
  2. The browser asks your Next.js backend for permission to upload.
  3. The backend checks auth, plan limits, file type, file size, and ownership.
  4. The backend creates a signed upload URL or signed upload token with your storage provider.
  5. The browser uploads the file directly to storage.
  6. The app writes or updates a database row containing the file metadata.
  7. Future reads use signed download URLs or protected proxy routes.

That keeps your Next.js app in control without turning it into a file-transfer server.

The alternative is sending the whole file to an API route or server action and then forwarding it to storage. That can work for small files like avatars, but it gets fragile quickly. Serverless functions and edge runtimes have body-size, memory, and execution limits. Those limits vary by hosting platform and plan, and they are not the place to build your core document pipeline.

Use a server route to authorize the upload. Use storage to receive the file.

The official Next.js Route Handlers documentation is a useful reference for building the backend endpoints that issue upload permissions.

Choose storage before you build the UI

Do not start with the upload component. Start with the storage model. Your UI depends on whether files are public, private, team-scoped, temporary, or attached to paid accounts.

Upload typeRecommended storage patternNotes
User avatarsPublic or signed storage with image resizing laterLow sensitivity, usually small files
Private documentsPrivate bucket with signed download URLsGood default for SaaS dashboards
Team filesPrivate bucket with org/team path prefixRequires team-level authorization
CSV importsPrivate bucket plus processing statusTreat as a job, not just a file
AI knowledge base filesPrivate bucket plus extraction pipelineStore original file and processed output separately
Receipts or invoicesPrivate bucket, durable metadata, audit trailPermissions matter more than UI polish

For a Next.js SaaS using Supabase, Supabase Storage is often the fastest practical choice because it sits near your auth and database model. You can use private buckets, signed upload URLs, and signed download URLs without stitching together too many services. The official Supabase Storage signed upload URL docs show the API surface.

If your app already has a larger AWS footprint, S3 is also a solid option. The architecture is similar: your backend signs an upload request, the browser uploads directly, and your database stores metadata.

A practical upload flow in Next.js and Supabase

Here is the shape of a production-friendly implementation.

First, the browser asks your app for an upload token. It sends only metadata, not the file itself:

// app/api/uploads/sign/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";

const ALLOWED_TYPES = ["application/pdf", "image/png", "image/jpeg"];
const MAX_BYTES = 10 * 1024 * 1024;

function safeFileName(name: string) {
  return name.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 120);
}

export async function POST(request: Request) {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { fileName, contentType, size } = await request.json();

  if (!ALLOWED_TYPES.includes(contentType)) {
    return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
  }

  if (size > MAX_BYTES) {
    return NextResponse.json({ error: "File is too large" }, { status: 400 });
  }

  const path = `${user.id}/${crypto.randomUUID()}-${safeFileName(fileName)}`;

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

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

  await supabase.from("files").insert({
    owner_id: user.id,
    bucket: "uploads",
    path,
    original_name: fileName,
    mime_type: contentType,
    size_bytes: size,
    status: "pending",
  });

  return NextResponse.json({
    path,
    token: data.token,
    signedUrl: data.signedUrl,
  });
}

Then the browser uploads to storage with the token:

const signResponse = await fetch("/api/uploads/sign", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    fileName: file.name,
    contentType: file.type,
    size: file.size,
  }),
});

const { path, token } = await signResponse.json();

const { error } = await supabase.storage
  .from("uploads")
  .uploadToSignedUrl(path, token, file);

if (error) throw error;

await fetch("/api/uploads/complete", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ path }),
});

The /complete route should verify the logged-in user owns the path, check that the object exists if needed, then mark the row as uploaded or ready.

This two-step pattern matters. If you create the database row only after upload, you may lose track of abandoned attempts. If you mark a file complete before storage confirms the upload, your UI may show files that do not exist. A pending -> uploaded -> processed status model is boring, but it is reliable.

Database metadata is part of the feature

A file in storage is not enough. Your app needs a database row that explains what the file is and who controls it.

A simple starting table might include:

create table files (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null references auth.users(id),
  bucket text not null,
  path text not null unique,
  original_name text not null,
  mime_type text not null,
  size_bytes bigint not null,
  status text not null default 'pending',
  created_at timestamptz not null default now()
);

For team SaaS apps, add organization_id or workspace_id. Do not rely on path strings alone for authorization. Paths are useful for storage organization, but your app should authorize against database relationships.

If you are using Supabase, row-level security deserves real attention. A user should not be able to list, update, or delete another user’s file record. If teams exist, members should only see files belonging to workspaces they belong to. We covered the mental model in more detail in Supabase Row Level Security, Explained Simply.

The same applies to the storage bucket. A private bucket plus signed URLs is safer than assuming obscure paths are private. Obscurity is not authorization.

Security checklist for Next.js file uploads

Security checklist for next.js file uploads in a SaaS dashboard

File upload security is where many prototype apps quietly fail. The UI looks done, but the backend trusts too much.

Use this checklist before you ship:

  • Require authentication before issuing an upload token.
  • Check plan limits or feature access before upload, not after.
  • Enforce file size on the client and server.
  • Enforce MIME type on the server.
  • Normalize file names before placing them in paths.
  • Use private buckets for user documents.
  • Store metadata in your database.
  • Scope paths by user ID or organization ID.
  • Use signed download URLs for private files.
  • Do not expose service-role keys to the browser.
  • Add rate limiting for upload-signing endpoints.
  • Treat CSV, PDF, and document processing as asynchronous jobs.
  • Show upload status clearly in the UI.
  • Log failures in a way you can debug later.

The big mistake is trusting browser checks. Client-side validation is for user experience. Server-side validation is for security.

You should also be careful with AI-generated upload code. AI tools often create a demo that works locally: file input, API route, write to storage, display the link. What they frequently miss is permission design, storage policies, object ownership, large-file behavior, and cleanup for failed uploads. If you are turning a prototype into something customers will use, run it through a production checklist like How to Move Vibe Code to Production.

Downloading files without making them public

Uploads are only half the feature. You also need a read path.

For private SaaS files, avoid public URLs unless the file is meant to be public. Instead:

  1. User clicks a file in your app.
  2. Next.js route checks auth and database ownership.
  3. Route creates a short-lived signed download URL.
  4. Browser redirects to that URL or downloads it.

The database check is important. Storage can issue signed links, but your app decides who deserves one.

A typical route might accept a fileId, load the files row, verify ownership or workspace membership, then call storage to create a signed URL for row.path. Keep the signed URL lifetime short enough that leaked links are not useful forever.

If the file is a generated invoice, exported report, or processed AI output, the same model works. The app authorizes the user, not the URL path.

UX details that make uploads feel finished

A production upload feature should feel predictable even when something goes wrong.

At minimum, include:

  • File type hints near the upload control.
  • Maximum file size text before the user selects a file.
  • Progress state while the file uploads.
  • A clear failure state with retry.
  • A disabled submit button while upload is incomplete.
  • A way to remove or replace the uploaded file.
  • A visible status if processing happens after upload.

For CSV imports or AI document ingestion, do not make the user stare at a spinner while your app parses the file. Upload first, show Processing, and let the backend job update status. If processing can take a while, send a transactional email when it finishes. We use Resend for that pattern; see Resend Transactional Email in Next.js for the surrounding setup.

These details are not polish for polish’s sake. They reduce support requests. Users want to know whether the app has their file, whether it worked, and what to do if it did not.

Where file uploads fit in a SaaS MVP

File uploads can be a small feature or the core product. The implementation should match the risk.

For a marketplace avatar or a profile logo, a simple private-or-public storage setup may be enough. For a B2B app that stores contracts, medical paperwork, HR documents, financial exports, or customer data, uploads need real authorization and audit thinking from the start.

This is why we scope upload features explicitly when building fixed-price MVPs. A vague requirement like “users can upload files” is not enough. We want to know:

  • What file types are allowed?
  • What is the maximum size?
  • Are files private, public, or team-scoped?
  • Who can delete files?
  • Does the app need previews?
  • Does the app process the file after upload?
  • Does billing limit storage or file count?
  • Are files part of an admin workflow?

That level of specificity keeps the build small without making it flimsy. It is the same principle behind a good product brief and MVP scope. If you are still defining the product around this feature, The Anatomy of a Production-Ready SaaS Architecture is a useful companion read.

At Build My App Fast, this affects which build tier fits. A simple upload feature may fit a $1,000 proof of concept delivered in 2–4 days. A real SaaS app with logins, a database, private files, and user dashboards usually fits the $5,000 real app tier delivered in 4–6 days. If uploads connect to subscriptions, AI processing, external integrations, or more advanced workflows, that is usually a $10,000 launchable MVP delivered in 7–10 days.

The point is not to overbuild. The point is to choose the smallest upload system that will not collapse the first time real users touch it.

FAQ

Can I upload files directly through a Next.js API route?

Yes, especially for small files like avatars or simple admin uploads. But for a SaaS product, direct-to-storage uploads with signed URLs are usually safer and more scalable. Your API route should authorize the upload, not necessarily receive the entire file.

Should SaaS uploads be public or private?

Default to private unless the file is intentionally public. Profile images, marketing assets, and public attachments may be public. Customer documents, imports, invoices, and workspace files should usually be private with signed download URLs.

Do I need Supabase Storage if I already use Supabase Auth and Postgres?

You do not have to use it, but it is a practical fit. Supabase Storage works well with Supabase Auth, private buckets, signed URLs, and database metadata. If your app is already on Supabase, it keeps the MVP stack simpler.

What is the most common file upload mistake in Next.js apps?

The most common mistake is treating upload as only a frontend task. The real work is permissions, metadata, storage policy, file status, and safe download behavior. A nice upload button without those pieces is still not production-ready.

If you want a Next.js SaaS with production-ready file uploads, auth, database rules, and deployment handled on a fixed timeline, apply to Build My App Fast.