Features

Uploads & Storage

Vextro includes a file upload system backed by S3-compatible storage. It handles image processing with sharp, automatic size generation, focal point selection, and orphan cleanup. Metadata is tracked in a vextroFiles table in Convex.

Upload fields

import { f, defineVextroCollection } from "vextro";

export const articles = defineVextroCollection({
  slug: "articles",
  label: "Articles",
  collectionType: "content",
  tableName: "articles",
  fields: {
    title: f.text({ required: true }),
    heroImage: f.image({
      relationTo: "media",
      accept: ["image/jpeg", "image/png", "image/webp"],
      maxSize: 10 * 1024 * 1024,
    }),
    attachment: f.file({ relationTo: "documents", accept: ["application/pdf"] }),
    gallery: f.upload({ relationTo: "media", hasMany: true }),
  },
});

Upload field options

Common options (f.image(), f.file(), f.upload())

OptionTypeDefaultDescription
relationTostringrequiredSlug of the upload collection.
acceptstring[]--Allowed MIME types (e.g., ["image/jpeg", "image/png"]).
maxSizenumber--Maximum file size in bytes.
hasManybooleanfalseAllow multiple file uploads.

f.image() additional options

OptionTypeDefaultDescription
displayPreviewbooleantrueShow thumbnail preview in the editor.

Upload collection configuration

An upload collection accepts an upload config object with the following options. Pass it as upload: { ... } in defineVextroCollection.

OptionTypeDefaultDescription
mimeTypesstring[]--Allowed MIME types at the collection level (e.g. ["image/*", "application/pdf"]). Applied to every upload into this collection.
maxFileSizenumber--Maximum file size in bytes for this collection.
imageSizesImageSizeConfig[]--Image size variants to auto-generate on upload. See Image sizes below.
formatOptionsobject--Output format conversion applied to the original and all generated sizes. { format: "webp" | "avif" | "png" | "jpeg", quality?: number }
resizeOptionsobject--Resize the original image on upload. { width?: number, height?: number, fit?: "cover" | "contain" | "fill" | "inside" | "outside" }
focalPointbooleantrue when imageSizes definedShow the focal point selector in the editor.
cropbooleantrueShow the crop tool in the editor.
adminThumbnailstring | function--Controls which image variant is shown in admin list views. Pass an image size name ("thumbnail") or a function (doc) => string that returns a URL.
bulkUploadbooleantrueEnable multi-file upload from the collection list view toolbar.
displayPreviewbooleantrueShow a thumbnail preview in upload fields that reference this collection.
filesRequiredOnCreatebooleantrueRequire a file to be attached when creating a new document in this collection.
pasteURLboolean | object--Allow editors to paste a URL to fetch a remote file. Pass true to allow any URL, or { allowList: [{ hostname, pathname? }] } to restrict to specific domains.
storageAdapterstringglobal adapterOverride the storage adapter for this collection (use the adapter's name identifier).
prefixstring--Path prefix for stored objects (e.g. "media/" for S3 key namespacing).

When to enable bulkUpload

Enable bulkUpload for media libraries and image-heavy collections where editors batch-import assets — logo sets, product photo shoots, or stock image dumps. It is less useful for document-oriented collections where every upload immediately needs metadata (title, alt text, tags) entered one at a time; enabling it there can leave incomplete records until editors circle back.

Example upload collection

import { defineVextroCollection } from "vextro";

export const media = defineVextroCollection({
  slug: "media",
  label: "Media",
  tableName: "media",
  upload: {
    mimeTypes: ["image/jpeg", "image/png", "image/webp"],
    maxFileSize: 10 * 1024 * 1024,
    imageSizes: [
      { name: "thumbnail", width: 200, height: 200, fit: "cover" },
      { name: "medium", width: 800 },
      { name: "large", width: 1600 },
    ],
    formatOptions: { format: "webp", quality: 85 },
    focalPoint: true,
    crop: true,
    adminThumbnail: "thumbnail",
    bulkUpload: true,
    filesRequiredOnCreate: true,
    prefix: "media/",
  },
  fields: {
    alt: f.text({ label: "Alt text" }),
    caption: f.text({ label: "Caption" }),
  },
});

Image processing

When an image is uploaded, Vextro can generate configured sizes and format conversions. Processing requires sharp and runs on the Astro server (not in Convex actions, where native modules aren't available).

Use processImageOnServer in your upload endpoint's onAfterUpload callback:

import { createUploadEndpoints } from "vextro/astro/uploadEndpoints";
import { processImageOnServer } from "vextro/astro/processImageOnServer";

const endpoints = createUploadEndpoints({
  convex, api,
  storageAdapter: s3Adapter,
  onAfterUpload: async (payload) => {
    await processImageOnServer({
      payload, convex, api,
      adapter: s3Adapter,
      uploadConfigs: {
        media: {
          imageSizes: [
            { name: "thumbnail", width: 200, height: 200, fit: "cover" },
            { name: "medium", width: 800 },
          ],
          formatOptions: { format: "webp", quality: 85 },
        },
      },
    });
  },
});

sharp in Convex

The processUploadedImage internal action falls back gracefully if sharp can't load in Convex's sandboxed runtime — it marks the file as skipped (not error) and logs a warning. Use processImageOnServer on the Astro side instead.

Processing statuses

StatusDescription
pendingUpload complete, processing not started.
processingSizes are being generated.
completeAll sizes generated successfully.
skippedProcessing skipped (sharp unavailable in runtime).
errorProcessing failed; error message stored.

Image sizes

Each entry in imageSizes is an ImageSizeConfig object:

OptionTypeDefaultDescription
namestringrequiredUnique identifier for the size (e.g. "thumbnail"). Used to retrieve the variant URL.
widthnumber--Target width in pixels.
heightnumber--Target height in pixels.
aspectRatiostring--Aspect ratio constraint (e.g. "16:9"). Auto-derived when both width and height are set.
fitstring"cover"Sharp fit mode: "cover", "contain", "fill", "inside", or "outside".
positionstring--Sharp position hint used with focal-point cropping (e.g. "entropy", "attention").
withoutEnlargementbooleanfalseWhen true, the image is not scaled up if the source is smaller than the target dimensions.
generateImageNamefunction--Custom filename generator: (opts: { height, width, sizeName, extension, originalName }) => string.
formatOptionsobject--Per-size format override: { format: "webp" | "avif" | "png" | "jpeg", quality?: number }. Overrides the collection-level formatOptions.

Choosing how many sizes to generate

Each size adds storage cost and processing time on every upload. Start with two or three sizes — thumbnail (200px), medium (800px), large (1600px) — and add more only if traffic data shows the need. For format conversion, WebP is typically 25–35% smaller than JPEG at equivalent quality; quality 80–85 is a good balance of file size and visual fidelity. Check your minimum browser support requirements before using AVIF.

Focal point and cropping

Editors can set a focal point on images to control cropping at different aspect ratios:

{
  focalX: 0.45,  // normalized 0-1
  focalY: 0.3,
  crop: { x: 100, y: 50, width: 800, height: 600, aspectRatio: "4:3" },
}

Choosing your image strategy

ScenarioRecommendation
Images displayed at multiple aspect ratios (cards, grids, hero)Enable focalPoint. The editor marks the subject; Vextro keeps it visible when containers crop to fit. Non-destructive — the original image is preserved.
Fixed-dimension slots with intentional framing (hero banners, thumbnails)Enable crop. The editor defines the exact region to store. Destructive — the cropped area becomes the canonical image.
Both flexible display and intentional framingEnable both. Focal point handles responsive reflows across sizes; crop handles deliberate composition choices.
Documents, PDFs, or images always shown at original aspect ratioDisable both. The added UI provides no value and can confuse editors.

focalPoint vs crop

focalPoint is a hint to the resize algorithm — it shifts the crop origin so the subject stays centered when sharp resizes to a different aspect ratio. crop is a direct editor action that explicitly frames the image before it is stored. They solve different problems and work well together.

Auto-injected fields

Upload collections (upload: true or upload: { ... }) automatically receive these schema fields. Do not redefine them — they are managed by the upload system.

FieldTypeDescription
filenamestringOriginal filename
mimeTypestringMIME type (e.g., image/jpeg)
filesizenumberFile size in bytes
fileIdstringReference to the internal vextroFiles storage record
urlstringPublic URL to the file
widthnumberImage width in pixels (images only)
heightnumberImage height in pixels (images only)
focalXnumberFocal point X (0-100), when focal point is enabled
focalYnumberFocal point Y (0-100), when focal point is enabled

Image dimensions are detected at upload time on the Astro server using sharp.

Custom fields alongside auto-injected

You can add any custom fields (e.g., alt, caption, tags, folder) alongside the auto-injected ones. If you define a field with the same name as an auto-injected field, your definition takes precedence.

File record structure

Each upload creates two records: a document in the upload collection (with the fields above plus your custom fields) and an internal vextroFiles record (for storage tracking, variant generation, and orphan cleanup).

VextroFileRecord shape

When querying uploaded files directly from the vextroFiles table, each record has the following shape:

PropertyTypeRequiredDescription
_idstringyesUnique file record identifier
filenamestringyesOriginal filename
mimeTypestringyesMIME type (e.g., image/png)
filesizenumberyesFile size in bytes
storageRefstringyesReference to the stored file (Convex storage ID or S3 key)
storageAdapterstringyesWhich storage adapter holds the file (e.g., "convex", "s3")
urlstringyesPublic URL for the file
collectionSlugstringyesUpload collection this file belongs to
documentIdstringnoDocument this file is attached to
fieldNamestringnoField name on the document
prefixstringnoStorage path prefix
widthnumbernoImage width in pixels (images only)
heightnumbernoImage height in pixels (images only)
focalXnumbernoFocal point X coordinate (0-1)
focalYnumbernoFocal point Y coordinate (0-1)
cropobjectnoCrop settings: { x: number, y: number, width: number, height: number, aspectRatio?: string }
sizesarraynoGenerated size variants. Each entry: { name, storageRef, url, filename, mimeType, filesize, width, height }
originalMimeTypestringnoOriginal MIME type before format conversion
processingStatusstringnoImage processing status: "pending", "processing", "complete", or "error"
processingErrorstringnoError message if processing failed
uploadedAtnumberyesUpload timestamp (milliseconds since epoch)

documentId and orphan cleanup

Files without a documentId are considered orphaned. The orphan cleanup cron removes these records after a configured threshold. Files are linked to documents during the document save process.

Orphan cleanup

Files uploaded but never linked to a document are periodically cleaned up. A cron job queries for vextroFiles records with no documentId older than a configured threshold and deletes both the database records and the S3 objects.

Previous
Error Handling