File uploads
The file field, bring-your-own upload collection, and the server-side trust boundary.
The file field lets visitors attach files. The client uploads to your upload collection and submits only the upload id; the server re-reads the stored document and enforces the constraints itself. Client-sent filenames, MIME types, and sizes are never trusted or stored.
Bring your own collection
Uploads are off by default: without an upload collection a file field would have nowhere to store anything, so the file type is removed from the field registry entirely and form authors never see it. Opt in by pointing the plugin at an upload-enabled collection your app owns, created with your storage adapter of choice:
formBuilder({
uploads: { collection: 'form-attachments' },
})export const FormAttachments: CollectionConfig = {
slug: 'form-attachments',
upload: true, // or an upload options object; add your storage adapter as usual
access: {
create: () => true, // public forms must upload anonymously
},
fields: [],
}The collection is yours: storage adapter (S3 and friends or staticDir), access control, extra fields, admin placement all stay in your hands. The plugin wires its own concerns into it at boot:
- Validation: a missing collection, or one without an
uploadconfig, throws a descriptive error at startup, so a typo'd slug can never silently disable enforcement. - Ownership: a hidden
ownertext field is appended for ownership scoping, unless you already define a field namedowner(then yours is reused and must stay a text-compatible identity slot). - Spam hooks: the upload rate limit and owner stamp are prepended ahead of your own hooks, so abuse checks run first.
The configured slug is stamped onto every file block server-side on each form save (a hidden uploadsCollection block field), so the client renderer always uploads to the collection from plugin config, never an author- or client-controlled value.
The field
{
blockType: 'file',
name: 'resume',
label: 'Resume',
required: true,
mimeTypes: ['application/pdf', 'image/*'],
maxSize: 5_000_000, // bytes
}In the admin, mimeTypes is a multi-select over a curated list of common types and type/* wildcards (images, PDF, Office documents, audio, video, and more); the list is exported as fileMimeTypeOptions. maxSize is entered in bytes and live-previews as a human-readable size (= 2.5 MB) while typing.
The submitted value becomes a self-describing FileRef, captured server-side from the upload document:
type FileRef = { id: string | number; filename: string; mimeType: string; filesize: number; url?: string }The admin answers view renders it as a download link when a url is present.
Server enforcement
On submit, the server loads the referenced upload and re-validates it against the field's mimeTypes and maxSize, then captures the authoritative FileRef. A missing, disallowed, or oversize file blocks the submission with a per-field error. Required presence rides the normal required rule.
A file field nested inside a repeater sub-field is captured and enforced per row exactly like a top-level one, so give any repeater that contains a file sub-field a maxRows to bound the per-submission upload lookups.
Uploads on the public path are also rate limited and ownership-scoped by default; see Spam protection.
Renderer
The built-in file renderer (and its shadcn parity) uploads on change to the stamped collection and stores the returned id. It shows a hint line with the accepted types and maximum size, and pre-checks the selected file's size before uploading, so an oversize file fails instantly with a readable message instead of a round trip (the server check in the previous section remains the authority). formatBytes is exported from the root and from ./react for the same human-readable sizes in custom renderers. uploadFile is the underlying helper:
import { uploadFile } from '@10x-media/form-builder/react'
const result = await uploadFile({ file, collection: field.uploadsCollection })
if (result.ok) {
// result.id is the value to store on the field
}Multiple files per field is a planned follow-up.