# How to Build Custom Media Storage Adapters for External Providers in Instatic

> Learn to build custom media storage adapters in Instatic by implementing the MediaStorageAdapter interface and registering it. Upload assets to S3, R2, or Azure Blob with ease.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: how-to-guide
- Published: 2026-07-30

---

**You can build custom media storage adapters in Instatic by implementing the `MediaStorageAdapter` interface with `beginWrite`, `finalizeWrite`, and optional lifecycle methods, then registering the adapter via `api.cms.media.registerStorageAdapter()` to handle uploads for specific asset roles like S3, R2, or Azure Blob.**

Instatic’s media subsystem uses a pluggable architecture that supports external storage providers through self-contained adapters. According to the CoreBunch/Instatic source code, plugins can register adapters that delegate file operations to cloud providers while the host manages the actual byte streaming, ensuring security and performance.

## Understanding the Media Storage Adapter Architecture

Instatic treats media storage as a capability-based plugin system. Instead of hardcoding provider logic, the platform delegates upload and retrieval operations to elected adapters through a strict contract defined in [`src/core/plugin-sdk/types/media.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugin-sdk/types/media.ts).

### The Three-Phase Upload Contract

Every adapter must implement a **two-phase write contract** orchestrated by the host:

1. **`beginWrite`** – The adapter receives file metadata and returns a `MediaStorageUploadPlan` containing signed URLs and HTTP methods (typically PUT or POST).

2. **Host streams bytes** – The Instatic host uses Bun’s native `fetch` to stream the payload directly to the signed URL. For the built-in local-disk adapter, it writes directly to the `uploads/` directory instead.

3. **`finalizeWrite`** – After successful upload, the adapter validates the operation and returns the final public URL to persist in the database.

If any phase fails, the host automatically calls `adapter.abortWrite()` to prevent stray files. This flow is dispatched from [`server/handlers/cms/mediaUploadDispatch.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/mediaUploadDispatch.ts), which looks up the elected adapter per asset role and manages the lifecycle.

### Security Boundary and QuickJS Sandbox

**Bytes never cross the QuickJS sandbox.** Adapters running inside the plugin sandbox only generate signed URLs and metadata; the host performs all network I/O. This invariant is enforced by the architecture test [`media-storage-no-bytes-in-sandbox.test.ts`](https://github.com/CoreBunch/Instatic/blob/main/media-storage-no-bytes-in-sandbox.test.ts), ensuring that untrusted plugin code cannot intercept file contents.

## Implementing the MediaStorageAdapter Interface

To build a custom adapter, create an object satisfying the `MediaStorageAdapter` interface exported from the plugin SDK.

### Required Interface Methods

At minimum, your adapter must provide:

- **`id`** – A unique namespaced identifier (e.g., `my-plugin.s3`).
- **`label`** – Human-readable text for the admin UI.
- **`roles`** – Array of `MediaAssetRole` values (`'original'`, `'variant'`, `'avatar'`, `'font'`, `'plugin-pack'`) indicating which upload types this adapter handles.
- **`servingMode`** – Either `'public-url'` (return CDN URL) or `'proxy'` (stream through Instatic).
- **`beginWrite(input)`** – Returns a `MediaStorageUploadPlan` with `storagePath`, `expiresAt`, and `steps` array containing HTTP method, URL, and headers.
- **`finalizeWrite(input)`** – Confirms upload completion and returns `{ publicUrl }`.

Optional methods include `abortWrite()` for cleanup, `delete()` for asset removal, and `verify()` for configuration health checks.

### Adapter Registration via Plugin SDK

Plugins must declare the `media.storage.adapter` capability in their manifest (defined in [`src/core/plugin-sdk/capabilities.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugin-sdk/capabilities.ts) at line 187). During initialization, call the host-exposed API:

```typescript
api.cms.media.registerStorageAdapter(s3Adapter)

```

The host’s QuickJS bridge forwards this to `MediaStorageRegistry.register()` in [`src/core/plugins/mediaStorageRegistry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/mediaStorageRegistry.ts), storing your adapter in the singleton `mediaStorageRegistry`.

## Complete S3-Compatible Adapter Example

Here is a production-ready pattern for an S3-compatible adapter that handles all asset roles:

```typescript
// src/plugin/adapters/s3Adapter.ts
import type {
  MediaStorageAdapter,
  MediaStorageBeginWriteInput,
  MediaStorageUploadPlan,
  MediaStorageFinalizeWriteInput,
  MediaStorageWriteResult,
} from '@core/plugin-sdk'

async function signS3Url(path: string, method: 'PUT' | 'POST'): Promise<string> {
  // Your AWS SDK v3 signing logic here
  return `https://my-bucket.s3.amazonaws.com/${path}?X-Amz-Signature=...`
}

async function deleteObjectFromS3(path: string): Promise<void> {
  // AWS SDK deletion logic
}

export const s3Adapter: MediaStorageAdapter = {
  id: 'my-plugin.s3',
  label: 'Amazon S3',
  roles: ['original', 'variant', 'avatar', 'font', 'plugin-pack'],
  servingMode: 'public-url',

  async beginWrite(input: MediaStorageBeginWriteInput): Promise<MediaStorageUploadPlan> {
    const storagePath = `${Date.now()}_${input.suggestedStoragePath}`
    const url = await signS3Url(storagePath, 'PUT')
    
    return {
      storagePath,
      expiresAt: Date.now() + 5 * 60_000, // 5 minute expiry
      steps: [{ method: 'PUT', url, headers: {} }],
    }
  },

  async finalizeWrite({ storagePath }: MediaStorageFinalizeWriteInput): Promise<MediaStorageWriteResult> {
    const publicUrl = `https://my-bucket.s3.amazonaws.com/${storagePath}`
    return { publicUrl }
  },

  async abortWrite({ storagePath }: { storagePath: string }): Promise<void> {
    await deleteObjectFromS3(storagePath)
  },

  async delete(storagePath: string): Promise<void> {
    await deleteObjectFromS3(storagePath)
  },

  async verify() {
    const ok = await canPutTestObject() // Your implementation
    return ok 
      ? { ok: true } 
      : { ok: false, reason: 'S3 bucket not reachable' }
  },
}

// Register at plugin startup
api.cms.media.registerStorageAdapter(s3Adapter)

```

All contract fields are validated against TypeBox schemas in [`src/core/plugin-sdk/storageSchemas.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugin-sdk/storageSchemas.ts). Avoid type assertions at the boundary.

## Adapter Election and Configuration

Once registered, adapters must be elected for specific asset roles through the admin interface or REST API.

### The Active Adapter Registry

Per-role adapter assignments are persisted in the `active_media_storage_adapter` table, created by migrations in [`server/db/migrations-pg.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/db/migrations-pg.ts) (line 630) and [`server/db/migrations-sqlite.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/db/migrations-sqlite.ts) (line 570). The repository layer in [`server/repositories/mediaStorageAdapters.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/mediaStorageAdapters.ts) handles CRUD operations for these records.

### Electing Adapters for Asset Roles

Administrators configure storage via the **Media Storage Panel** at [`src/admin/pages/media/components/MediaStoragePanel/MediaStoragePanel.tsx`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/media/components/MediaStoragePanel/MediaStoragePanel.tsx). The UI fetches available adapters from `mediaStorageRegistry.list()` and persists elections via:

```bash
curl -X POST https://my-instatic.local/_instatic/api/media/storage/elect \
  -H "Authorization: Bearer <admin-token>" \
  -H "Content-Type: application/json" \
  -d '{
        "role": "original",
        "adapterId": "my-plugin.s3"
      }'

```

Each asset role (`original`, `variant`, `avatar`, etc.) can use a different adapter. When serving files, `mediaStorageRegistry.resolveForRead()` retrieves the stored adapter ID from the media record and returns either the cached public URL or proxies the request.

### Verification and Health Checks

Before electing an adapter, administrators can verify configuration via `POST /media/storage/verify`. The host resolves the adapter using `mediaStorageRegistry.resolve(adapterId, role)` and invokes `adapter.verify()`, returning a `MediaStorageVerifyResult` defined in the storage schemas.

## Summary

- **Implement the interface:** Create a `MediaStorageAdapter` with `beginWrite`, `finalizeWrite`, and optional lifecycle methods, ensuring all return types match the TypeBox schemas in [`src/core/plugin-sdk/storageSchemas.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugin-sdk/storageSchemas.ts).
- **Register via plugin:** Call `api.cms.media.registerStorageAdapter()` during plugin initialization after declaring the `media.storage.adapter` capability.
- **Never stream bytes:** Return signed URLs only; the host handles所有的 byte streaming via Bun’s `fetch`, maintaining the security boundary.
- **Configure per role:** Adapters are elected per asset role (`original`, `variant`, etc.) through the `active_media_storage_adapter` table and managed via the admin UI or REST API.
- **Handle cleanup:** Implement `abortWrite` to delete partial uploads and `verify` to validate configuration before production use.

## Frequently Asked Questions

### What file paths should I reference when debugging adapter registration?

Consult [`src/core/plugins/mediaStorageRegistry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/mediaStorageRegistry.ts) for the registry singleton logic, [`server/handlers/cms/mediaUploadDispatch.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/mediaUploadDispatch.ts) for the upload orchestration, and [`src/core/plugin-sdk/capabilities.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugin-sdk/capabilities.ts) for the required capability declaration at line 187.

### Can an adapter handle multiple asset roles simultaneously?

Yes. Set the `roles` array to include any combination of `'original'`, `'variant'`, `'avatar'`, `'font'`, and `'plugin-pack'`. The same adapter instance will be invoked for each role type elected in the admin UI.

### How does Instatic ensure plugin adapters cannot access uploaded file contents?

The architecture enforces that **bytes never cross the QuickJS sandbox**. Your adapter only returns metadata and signed URLs; the host performs the actual HTTP PUT/POST operations using Bun’s native fetch. This is verified by [`media-storage-no-bytes-in-sandbox.test.ts`](https://github.com/CoreBunch/Instatic/blob/main/media-storage-no-bytes-in-sandbox.test.ts).

### What happens if `finalizeWrite` fails or the upload is interrupted?

The host calls `adapter.abortWrite()` with the `storagePath` if any phase fails, allowing you to clean up partial objects in S3 or other remote storage. Implement this method to avoid orphan files and unexpected storage costs.