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())
| Option | Type | Default | Description |
|---|---|---|---|
relationTo | string | required | Slug of the upload collection. |
accept | string[] | -- | Allowed MIME types (e.g., ["image/jpeg", "image/png"]). |
maxSize | number | -- | Maximum file size in bytes. |
hasMany | boolean | false | Allow multiple file uploads. |
f.image() additional options
| Option | Type | Default | Description |
|---|---|---|---|
displayPreview | boolean | true | Show 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.
| Option | Type | Default | Description |
|---|---|---|---|
mimeTypes | string[] | -- | Allowed MIME types at the collection level (e.g. ["image/*", "application/pdf"]). Applied to every upload into this collection. |
maxFileSize | number | -- | Maximum file size in bytes for this collection. |
imageSizes | ImageSizeConfig[] | -- | Image size variants to auto-generate on upload. See Image sizes below. |
formatOptions | object | -- | Output format conversion applied to the original and all generated sizes. { format: "webp" | "avif" | "png" | "jpeg", quality?: number } |
resizeOptions | object | -- | Resize the original image on upload. { width?: number, height?: number, fit?: "cover" | "contain" | "fill" | "inside" | "outside" } |
focalPoint | boolean | true when imageSizes defined | Show the focal point selector in the editor. |
crop | boolean | true | Show the crop tool in the editor. |
adminThumbnail | string | 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. |
bulkUpload | boolean | true | Enable multi-file upload from the collection list view toolbar. |
displayPreview | boolean | true | Show a thumbnail preview in upload fields that reference this collection. |
filesRequiredOnCreate | boolean | true | Require a file to be attached when creating a new document in this collection. |
pasteURL | boolean | 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. |
storageAdapter | string | global adapter | Override the storage adapter for this collection (use the adapter's name identifier). |
prefix | string | -- | 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).
Server-side processing (recommended)
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
| Status | Description |
|---|---|
pending | Upload complete, processing not started. |
processing | Sizes are being generated. |
complete | All sizes generated successfully. |
skipped | Processing skipped (sharp unavailable in runtime). |
error | Processing failed; error message stored. |
Image sizes
Each entry in imageSizes is an ImageSizeConfig object:
| Option | Type | Default | Description |
|---|---|---|---|
name | string | required | Unique identifier for the size (e.g. "thumbnail"). Used to retrieve the variant URL. |
width | number | -- | Target width in pixels. |
height | number | -- | Target height in pixels. |
aspectRatio | string | -- | Aspect ratio constraint (e.g. "16:9"). Auto-derived when both width and height are set. |
fit | string | "cover" | Sharp fit mode: "cover", "contain", "fill", "inside", or "outside". |
position | string | -- | Sharp position hint used with focal-point cropping (e.g. "entropy", "attention"). |
withoutEnlargement | boolean | false | When true, the image is not scaled up if the source is smaller than the target dimensions. |
generateImageName | function | -- | Custom filename generator: (opts: { height, width, sizeName, extension, originalName }) => string. |
formatOptions | object | -- | 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
| Scenario | Recommendation |
|---|---|
| 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 framing | Enable both. Focal point handles responsive reflows across sizes; crop handles deliberate composition choices. |
| Documents, PDFs, or images always shown at original aspect ratio | Disable 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.
| Field | Type | Description |
|---|---|---|
filename | string | Original filename |
mimeType | string | MIME type (e.g., image/jpeg) |
filesize | number | File size in bytes |
fileId | string | Reference to the internal vextroFiles storage record |
url | string | Public URL to the file |
width | number | Image width in pixels (images only) |
height | number | Image height in pixels (images only) |
focalX | number | Focal point X (0-100), when focal point is enabled |
focalY | number | Focal 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:
| Property | Type | Required | Description |
|---|---|---|---|
_id | string | yes | Unique file record identifier |
filename | string | yes | Original filename |
mimeType | string | yes | MIME type (e.g., image/png) |
filesize | number | yes | File size in bytes |
storageRef | string | yes | Reference to the stored file (Convex storage ID or S3 key) |
storageAdapter | string | yes | Which storage adapter holds the file (e.g., "convex", "s3") |
url | string | yes | Public URL for the file |
collectionSlug | string | yes | Upload collection this file belongs to |
documentId | string | no | Document this file is attached to |
fieldName | string | no | Field name on the document |
prefix | string | no | Storage path prefix |
width | number | no | Image width in pixels (images only) |
height | number | no | Image height in pixels (images only) |
focalX | number | no | Focal point X coordinate (0-1) |
focalY | number | no | Focal point Y coordinate (0-1) |
crop | object | no | Crop settings: { x: number, y: number, width: number, height: number, aspectRatio?: string } |
sizes | array | no | Generated size variants. Each entry: { name, storageRef, url, filename, mimeType, filesize, width, height } |
originalMimeType | string | no | Original MIME type before format conversion |
processingStatus | string | no | Image processing status: "pending", "processing", "complete", or "error" |
processingError | string | no | Error message if processing failed |
uploadedAt | number | yes | Upload 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.