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

OptionTypeDescription
bucket *stringS3 bucket name
region *stringAWS region or "auto" for R2
credentials.accessKeyId *stringAccess key ID
credentials.secretAccessKey *stringSecret access key
endpointstringCustom endpoint URL for R2, MinIO, etc.
publicUrlBasestringCustom domain for public file URLs (overrides default S3 URL)
acl"private" | "public-read"Object ACL. When "private", serving uses presigned GET URLs
forcePathStylebooleanUse path-style URLs (endpoint/bucket/key) instead of virtual-hosted (bucket.endpoint/key). Required for MinIO
presignedUrlExpirynumberExpiry 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

OptionTypeDescription
convex *ConvexClientConvex client instance
api *anyConvex generated API reference
storageAdapterVextroStorageAdapterDefault adapter for all collections. Defaults to Convex native
adaptersRecord<string, VextroStorageAdapter>Per-collection adapter overrides keyed by collection slug
allowedMimeTypesstring[]MIME types accepted at the endpoint. Defaults to common image, document, video, and audio types
maxFileSizeBytesnumberMax 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:

PropertyTypeDescription
fileIdstringID of the created vextroFiles record
storageRefstringStorage reference (Convex storage ID or S3 key)
filenamestringOriginal filename
mimeTypestringFile MIME type
filesizenumberFile size in bytes
urlstringPublic URL for the uploaded file
collectionSlugstringUpload collection the file belongs to
storageAdapterstringName 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.

Previous
Uploads & Storage