File upload

Drag-and-drop uploader with validation for type, size, count, and image dimensions.

Pass `sources` to add the import-from row under the dropzone. Device and Record Screen work with no Companion; Link, Dropbox, Drive, and OneDrive route through a Companion server at `companionUrl`; Google Photos authorizes in the browser via `googlePhotosClientId`; Library reads the account's own files.

Drop files here, or browse

Up to 3 files · max 15GB each · Images, Videos

Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/file-upload-v2.json
Usage
The CLI install pulls every file the demo needs (component, registry deps, Uppy plugins, and the S3 + Dropbox upload routes). The component renders its own UI with Tailwind — there's no global CSS to wire up. Set `SQUAD_API_KEY` and it works.
$Terminal
npm install @uppy/core @uppy/react @uppy/aws-s3 \
  @uppy/thumbnail-generator @uppy/golden-retriever \
  @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
TSImport
import { FileUploadV2 } from "@/components/blocks/file-upload-v2";
TSExample
"use client";

import { FileUploadV2 } from "@/components/blocks/file-upload-v2";

// Single component, every "variant" driven by props:
//   label / caption / error / restrictionText / showUploadedUrls
//   acceptedFileTypes / maxFileSize / maxFiles / maxTotalFileSize / allowMultiple
//   imageValidateSizeMin/MaxWidth/Height
//   platform ("s3" | "dropbox") / dropboxFolder
export function Example() {
  return (
    <FileUploadV2
      label="Files"
      allowMultiple
      maxFiles={2}
      maxFileSize="10MB"
      acceptedFileTypes={["image/*"]}
      restrictionText="Images only"
      showUploadedUrls
    />
  );
}
Post-install wiring
Things the registry can't do for you (the env var). Skip it and you'll get a 500 from `/api/s3-presign` (or `/api/dropbox-upload`) on the first upload.
What `shadcn add file-upload-v2.json` drops in for you:
- src/components/blocks/file-upload-v2.tsx           (the Uppy component)
- src/components/ui/{alert,label,field-hint}.tsx     (registry deps)
- src/app/api/s3-presign/route.ts                    (S3 presign proxy, small files)
- src/app/api/s3-multipart/route.ts                  (multipart signing, files > 100MB)
- src/app/api/dropbox-upload/route.ts                (Dropbox finalize route)
- src/app/api/previous-submissions/route.ts          (From previous submission source)
- src/components/blocks/file-upload-submissions-grid.tsx  (its picker UI)
- .env.example                                       (the env vars the routes read)
- npm: @uppy/core, @uppy/react, @uppy/aws-s3,
       @uppy/thumbnail-generator, @uppy/golden-retriever,
       @aws-sdk/client-s3, @aws-sdk/s3-request-presigner

No global CSS wiring needed — the Uppy block renders its
own item cards with Tailwind, so there's no stylesheet to import.

What you still have to wire up by hand:
1. Copy `.env.example` → `.env.local` and set `SQUAD_API_KEY`. Both the S3
   presign route and the Dropbox route read it. Without it, uploads 500.

2. Brand-new project? Install the theme first (tokens, `cn`, shared deps):
     npx shadcn@latest add https://sdk-components.thesqd.com/r/theme.json
Upload backend
The component is a client for a shared Droplet — your app hosts none of it. Read the topology before debugging a stall: `platform='dropbox'` and `platform='wasabi'` take different paths through it, and most 'it hangs at 90%' reports are a CORS origin or a mismatched relay secret rather than anything in React.
TXTTopology
https://uploads.thesqd.dev   (DigitalOcean Droplet "uppy-stack", tor1, 2 vCPU / 4 GB)

The old https://uppy-companion.thesqd.com answers nothing: its CNAME still
points at the App Platform app that was deleted on 2026-09-04, so the name does
not resolve at all. uploads.thesqd.dev is the only host.

  caddy ──┬─ /files/*  ──> tusd           upload destination for platform="wasabi"
          │                              (CLOSED — 403 at the edge, see below)
          ├─ /relay/*  ──> dropbox-relay  upload destination for platform="dropbox"
          └─ /*        ──> companion      OAuth, Google Picker, OneDrive, import-from-URL,
                                          and the upload-progress websocket
                           redis          Companion session / token / progress store
                           vector         ships every container log to sis.uppy_logs

/files/ is currently returning 403 to the public. tusd has no authentication of
its own — an anonymous POST answered 201 and wrote into the Wasabi bucket — and
CORS is browser-only, so it is no defence. Nothing uses it yet: device uploads
still go through @uppy/aws-s3 presigned PUTs. It reopens once a tus pre-create
hook validates a short-lived, path-scoped token.

Two destinations, chosen by the `platform` prop, and they are not interchangeable:

  platform="wasabi"    browser or Companion ──tus──> tusd ──> Wasabi S3
  platform="dropbox"   browser or Companion ──tus──> dropbox-relay ──> Dropbox

platform="dropbox" never writes a byte to Wasabi. Companion has no Dropbox
destination of its own and tusd only speaks S3/GCS/Azure/filesystem, so the
relay is the translator that turns tus PATCHes into a Dropbox upload session.
It is not an optional fifth wheel — without it, client assets land in the
staging bucket instead of the client's folder.

Companion's own upload leg stays inside the Droplet: caddy holds a Docker
network alias for both hostnames, so Companion resolving either one gets
caddy's container IP. Same URL for browser and server, one certificate, and a
remote-source import never leaves the box on its way to storage.
$.env.local
# Parent app (.env.local). The component needs nothing else.
SQUAD_API_KEY=...                                          # /api/s3-presign + /api/dropbox-upload read this
NEXT_PUBLIC_COMPANION_URL=https://uploads.thesqd.dev       # import sources; omit and the "Or import from" row hides
NEXT_PUBLIC_PRESIGN_URL=/api/s3-presign                     # optional, this is the default

# Dropbox destination only — prf4 mints the short-lived relay token
DROPBOX_RELAY_URL=https://uploads.thesqd.dev/relay
DROPBOX_RELAY_SECRET=...                                    # must equal the Droplet's RELAY_SECRET
TXTSetup & runbook
Your app does NOT host any of this — it is shared infrastructure. Point at it
and you are done. What follows is for the person who has to rebuild or debug it.

Adding a new parent app (the only step most people need)
  1. Add the app's origin to COMPANION_CLIENT_ORIGINS on the Droplet, and to
     TUSD_CORS_ORIGIN_REGEX if it uploads to Wasabi. Companion and tusd both
     fail CORS closed, so an origin that is not listed sees the picker open and
     every request refused.
  2. Set the env vars above.
  3. For the Dropbox destination, DROPBOX_RELAY_SECRET must match the Droplet's
     RELAY_SECRET or /api/dropbox-relay-token answers 503 and picker imports
     stop. Device uploads keep working, which makes this a confusing one.

Rebuilding the Droplet from scratch
  Config lives in prf4 at infra/uppy-droplet/ — docker-compose.yml, Caddyfile,
  bootstrap.sh, deploy.sh, .env.example. Full runbook in its README.md.

  1. Droplet: Ubuntu 24.04, s-2vcpu-4gb, tor1, SSH key attached. Size for
     bandwidth, not cores — Companion saturates the NIC long before the CPU.
  2. ssh root@<ip> 'bash -s' < bootstrap.sh      # docker, swap, sysctls, systemd unit
  3. doctl registry login                        # the dropbox-relay image is in DOCR
  4. cp .env.example .env && $EDITOR .env        # gitignored; never commit it
  5. ./deploy.sh <ip> --with-env                 # first run, to seed secrets
     ./deploy.sh <ip>                            # afterwards: config only.
     The Droplet's .env is authoritative for secrets and is NOT pushed
     without --with-env, which also refuses if the local copy still has
     REPLACE_ME in it.
  6. Firewall: inbound 22/80/443 tcp + 443 udp; outbound tcp AND udp
     (udp 53, or the box cannot resolve DNS at all)
  7. DNS: uploads.thesqd.dev is on Vercel DNS. Under Domains -> thesqd.dev ->
     DNS Records add A / name "uploads" / the Droplet IP / TTL 60 — a DNS
     record, NOT a connected project domain, which would route to a Vercel
     deployment instead. It overrides the existing *.thesqd.dev wildcard.
     Caddy issues the certificate seconds after that lands; before it lands
     ACME fails and retries, which is expected. thesqd.dev's CAA already
     allows letsencrypt.org, so renewal is fine.
     (The legacy uppy-companion.thesqd.com is at GoDaddy, not DigitalOcean.)

Why a Droplet and not App Platform
  App Platform reaped an idle connection at ~100s. Companion only emits
  progress when a PATCH completes and its websocket carries nothing else, so a
  PATCH waiting behind a Dropbox rate limit meant a silent socket, a reaped
  socket, a discarded upload token — and the whole file restarting from zero.
  It also capped request bodies near 100MB, had no persistent disk, and ran on
  1 shared vCPU: a 9.2GB transfer took 52 minutes at ~3MB/s. On the Droplet
  none of those limits exist and the proxy timeouts are ours to set.

Operating it
  ssh root@165.245.232.225
  cd /srv/uppy && docker compose ps
  docker compose logs -f companion        # or tusd / dropbox-relay / caddy
  systemctl restart uppy.service

  Every remote-source file crosses the box twice — in from Drive/OneDrive, out
  to storage — so a 5GB video costs 10GB against the 4TB/mo allowance.
Composition
Anatomy of the uploader.
FileUploadV2                        (Uppy engine — one flat component)
├── @uppy/core                    (reactive state: files, progress, restrictions)
├── @uppy/aws-s3                  (both platforms: presign → PUT to Wasabi;
│                                  multipart above 100MB, per-part retry + resume)
├── @uppy/golden-retriever        (IndexedDB — survives a refresh or crash mid-upload)
├── postprocess step              (platform="dropbox": public URL → /api/dropbox-upload)
├── @uppy/thumbnail-generator     (row thumbnails for image files)
├── restrictions                  (maxFileSize / maxNumberOfFiles / maxTotalFileSize / allowedFileTypes)
└── retry                         (per-part retry inside multipart, then 3 backoff
                                   retries of the whole file — fresh presign each
                                   time; offline waits for the connection, then
                                   retries every errored file)

├── file-upload-motion.tsx        (dropzone + animated attachment rows)
├── file-upload-sources.tsx       (our own Link / Record Screen /
│                                  From previous submission /
│                                  Dropbox / OneDrive browsers + the Google
│                                  picker panels — Uppy's Dashboard is never
│                                  mounted. Dropbox and OneDrive browse through
│                                  Companion; Drive and Photos are Google's own
│                                  pickers. Either way the pick becomes an Uppy
│                                  remote file, so Companion pushes the bytes)
├── file-upload-google-auth.ts    (one GIS token, both Google scopes — sign in
│                                  to Drive or Photos and the other is done)
└── file-upload-events.ts         (CloudEvents payload types behind onEvent)

Rows render from useUppyState — no DOM scraping for progress. Image
dimension bounds are checked in a file-added handler (Uppy has no built-in
dimension restriction). Pair with the relevant route handler:
  • S3:      src/app/api/s3-presign/route.ts      → api.thesqd.com /v1/s3/get-presigned-upload-url
  • Large:   src/app/api/s3-multipart/route.ts    → signs create/part/complete/list/abort on Wasabi
  • Dropbox: src/app/api/dropbox-upload/route.ts  → api.thesqd.com /v1/dropbox/upload-files
Import sourcesv2
Pass `sources` to add the "Or import from" row under the dropzone: Google Drive, Google Photos, Dropbox, OneDrive, Link, Record Screen, and From previous submission. Every chip and panel is ours — Uppy's Dashboard is never mounted. Google Drive and Google Photos are browser-side pickers sharing one authorization (sign into either and the other is already signed in), Link posts through @uppy/url, Record Screen is `getDisplayMedia` + MediaRecorder, From previous submission reads the account's past form uploads from `/api/previous-submissions` and attaches them by URL rather than re-uploading, Dropbox and OneDrive open a `card-surface` browser driven by each plugin's Companion client (set `companionUrl`; server-side OAuth, so they work on any host — no browser origin whitelisting). Remote picks are added as Uppy remote files, so Companion still pushes the bytes to your chosen `platform`.

Pass `sources` to add the import-from row under the dropzone. Device and Record Screen work with no Companion; Link, Dropbox, Drive, and OneDrive route through a Companion server at `companionUrl`; Google Photos authorizes in the browser via `googlePhotosClientId`; Library reads the account's own files.

Drop files here, or browse

Up to 3 files · max 15GB each · Images, Videos

Upload to a project folderv2
The same uploader as above — same `sources`, same import row — with `platform="dropbox"` and `dropboxFolder="/Project Staging/Design/306 - Bogus Summit Sanctuary"`. Files land on S3 first — one presigned PUT under 100MB, resumable multipart above that — and the Squad API then pulls that URL into the Dropbox folder. The folder must already exist (or sit under one that does): the Dropbox app can't create folders at the account root. Everything else on this page uploads to S3.

Same uploader as above, but every file lands in Project Staging / Design / 306 - Bogus Summit Sanctuary.

Drop files here, or browse

Up to 3 files · max 15GB each

API Reference
Most-used props. The wrapper props are the same on either engine; engine-specific props are flagged in the table.

Wrapper — label / hint / error

PropTypeDefaultDescription
labelstring—Field label rendered above the uploader.
captionReactNode—Helper copy shown directly under the label via FieldLabel.
errorboolean | string—Static error state. `true` applies the red panel only; a string also renders the inline message (`text-xs mt-1.5 text-red-500`) below the uploader. Overridden by runtime validation messages.
showNetworkStatusbooleanfalseRender a live network-quality + estimated-time-left readout under the uploader while files are in flight (e.g. "Network good · 42.5% · ~1m 20s left"). Upload rate is sampled over a fixed window and EMA-smoothed; a stall reads as "Reconnecting…" and going offline shows "Offline — will resume".
progressDecimalsnumber0Decimal places for the overall percent in the network readout (`0` → "42%", `1` → "42.5%"). Only applies with `showNetworkStatus`.
onwarning(err, file?, status?) => void—Validation warning callback (e.g. file count exceeded). (v1 engine) Displays `err.body ?? err.main` as an amber-600 message below the uploader. Clears on a successful `onaddfile`.
onerror(err, file?, status?) => void—Upload error callback (e.g. server upload failure). (v1 engine) Displays `err.body ?? err.main` as a red-500 message below the uploader. Clears on a successful `onaddfile`.

Core

PropTypeDefaultDescription
namestring—Form field name applied to the upload request body.
allowMultiplebooleanfalseAccept more than one file in a single picker.
instantUploadbooleantrueUpload as soon as the user adds a file.
serverFileUploadServerConfig["server"]—Custom upload pipeline. Most apps implement `process` and pass the response body to `load()` so it lands as the file's serverId.
creditsbooleantrueShow the uploader branding in the corner. (v1 only.)
restoreOnReloadbooleantruev2 (Uppy) only. Persist in-flight uploads (Golden Retriever, IndexedDB) so a refresh, crash, or closed tab doesn't lose them — a restored large file resumes from the parts S3 already has. Files over 10MB come back as metadata for the user to re-pick (no service worker shipped).
idstring"squad-file-upload"v2 (Uppy) only. Identity for this uploader's persisted state. Must be unique per uploader on a page and stable across reloads for `restoreOnReload` to find its files again.
platform"s3" | "dropbox""s3"v2 (Uppy) only. Where uploads land. Both platforms PUT the bytes straight to Wasabi S3 from the browser; `dropbox` then hands the resulting public URL to the Squad API via `/api/dropbox-upload` so the file also lands in Dropbox. Read once at mount.
dropboxFolderstring"/Client Assets/Squad SDK Uploads"v2 (Uppy) only. Dropbox folder when `platform="dropbox"`; the file name is appended. Must sit under an existing top-level folder — the Dropbox app can't create folders at the account root.
sources("device" | "url" | "screenCapture" | "googleDrive" | "dropbox" | "oneDrive" | "googlePhotos" | "previousSubmission")[]—v2 (Uppy) only. Import sources shown in the "Or import from" row under the dropzone — our own tiles and panels, never Uppy's Dashboard. Omit for all of them; pass an array to allow-list a subset, or `[]` to drop the row. A source only appears once it can work, so the default is safe: no `companionUrl` and Link/Drive/Dropbox/OneDrive/Photos never register (Companion still fetches the bytes for the Google pickers); no `previousSubmissionAccount` and From previous submission doesn't either. `device` is the dropzone itself and has no tile; `screenCapture` is `getDisplayMedia` in the browser; `googleDrive` and `googlePhotos` are browser-side pickers sharing one authorization, so they also need `googleClientId` (and Drive `googleApiKey` / `googleAppId`).
excludeSources("device" | "url" | "screenCapture" | "googleDrive" | "dropbox" | "oneDrive" | "googlePhotos" | "previousSubmission")[]—v2 (Uppy) only. Sources to drop from whatever `sources` allows (all of them, by default). `excludeSources={["screenCapture"]}` is the everything-but-one case, which an allow-list can't express without restating the whole set.
previousSubmissionAccountstring—v2 (Uppy) only. Account (member) number behind the `previousSubmission` source. The tile opens every file this account has attached to a past form submission — read from `/api/previous-submissions`, which pairs the bucket's own upload log (`files.files`, under the `<account>/form_uploads/` prefix) with the request each file came in on (`forms.form_submissions` → `forms.forms`). One row per file, searchable by filename or by the form it was submitted on. Picking attaches by URL; nothing is re-uploaded. Without this prop the tile isn't registered.
companionUrlstring—v2 (Uppy) only. Base URL of your `@uppy/companion` server. Required for the `url`, `dropbox`, `googleDrive`, `oneDrive`, and `googlePhotos` sources.
onEvent(event: FileUploadEvent) => void—v2 (Uppy) only. The uploader's full lifecycle, one callback per event, shaped as a [CloudEvents 1.0](https://cloudevents.io) envelope: `specversion`, `id`, `source` (`/squad-ui/file-upload/<your id prop>`, so two uploaders on one form are distinguishable), `type` (`com.thesqd.file-upload.<name>`), `time`, `subject` (the file id), and `data`. Ten types fire: `file.added`, `file.rejected`, `upload.started`, `upload.progress`, `upload.succeeded`, `upload.failed`, `postprocess.started`, `file.stored`, `file.removed`, `batch.completed`. Emission is deliberately over-complete — keep what you need. Platform-specific detail is nested rather than flattened: `data.file.origin` describes where a file came from (`device` / `link` / `screenCapture` / `previousSubmission` / `provider`, the last carrying the Companion provider key, remote file id, folder path and author), and `data.destination` on `file.stored` describes where it landed (`s3` with key, bucket and the service response; `dropbox` with the final path and the S3 URL it was pulled from; `library` for an attached file that never moved). A throwing handler is caught — a consumer can't take an upload down.
googleClientIdstring—v2 (Uppy) only. Google OAuth web client id, shared by the `googleDrive` and `googlePhotos` sources. Both are browser-side pickers: the user authorizes once — a single token request carrying both scopes — and whichever tile they open second costs no prompt at all. Photos has no alternative (Google retired the Library API that Companion used to browse in March 2025); Drive is a picker so the two can share that authorization instead of prompting twice. Neither tile appears without this, and the client's **authorized JavaScript origins** must list every host the uploader runs on — otherwise Google refuses with `Error 400: origin_mismatch`. Companion still fetches the picked bytes, so `companionUrl` is required too.
googleApiKeystring—v2 (Uppy) only. Browser API key for the Google Drive picker (Google Cloud → Credentials). Photos doesn't need one.
googleAppIdstring—v2 (Uppy) only. The Google Cloud project number, passed to the Drive picker as its `appId`.
companionUrlstring—v2 (Uppy) only. Base URL of your `@uppy/companion` server. Required for the `url`, `dropbox`, `googleDrive`, `oneDrive`, and `googlePhotos` sources.
onEvent(event: FileUploadEvent) => void—v2 (Uppy) only. The uploader's full lifecycle, one callback per event, shaped as a [CloudEvents 1.0](https://cloudevents.io) envelope: `specversion`, `id`, `source` (`/squad-ui/file-upload/<your id prop>`, so two uploaders on one form are distinguishable), `type` (`com.thesqd.file-upload.<name>`), `time`, `subject` (the file id), and `data`. Ten types fire: `file.added`, `file.rejected`, `upload.started`, `upload.progress`, `upload.succeeded`, `upload.failed`, `postprocess.started`, `file.stored`, `file.removed`, `batch.completed`. Emission is deliberately over-complete — keep what you need. Platform-specific detail is nested rather than flattened: `data.file.origin` describes where a file came from (`device` / `link` / `screenCapture` / `previousSubmission` / `provider`, the last carrying the Companion provider key, remote file id, folder path and author), and `data.destination` on `file.stored` describes where it landed (`s3` with key, bucket and the service response; `dropbox` with the final path and the S3 URL it was pulled from; `library` for an attached file that never moved). A throwing handler is caught — a consumer can't take an upload down.

Validation

PropTypeDefaultDescription
maxFilesnumber—Cap how many files can be selected.
maxFileSizestring—Per-file size limit, e.g. "10MB".
maxTotalFileSizestring—Combined size cap across every selected file.
acceptedFileTypesstring[]—Whitelist of MIME types, e.g. ["image/*", "application/pdf"].
imageValidateSizeMinWidth / MinHeight / MaxWidth / MaxHeightnumber—Image dimension bounds (min/max width & height).