upload field
An upload() field stores one or more Convex Id references pointing to
documents in a media collection — a collection created via a storage
package’s defineMediaCollection() (e.g. @vexcms/file-storage-convex/client)
and declared directly on the client config’s mediaCollections. The
generated Convex schema always emits v.array(v.id("<to-slug>")) regardless
of hasMany — hasMany is a UI-only hint that switches the admin panel
between a single-file and multi-file picker, mirroring how relationship()
handles hasMany.
At defineConfig() time, to is validated against the client config’s own
mediaCollections array; referencing an unregistered collection throws
VexStorageConfigError. After running vex generate, to
is typed as MediaCollectionSlug — a compile-time union of every registered
media collection slug — so an invalid reference is also a TypeScript error.
Common uses: featured/hero images, photo galleries, PDF or document attachments, and any field that needs to reference an uploaded file instead of storing raw string data.
Config options
Section titled “Config options”| Option | Type | Default | Description |
|--------|------|---------|-------------|
| to | MediaCollectionSlug | (required) | Slug of the target media collection, defined via a storage adapter’s defineMediaCollection(). Validated against VexConfig.mediaCollections at defineConfig() time. |
| label | string | "" | Display label in the admin panel. Inferred from the field key by defineCollection when left empty. |
| required | boolean | false | When false, the field is wrapped in v.optional(…) in the schema. |
| hasMany | boolean | false | UI hint — false shows a single-file picker, true shows a multi-file picker. Does not change the Convex schema type, which is always v.array(v.id(...)). |
| min | number | 0 | Minimum number of files. Accepted by the config but not currently enforced by the Zod schema, the Convex validator, or the admin UI. |
| max | number | — | Maximum number of files. Enforced only in the admin UI — disables adding more files and shows a count/max indicator once reached; not enforced by the Zod schema or Convex validator. |
| accept | string | "" | MIME type restriction passed through to the file input’s accept attribute, e.g. "image/*" or "image/*, audio/mp4". |
| defaultValue | string[] | — | Pre-filled array of media document IDs shown when creating a new document. |
| description | string | — | Helper text shown below the field input. |
| admin.hidden | boolean | false | Hides the field from the admin edit form entirely. |
| admin.readOnly | boolean | false | Renders the picker/dropzone as non-interactive — existing files are shown but cannot be added or removed. |
| admin.position | "main" \| "sidebar" | "main" | Column in the admin edit layout where this field appears. |
| admin.width | string | "100%" | CSS width of the field in the edit form (e.g. "50%", "240px"). Meaningful as a child of row(); below a narrow form column every field renders full width regardless. |
| admin.cellAlignment | "left" \| "center" \| "right" | "left" | Alignment used for this field’s column in the list-view table. |
| admin.condition | (props: { data, siblingData }) => boolean | — | Shows the field only while this returns true. A throwing condition counts as met. Hidden fields skip required/validate on the server too. See the Conditional fields & masks guide. |
Schema output
Section titled “Schema output”// Single reference (hasMany: false, required: false — default)featuredImage: v.optional(v.array(v.id("images")))
// Required — still an array regardless of hasManyheroImage: v.array(v.id("images"))
// Multi-file gallery (hasMany: true, required: false)gallery: v.optional(v.array(v.id("images")))import { defineCollection, upload, text } from "@vexcms/core";
const posts = defineCollection({ slug: "posts", fields: { title: text({ required: true }),
// Single required featured image, restricted to image MIME types featuredImage: upload({ to: "images", required: true, accept: "image/*", }),
// Multi-file gallery, capped at 8 images in the admin UI gallery: upload({ to: "images", label: "Gallery", hasMany: true, max: 8, accept: "image/*", }),
// PDF attachment from a separate media collection resume: upload({ to: "documents", accept: "application/pdf", }), },});Media collections and storage adapters
Section titled “Media collections and storage adapters”upload() fields don’t work in isolation — they reference a media collection, declared
directly on the client config’s mediaCollections via a storage package’s
defineMediaCollection() (imported from its /client entry point — it’s client-safe, unlike
the adapter itself). The adapter that backs it is registered separately, on the server
config, via defineServerConfig({ server: { storage: { adapters: [...] } } }).
@vexcms/file-storage-convex is the built-in adapter; any adapter implementing
StorageAdapterBaseInterface (see the
Custom Storage Adapter guide) works the same way.
// vex.config.ts (client)import { defineConfig, defineCollection, upload, text } from "@vexcms/core";import { defineMediaCollection } from "@vexcms/file-storage-convex/client";
// `defineMediaCollection()` auto-injects the required base fields// (filename, alt, mimeType, size, storageId, deleted) plus the Convex// adapter's own fields (src, width, height). `fields` only needs// collection-specific additions — or an override of `alt`'s label/description.const images = defineMediaCollection({ slug: "images", fields: { alt: text({ required: true, label: "Alt text" }), },});
const posts = defineCollection({ slug: "posts", fields: { featuredImage: upload({ to: "images", required: true }), },});
export default defineConfig({ collections: [posts], mediaCollections: [images],});// vex.config.server.ts (server)import { defineServerConfig } from "@vexcms/core";import { convexFileStorage } from "@vexcms/file-storage-convex";
import vexConfig from "./vex.config";
export default defineServerConfig({ config: vexConfig, server: { storage: { adapters: [convexFileStorage()] }, },});filename, mimeType, size, storageId, src, width, and height are
locked at the type level — the fields object can only add new keys or
override alt. Multiple storage adapters (and multiple media collections per
adapter) can be registered simultaneously; upload({ to }) is validated
against the client config’s whole mediaCollections array, not tied to any one adapter.
Admin UI
Section titled “Admin UI”The upload field renders a two-part control. The empty state shows a
dropzone (“Drop files here or click to browse”) plus a “Browse media library”
button that opens a media-library picker modal scoped to the target
collection; accept is applied to the file input. The filled state shows
each attached file via a thumbnail preview with drag-to-reorder and per-file
removal, plus a “Select <plural>” button (labeled with the target media
collection’s plural label) that reopens the picker, and a count/max
indicator once max is set. hasMany: false allows only a single selection;
hasMany: true allows selecting or uploading multiple files. In the
list-view table, the cell shows the first file’s thumbnail and filename with
a +N badge when more than one file is attached; sorting is disabled since
file references aren’t meaningfully sortable.