Files

app.files wraps the per-tenant upload endpoint backed by Vercel Blob. Every upload is scoped to the signed-in user and returns a FileInfo with a stable public URL.

yourapp.com/files

Drop files here

or click to choose — up to 10 MB per file

PDF

invoice-2026-04.pdf

412 KB

JPG

team-photo.jpg

2.3 MB

MD

spec-notes.md

7 KB

Upload

const info = await app.files.upload(file);
console.log(info.url, info.id, info.size);

file is a browser File (from an <input type="file">) or a Blob. Optionally pass a filename as the second argument when uploading a raw Blob:

await app.files.upload(new Blob([csv], { type: 'text/csv' }), 'export.csv');

Retrieve and delete

const again = await app.files.get(info.id);
await app.files.delete(info.id);

Guardrails

  • Max size: 10 MB per upload. Bigger files return 413 file_too_large.
  • Disallowed MIME types: text/html, application/xhtml+xml, image/svg+xml, application/javascript, text/javascript, application/x-javascript. Rejected with 415 unsupported_mime_type to prevent stored XSS on the public CDN.
  • Filenames are sanitized: characters outside [a-zA-Z0-9._-] are replaced with -. The original filename travels in FileInfo.originalName.

Plan limits

If the tenant's plan caps total file count via a max_files-style entitlement, exceeding the cap returns 402 limit_reached with { feature, limit, current } in the response body — identical shape to the data-collection limits.

Full example — a drag-and-drop uploader

A complete React component that lets the user drop files, shows each upload inline, and lets them delete a file they don't want anymore.

// app/uploads/page.tsx
'use client';
import { useCallback, useEffect, useState } from 'react';
import { usePluralize } from '@pluralize/sdk/react';
import { PluralizeError, type FileInfo } from '@pluralize/sdk';
 
export default function UploadsPage() {
  const app = usePluralize();
  const [files, setFiles] = useState<FileInfo[]>([]);
  const [uploading, setUploading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [dragging, setDragging] = useState(false);
 
  // Load existing files on mount. Files listing uses the data collection that
  // your upload flow writes to; here we assume you keep your own `uploads`
  // index collection. Skip this effect if you don't.
  useEffect(() => {
    app.db
      .collection('uploads')
      .find(undefined, { sort: [['created_at', 'desc']] })
      .then((res) => setFiles(res.records.map((r) => r.data as FileInfo)));
  }, [app]);
 
  const onFiles = useCallback(
    async (list: FileList | null) => {
      if (!list || list.length === 0) return;
      setError(null);
      setUploading(true);
      try {
        for (const f of Array.from(list)) {
          const info = await app.files.upload(f);
          await app.db.collection('uploads').insert(info);
          setFiles((prev) => [info, ...prev]);
        }
      } catch (err) {
        if (err instanceof PluralizeError) {
          if (err.code === 'file_too_large') setError('File is larger than 10 MB.');
          else if (err.code === 'unsupported_mime_type')
            setError('That file type is not allowed.');
          else if (err.code === 'limit_reached') setError('Upgrade to store more files.');
          else setError('Upload failed. Try again.');
        } else {
          setError('Upload failed. Try again.');
        }
      } finally {
        setUploading(false);
      }
    },
    [app],
  );
 
  async function onDelete(id: string) {
    await app.files.delete(id);
    setFiles((prev) => prev.filter((f) => f.id !== id));
  }
 
  return (
    <main className="mx-auto max-w-xl space-y-4 p-6">
      <label
        onDragOver={(e) => {
          e.preventDefault();
          setDragging(true);
        }}
        onDragLeave={() => setDragging(false)}
        onDrop={(e) => {
          e.preventDefault();
          setDragging(false);
          onFiles(e.dataTransfer.files);
        }}
        className={`block cursor-pointer rounded-xl border-2 border-dashed px-6 py-10 text-center ${
          dragging ? 'border-black bg-muted/50' : 'border-muted-foreground/30'
        }`}
      >
        <p className="text-sm font-medium">
          {uploading ? 'Uploading…' : 'Drop files or click to choose'}
        </p>
        <p className="mt-1 text-xs text-muted-foreground">Up to 10 MB per file</p>
        <input
          type="file"
          multiple
          className="hidden"
          onChange={(e) => onFiles(e.target.files)}
        />
      </label>
 
      {error && <p className="text-sm text-red-600">{error}</p>}
 
      <ul className="divide-y rounded-md border">
        {files.map((f) => (
          <li key={f.id} className="flex items-center gap-3 px-3 py-2 text-sm">
            <span className="flex-1 truncate">{f.originalName ?? f.id}</span>
            <a
              href={f.url}
              target="_blank"
              rel="noreferrer"
              className="text-xs text-muted-foreground hover:underline"
            >
              Open
            </a>
            <button
              onClick={() => onDelete(f.id)}
              className="text-xs text-muted-foreground hover:text-red-600"
            >
              Delete
            </button>
          </li>
        ))}
      </ul>
    </main>
  );
}

Error reference

| Status | code | Cause | | --- | --- | --- | | 400 | no_file | The request did not include a file field. | | 402 | limit_reached | Tenant is at the max_files cap. | | 413 | file_too_large | File exceeded 10 MB. | | 415 | unsupported_mime_type | MIME type is on the blocklist. |