CodexaCodexa

Overview

One upload interface across local disk, S3, Cloudinary, and ImageKit, plus signed URLs for client-direct uploads.

createStorageManager(config) resolves the right provider adapter from config.provider automatically, so the rest of your code never branches on which provider is active.

import { buildStorageConfig } from '@codexa/core/config';
import { createStorageManager } from '@codexa/core/storage';

const storage = createStorageManager(
  buildStorageConfig({
    STORAGE_PROVIDER: 'local',
    LOCAL_STORAGE_DIR: './uploads',
    LOCAL_BASE_URL: 'http://localhost:8000/uploads',
  }),
);

const uploaded = await storage.upload(fileBytes, {
  folder: 'avatars',
  contentType: 'image/png',
  assetType: 'image',
});

config comes from buildStorageConfig, which turns environment variables into the typed config each provider needs.

Uploading

// One file
const result = await storage.upload(fileBytes, {
  folder: 'avatars',
  contentType: 'image/jpeg',
  assetType: 'image',
});

// Several files at once, uploaded concurrently
const [image, video] = await storage.upload(
  [imageBytes, videoBytes],
  [
    { folder: 'images', assetType: 'image', contentType: 'image/webp' },
    { folder: 'videos', assetType: 'video', contentType: 'video/mp4' },
  ],
) as UploadResult[];

file accepts raw bytes, a ReadableStream, or an array of either. A single options object applies to every file in an array upload, or pass a parallel array for per-file metadata. The as UploadResult[] cast above exists because upload's return type is a union of one result or many, TypeScript cannot narrow that automatically from an array argument, so an array upload needs the cast to get array methods back.

Upload options

Every field beyond folder and contentType is optional, and support varies by provider, an unsupported field is simply ignored rather than rejected.

Prop

Type

Upload result

Prop

Type

Deleting and checking

await storage.delete(result.key);
await storage.exists(result.key); // boolean, or undefined if the provider does not support it

What "key" means depends on the provider: S3 and local use the relative object or file path. Cloudinary uses its public_id. ImageKit uses its fileId, returned as result.publicId rather than result.key. Always delete using the same identifier upload returned, not a path you construct yourself.

URLs

storage.getTransformedUrl(result.key, { width: 200, height: 200, crop: 'fill', format: 'webp' });
await storage.getSignedUrl(result.key, 3600); // time-limited delivery URL

Both return undefined on a provider that does not support the operation, local storage has no transformation or signed-delivery-URL support, for example, rather than throwing. Check for undefined instead of wrapping every call in try/catch.

Client-direct uploads

For large files, letting the browser upload straight to the provider avoids routing file bytes through your own server entirely. Your server only ever issues short-lived, scoped credentials, it never touches the file itself.

Recommended default: use plain storage.upload() for small files, avatars, documents, anything a few MB or under, where simplicity matters more than bandwidth. Reach for client-direct uploads once files get large, video especially, since it takes your server out of the transfer entirely, upload speed is limited only by the client's own connection, and you are not paying to receive bytes you are just going to re-send to the provider anyway.

Client                   Your Server               Provider
  |--- POST /upload-token ->|                          |
  |<-- SignedUploadResult --|  (signs credentials)     |
  |--- PUT / POST -------->|------------------------->|
  |<-- 200 / asset URL ----|--------------------------|

The three parties involved, and what each one sends:

The client asks your server for credentials

Your server decides this request body's shape, the framework does not enforce one. A minimal, common one is the file's name, its content type, and where it should go:

components/avatar-uploader.tsx
const res = await fetch('/media/upload-token', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    fileName: file.name,
    contentType: file.type,
    folder: 'avatars',
  }),
});

const token = await res.json(); // SignedUploadResult

Your server signs credentials and returns them

Authenticate the request first, this endpoint is what actually authorizes an upload, getSignedUploadUrl itself has no idea who is calling it.

media-router.ts
import { createRouter } from '@codexa/core/http';

const mediaRouter = createRouter('media-router').route({
  method: 'POST',
  path: '/media/upload-token',
  handler: async (ctx) => {
    const body = await ctx.request.json();

    const token = await storage.getSignedUploadUrl({
      folder: body.folder,
      fileName: body.fileName,
      contentType: body.contentType,
      assetType: 'image',
      expiresIn: 900,
    });

    return ctx.json(token);
  },
});

getSignedUploadUrl accepts the same metadata fields as upload, fileName, customId, tags, overwrite, metadata, eagerTransformations, plus expiresIn (seconds, default 3600). contentType is the one required field, the provider needs to know what it is signing credentials for.

What comes back is a SignedUploadResult, but its exact shape depends on which provider is configured. The fields a browser must submit differ per provider, S3 needs none at all:

{
  "uploadUrl": "https://my-bucket.s3.amazonaws.com/avatars/a1b2c3.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&...",
  "method": "PUT",
  "key": "avatars/a1b2c3.png",
  "expiresAt": 1755123456,
  "publicUrl": "https://cdn.example.com/avatars/a1b2c3.png"
}

No fields, the signature is baked into uploadUrl itself as query parameters.

Never sent to the client: your S3 secret key, Cloudinary API secret, or ImageKit private key. fields only ever contains derived, time-limited values, a signature computed from a secret, not the secret itself.

Prop

Type

The client uploads straight to the provider

Branch on token.method rather than hardcoding which provider is configured, this same code works no matter which of the three is active:

components/avatar-uploader.tsx
if (token.method === 'PUT') {
  // S3: the raw file as the body, no FormData
  await fetch(token.uploadUrl, {
    method: 'PUT',
    headers: { 'content-type': file.type },
    body: file,
  });
} else {
  // Cloudinary or ImageKit: a multipart form, provider fields plus the file
  const form = new FormData();
  Object.entries(token.fields).forEach(([key, value]) => {
    form.append(key, value as string);
  });
  form.append('file', file);

  await fetch(token.uploadUrl, { method: 'POST', body: form });
}

Your server records that the upload happened

The provider never calls your server back on its own, so if your app needs to know the upload succeeded, save a user's new avatar key to their profile, for example, the client tells your server once the direct upload resolves. token.key is already known from step 2, no need to re-derive it:

components/avatar-uploader.tsx
await fetch('/media/confirm', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ key: token.key }),
});
media-router.ts
.route({
  method: 'POST',
  path: '/media/confirm',
  handler: async (ctx) => {
    const { key } = await ctx.request.json();
    // e.g. await db.users.update(userId, { avatarKey: key });
    return ctx.json({ ok: true });
  },
})

Full client component

Putting all three client-side steps together, a minimal React uploader:

components/avatar-uploader.tsx
'use client';

import { useState } from 'react';

export function AvatarUploader() {
  const [uploading, setUploading] = useState(false);

  async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    setUploading(true);

    const token = await fetch('/media/upload-token', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        fileName: file.name,
        contentType: file.type,
        folder: 'avatars',
      }),
    }).then((res) => res.json());

    if (token.method === 'PUT') {
      await fetch(token.uploadUrl, {
        method: 'PUT',
        headers: { 'content-type': file.type },
        body: file,
      });
    } else {
      const form = new FormData();
      Object.entries(token.fields).forEach(([key, value]) => {
        form.append(key, value as string);
      });
      form.append('file', file);
      await fetch(token.uploadUrl, { method: 'POST', body: form });
    }

    await fetch('/media/confirm', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ key: token.key }),
    });

    setUploading(false);
  }

  return (
    <input
      type="file"
      accept="image/*"
      onChange={handleFileChange}
      disabled={uploading}
    />
  );
}
ProviderMethodClient body
S3PUTRaw binary
CloudinaryPOSTFormData with a file field, plus signed params
ImageKitPOSTFormData with a file field, plus auth fields
LocalThrowsNot supported, use storage.upload() directly instead

Introspection and the escape hatch

storage.providerType; // 'local' | 's3' | 'cloudinary' | 'imagekit'
storage.config;       // the full StorageConfig used to create this manager
storage.adapter;      // the raw provider instance, for provider-specific calls not wrapped by StorageManager

On this page