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
npx shadcn@latest add https://sdk-components.thesqd.com/r/file-upload-v2.jsonnpm install @uppy/core @uppy/react @uppy/aws-s3 \
@uppy/thumbnail-generator @uppy/golden-retriever \
@aws-sdk/client-s3 @aws-sdk/s3-request-presignerimport { FileUploadV2 } from "@/components/blocks/file-upload-v2";"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
/>
);
}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.jsonhttps://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.# 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_SECRETYour 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.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-filesPass `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
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
Wrapper — label / hint / error
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Field label rendered above the uploader. |
caption | ReactNode | — | Helper copy shown directly under the label via FieldLabel. |
error | boolean | 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. |
showNetworkStatus | boolean | false | Render 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". |
progressDecimals | number | 0 | Decimal 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
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | Form field name applied to the upload request body. |
allowMultiple | boolean | false | Accept more than one file in a single picker. |
instantUpload | boolean | true | Upload as soon as the user adds a file. |
server | FileUploadServerConfig["server"] | — | Custom upload pipeline. Most apps implement `process` and pass the response body to `load()` so it lands as the file's serverId. |
credits | boolean | true | Show the uploader branding in the corner. (v1 only.) |
restoreOnReload | boolean | true | v2 (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). |
id | string | "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. |
dropboxFolder | string | "/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. |
previousSubmissionAccount | string | — | 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. |
companionUrl | string | — | 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. |
googleClientId | string | — | 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. |
googleApiKey | string | — | v2 (Uppy) only. Browser API key for the Google Drive picker (Google Cloud → Credentials). Photos doesn't need one. |
googleAppId | string | — | v2 (Uppy) only. The Google Cloud project number, passed to the Drive picker as its `appId`. |
companionUrl | string | — | 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
| Prop | Type | Default | Description |
|---|---|---|---|
maxFiles | number | — | Cap how many files can be selected. |
maxFileSize | string | — | Per-file size limit, e.g. "10MB". |
maxTotalFileSize | string | — | Combined size cap across every selected file. |
acceptedFileTypes | string[] | — | Whitelist of MIME types, e.g. ["image/*", "application/pdf"]. |
imageValidateSizeMinWidth / MinHeight / MaxWidth / MaxHeight | number | — | Image dimension bounds (min/max width & height). |