Features
Storage Adapters
Vextro's upload system is backed by a pluggable storage adapter interface. Every file operation — generating presigned upload URLs, confirming completed uploads, serving files, and deleting them — goes through a VextroStorageAdapter. Two adapters ship with the package: Convex native storage and S3-compatible storage.
Convex Storage Adapter
createConvexStorageAdapter() stores files directly in Convex's built-in _storage system. Files are served via Convex-issued URLs. No external credentials are required.
import { createConvexStorageAdapter } from "vextro/storage"; Use Convex storage when:
- You want zero external dependencies
- Your files are primarily accessed by authenticated users through Convex queries
- You do not need a public CDN URL or custom domain
The adapter uses POST for client uploads (Convex-generated upload URLs), and delegates serving to Convex's storage.getUrl().
S3 Storage Adapter
createS3StorageAdapter(config) supports AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2, and any other S3-compatible service. The AWS SDK is lazily loaded as an optional peer dependency.
Installation
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner Basic setup
import { createS3StorageAdapter } from "vextro/storage";
const s3 = createS3StorageAdapter({
bucket: "my-media-bucket",
region: "us-east-1",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
}); Cloudflare R2
const r2 = createS3StorageAdapter({
bucket: "my-media-bucket",
region: "auto",
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
publicUrlBase: "https://media.example.com",
}); MinIO
const minio = createS3StorageAdapter({
bucket: "media",
region: "us-east-1",
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY!,
secretAccessKey: process.env.MINIO_SECRET_KEY!,
},
endpoint: "https://minio.example.com",
forcePathStyle: true,
}); Config options
| Option | Type | Description |
|---|---|---|
bucket * | string | S3 bucket name |
region * | string | AWS region or "auto" for R2 |
credentials.accessKeyId * | string | Access key ID |
credentials.secretAccessKey * | string | Secret access key |
endpoint | string | Custom endpoint URL for R2, MinIO, etc. |
publicUrlBase | string | Custom domain for public file URLs (overrides default S3 URL) |
acl | "private" | "public-read" | Object ACL. When "private", serving uses presigned GET URLs |
forcePathStyle | boolean | Use path-style URLs (endpoint/bucket/key) instead of virtual-hosted (bucket.endpoint/key). Required for MinIO |
presignedUrlExpiry | number | Expiry in seconds for presigned upload and GET URLs. Default: 3600 |
Vextro omits the ACL and Content-Type headers from presigned PUT URLs. Many S3-compatible services (DigitalOcean Spaces, MinIO) reject presigned requests that include an ACL header. Rely on your bucket's default ACL instead.
Using Adapters with Upload Endpoints
Pass your adapter to createUploadEndpoints in your Astro API route:
// src/pages/api/vextro/upload/[...action].ts
import { createUploadEndpoints } from "vextro/astro/uploadEndpoints";
import { createS3StorageAdapter } from "vextro/storage";
import { convex } from "../../../lib/convex";
import { api } from "../../../convex/_generated/api";
const s3 = createS3StorageAdapter({
bucket: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
});
const endpoints = createUploadEndpoints({
convex,
api,
storageAdapter: s3,
});
export const POST = endpoints.handleRequest;
export const DELETE = endpoints.handleRequest; Per-Collection Adapter Override
The adapters map lets you route specific upload collections to different storage backends. The collection slug is used as the key.
import { createConvexStorageAdapter } from "vextro/storage";
const endpoints = createUploadEndpoints({
convex,
api,
storageAdapter: s3, // default adapter
adapters: {
// "documents" collection uses Convex native storage
documents: createConvexStorageAdapter(),
// "videos" collection uses a different S3 bucket
videos: createS3StorageAdapter({
bucket: process.env.VIDEO_BUCKET!,
region: process.env.S3_REGION!,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
}),
},
}); When a request arrives for the documents collection, Vextro uses the Convex adapter. All other collections fall back to the default storageAdapter.
createUploadEndpoints options
| Option | Type | Description |
|---|---|---|
convex * | ConvexClient | Convex client instance |
api * | any | Convex generated API reference |
storageAdapter | VextroStorageAdapter | Default adapter for all collections. Defaults to Convex native |
adapters | Record<string, VextroStorageAdapter> | Per-collection adapter overrides keyed by collection slug |
allowedMimeTypes | string[] | MIME types accepted at the endpoint. Defaults to common image, document, video, and audio types |
maxFileSizeBytes | number | Max upload size in bytes. Default: 52428800 (50 MB) |
onAfterUpload | (payload: AfterUploadPayload) => Promise<void> | Callback after upload confirmation. Use to schedule image processing or other async work |
AfterUploadPayload
The onAfterUpload callback receives an AfterUploadPayload object with the following properties:
| Property | Type | Description |
|---|---|---|
fileId | string | ID of the created vextroFiles record |
storageRef | string | Storage reference (Convex storage ID or S3 key) |
filename | string | Original filename |
mimeType | string | File MIME type |
filesize | number | File size in bytes |
url | string | Public URL for the uploaded file |
collectionSlug | string | Upload collection the file belongs to |
storageAdapter | string | Name of the storage adapter that stored the file |
All properties are required. Errors thrown inside onAfterUpload are logged but do not fail the upload response.
Variant URL Utilities
When a collection has imageSizes configured, Vextro generates resized variants on upload and stores them on the file record's sizes array. Three helpers resolve which URL to use.
Import them from vextro/storage:
import { getVariantUrl, getVariant, selectVariantForWidth } from "vextro/storage"; getVariantUrl(fileRecord, sizeName)
Returns the URL for a specific named size variant. Falls back to the original file URL if the variant is not found or processing has not completed.
const thumbnail = getVariantUrl(articleHero, "thumbnail");
const medium = getVariantUrl(articleHero, "medium"); getVariant(fileRecord, sizeName)
Returns the full variant metadata object (name, url, width, height, mimeType, filesize), or null if not found. Use this when you need dimensions for <img width height> attributes.
const variant = getVariant(articleHero, "medium");
if (variant) {
console.log(variant.width, variant.height);
} selectVariantForWidth(fileRecord, targetWidth)
Picks the smallest variant that is at least as wide as targetWidth. Falls back to the largest available variant. Use this for responsive image src selection when the container width is known at render time.
// Container is 400px — picks the smallest variant >= 400px wide
const src = selectVariantForWidth(articleHero, 400); Full responsive example:
const small = selectVariantForWidth(image, 480);
const medium = selectVariantForWidth(image, 800);
const large = selectVariantForWidth(image, 1200); Custom Adapters
Any object that satisfies the VextroStorageAdapter interface can be used as an adapter:
import type { VextroStorageAdapter } from "vextro/storage";
const myAdapter: VextroStorageAdapter = {
name: "custom",
generateUploadUrl: async (ctx, opts) => { ... },
confirmUpload: async (ctx, opts) => { ... },
handleDelete: async (ctx, opts) => { ... },
getUrl: async (ctx, opts) => { ... },
storeBuffer: async (ctx, opts) => { ... },
}; Pass it to createUploadEndpoints or use it as a per-collection override in the adapters map.