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.
Drop files here
or click to choose — up to 10 MB per file
invoice-2026-04.pdf
412 KB
team-photo.jpg
2.3 MB
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 with415 unsupported_mime_typeto prevent stored XSS on the public CDN. - Filenames are sanitized: characters outside
[a-zA-Z0-9._-]are replaced with-. The original filename travels inFileInfo.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. |