Recipes

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 useUpload for documents, useImageUpload for 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:

  1. generating-thumbnail — resizes image on client using canvas
  2. uploading-original — uploads full-size image (10–50%)
  3. uploading-thumbnail — uploads thumbnail (50–90%)
  4. 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:

  1. The original file from storage
  2. The thumbnail from storage (if it exists)
  3. The metadata from the database

File Categories and Limits

The storage system has predefined categories with size and type restrictions:

CategoryMax SizeAllowed Types
PICTURES10 MBJPEG, PNG, GIF, WebP, SVG
THUMBNAILS10 MBJPEG, PNG, GIF, WebP, SVG
DOCUMENTS25 MBPDF, Word, Excel, text, CSV
AVATARS5 MBImages only
ATTACHMENTS50 MBImages + documents
EXPORTSCSV, XLSX

Import categories:

import { STORAGE_PATHS } from "@/lib/storage"

// Use in hooks
useUpload({ category: STORAGE_PATHS.DOCUMENTS })
useImageUpload() // defaults to PICTURES

Security: How It Stays Safe

You don't need to implement any of this — it's built in:

  1. Server-side key generation — clients can't control the storage path. Keys are always orgs/{orgId}/{category}/{uuid}-{filename}
  2. 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
  3. Organization isolationvalidateKeyOwnership() ensures you can't access another org's files
  4. Presigned URL expiry — upload URLs expire in 60 seconds, download URLs in 1 hour
  5. 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 metadata

Verify

npm run typecheck

On this page