Add File Upload to Entity
How to let users upload files and images — presigned URLs, progress tracking, thumbnails
Add File Upload to Entity
TL;DR: Use
useUploadfor documents,useImageUploadfor images with auto-thumbnails. Files go directly to storage (S3/Supabase) via presigned URLs — your server never handles the binary data.
When You Need This
- Users need to attach documents (PDFs, spreadsheets) to an entity
- Users need to upload images with automatic thumbnail generation
- You want drag-and-drop upload with progress tracking
How It Works
Browser Your Server Storage (S3/Supabase)
│ │ │
├─ 1. Request upload URL ───────►│ │
│ ├─ Generate key + intent token │
│◄─ 2. Presigned URL + intent ──┤ │
│ │ │
├─ 3. Upload file directly ─────────────────────────────────────►│
│ (XHR with progress) │ │
│ │ │
├─ 4. Confirm upload ──────────►│ │
│ ├─ Verify intent token │
│ ├─ Check file exists in storage │
│ ├─ Save metadata to DB │
│◄─ 5. File record ────────────┤ │Your server never touches the file. It only generates secure URLs and tracks metadata.
Option A: Document Upload (PDFs, Excel, etc.)
Use the useUpload hook and <StorageFileUpload> component.
Step 1: Add the Upload Component
import { StorageFileUpload } from "@/lib/storage"
import { STORAGE_PATHS } from "@/lib/storage"
function InvoiceAttachments({ invoiceId }: { invoiceId: string }) {
const queryClient = useQueryClient()
return (
<StorageFileUpload
category={STORAGE_PATHS.DOCUMENTS}
multiple
onUpload={(files) => {
// Link uploaded files to your entity
linkFilesToInvoice(invoiceId, files)
queryClient.invalidateQueries({
queryKey: orpcUtils.invoices.key(),
})
}}
placeholder="Drop PDFs, Excel files, or documents here"
/>
)
}That's it for basic uploads. The component handles:
- Drag-and-drop zone
- File type and size validation
- Progress bars per file
- Error display
Step 2: Use the Hook Directly (for custom UI)
If you need more control over the UI:
import { useUpload } from "@/lib/storage"
import { STORAGE_PATHS } from "@/lib/storage"
function CustomUpload() {
const { upload, uploadMultiple, isUploading, progress, error, abort } =
useUpload({
category: STORAGE_PATHS.DOCUMENTS,
maxSize: 25 * 1024 * 1024, // 25 MB
onProgress: (progress, file) => {
console.log(`${file.name}: ${progress.percentage}%`)
},
onSuccess: (file) => {
toast.success(`${file.originalFilename} uploaded`)
},
onError: (error, file) => {
toast.error(`Failed to upload ${file?.name}`)
},
})
const handleFiles = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
await uploadMultiple(files) // Uploads with concurrency: 3
}
return (
<div>
<input type="file" multiple onChange={handleFiles} disabled={isUploading} />
{isUploading && <p>Uploading... {progress?.percentage}%</p>}
{error && <p className="text-destructive">{error.message}</p>}
<button onClick={abort}>Cancel</button>
</div>
)
}Option B: Image Upload (with Auto-Thumbnails)
Use the useImageUpload hook for images. It automatically generates a thumbnail on the client before uploading both files.
import { useImageUpload } from "@/lib/storage"
function AvatarUpload({ onUploaded }: { onUploaded: (key: string, thumbnailKey: string) => void }) {
const { upload, isUploading, progress } = useImageUpload({
maxSize: 10 * 1024 * 1024, // 10 MB
thumbnailWidth: 200,
thumbnailFormat: "image/webp",
thumbnailQuality: 0.8,
onSuccess: (file) => {
onUploaded(file.key, file.thumbnailKey)
},
})
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) await upload(file)
}
return (
<div>
<input type="file" accept="image/*" onChange={handleFile} disabled={isUploading} />
{isUploading && progress && (
<p>
{progress.stage === "generating-thumbnail" && "Generating thumbnail..."}
{progress.stage === "uploading-original" && `Uploading... ${progress.percentage}%`}
{progress.stage === "uploading-thumbnail" && "Uploading thumbnail..."}
{progress.stage === "confirming" && "Saving..."}
</p>
)}
</div>
)
}Image upload stages:
generating-thumbnail— resizes image on client using canvasuploading-original— uploads full-size image (10–50%)uploading-thumbnail— uploads thumbnail (50–90%)confirming— saves metadata to DB (90–100%)
Displaying Uploaded Files
Download URL
Files are stored with keys, not URLs. Generate a temporary download URL on demand:
// In your page or component
const handleDownload = async (fileId: string) => {
const response = await fetch(`/api/storage/files/${fileId}/download`)
const { url } = await response.json()
window.open(url, "_blank")
}Image with Thumbnail
// The download endpoint returns both URLs
const response = await fetch(`/api/storage/files/${fileId}/download`)
const { url, thumbnailUrl } = await response.json()
// Use thumbnailUrl for lists/cards, url for full-size preview
<img src={thumbnailUrl} alt={file.name} className="h-10 w-10 rounded object-cover" />Deleting Files
Always clean up both storage and database:
const deleteMutation = useMutation({
...orpcUtils.files.delete.mutationOptions(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: orpcUtils.files.key() })
toast.success(t("deleted"))
},
})
// With confirmation dialog
const handleDelete = async (fileId: string) => {
if (await confirm({ title: t("deleteConfirmTitle") })) {
deleteMutation.mutate({ id: fileId })
}
}The delete handler on the server removes:
- The original file from storage
- The thumbnail from storage (if it exists)
- The metadata from the database
File Categories and Limits
The storage system has predefined categories with size and type restrictions:
| Category | Max Size | Allowed Types |
|---|---|---|
PICTURES | 10 MB | JPEG, PNG, GIF, WebP, SVG |
THUMBNAILS | 10 MB | JPEG, PNG, GIF, WebP, SVG |
DOCUMENTS | 25 MB | PDF, Word, Excel, text, CSV |
AVATARS | 5 MB | Images only |
ATTACHMENTS | 50 MB | Images + documents |
EXPORTS | — | CSV, XLSX |
Import categories:
import { STORAGE_PATHS } from "@/lib/storage"
// Use in hooks
useUpload({ category: STORAGE_PATHS.DOCUMENTS })
useImageUpload() // defaults to PICTURESSecurity: How It Stays Safe
You don't need to implement any of this — it's built in:
- Server-side key generation — clients can't control the storage path. Keys are always
orgs/{orgId}/{category}/{uuid}-{filename} - Intent tokens (JWT) — the server creates a short-lived token binding the upload to a specific org, user, and key. Must be presented in the confirm step
- Organization isolation —
validateKeyOwnership()ensures you can't access another org's files - Presigned URL expiry — upload URLs expire in 60 seconds, download URLs in 1 hour
- Idempotent confirm — uploading the same file twice returns the existing record, not an error
Common Mistakes
Don't store URLs in the database:
// ❌ Wrong — URLs expire
await db.insert(files).values({ url: "https://s3.../file.pdf" })
// ✅ Correct — store the key, generate URLs on demand
await db.insert(files).values({ key: "orgs/abc/documents/uuid-file.pdf" })Don't skip the confirm step:
// ❌ Wrong — file uploaded to storage but no DB record
const { url, key } = await getUploadUrl(...)
await uploadToStorage(url, file)
// "done"... but the file is orphaned
// ✅ Correct — always confirm after upload
await uploadToStorage(url, file)
await confirmUpload({ intent, originalFilename, size, contentType })Don't upload through your server:
// ❌ Wrong — server becomes a bottleneck
app.post("/upload", async (req) => {
const file = req.body
await s3.putObject(file) // Your server handles all the bytes
})
// ✅ Correct — client uploads directly to storage
// Server only generates presigned URLs and tracks metadataVerify
npm run typecheckRelated
- Storage Configuration — Set up S3, Supabase, R2, or MinIO
- Add New Feature — Create the entity first
- Add Field to Entity — Add a file reference field to your entity