# How to Extend Instatic Media Storage with Custom Adapters: A Complete Developer Guide

> Extend Instatic media storage with custom adapters. Implement the MediaStorageAdapter interface and register it via api.cms.media.registerStorageAdapter() for flexible media management in your plugin. Learn how today.

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

---

**To extend Instatic media storage with custom adapters, implement the `MediaStorageAdapter` interface and register your adapter via `api.cms.media.registerStorageAdapter()` from a plugin that declares the `media.storage.adapter` permission.**

The CoreBunch/Instatic repository provides a plugin-driven storage abstraction that lets you replace or supplement the default local-disk storage with custom backends like S3, Cloudflare R2, or proprietary object stores. This guide walks through the architecture, implementation steps, and security considerations for building custom media storage adapters that integrate seamlessly with Instatic's CMS.

## Architecture of the Media Storage System

Instatic's media handling revolves around a **singleton registry** pattern that keeps adapter logic separate from byte streaming operations.

### The Adapter Registry

All storage adapters live in [`src/core/plugins/mediaStorageRegistry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/mediaStorageRegistry.ts), which maintains a map of `MediaStorageAdapter` objects keyed by their unique IDs. The registry operates entirely on the host side—adapters never run inside the QuickJS sandbox. At application boot, the built-in local-disk adapter registers automatically, but third-party plugins can inject additional adapters through the CMS API.

### Adapter Election and Persistence

Each **media role** (original, variant, avatar, font, plugin-pack) can use a different storage backend. The active adapter for each role persists in the database table `active_media_storage_adapter`, managed by the repository at [`server/repositories/mediaStorageAdapters.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/mediaStorageAdapters.ts). When users select a storage backend through the admin UI, the system writes the elected adapter ID to this table for subsequent operations.

### Two-Phase Upload Contract

Custom adapters must implement a strict three-step contract defined in the SDK:

1. **Initialization**: `adapter.beginWrite(input)` returns a signed `MediaStorageUploadPlan` containing upload steps (HTTP `PUT`/`POST` URLs or the `LOCAL` sentinel).
2. **Streaming**: The host streams file bytes directly to the URLs using Bun's `fetch`, or writes to disk for local storage.
3. **Finalization**: `adapter.finalizeWrite({ storagePath, uploadReceipts })` confirms completion and returns a `MediaStorageWriteResult` persisted on the media row.

If any step fails, the host calls `adapter.abortWrite({ storagePath })` to clean up partial uploads. The dispatch logic lives in [`server/handlers/cms/mediaUploadDispatch.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/mediaUploadDispatch.ts), while the execution engine resides in [`src/core/mediaUploadExecutor.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/mediaUploadExecutor.ts).

### Byte Isolation and Security

Raw bytes never cross the QuickJS sandbox boundary. Plugin adapters receive only signed URLs and metadata, while the host performs all actual I/O. This architecture, validated by [`src/__tests__/architecture/media-storage-no-bytes-in-sandbox.test.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/__tests__/architecture/media-storage-no-bytes-in-sandbox.test.ts), ensures that large uploads (like 4K videos) stay within host memory limits and cannot exhaust plugin sandbox resources.

## Implementing a Custom Storage Adapter

Extending Instatic media storage requires three concrete steps: implementing the interface, registering the adapter, and configuring upload handling.

### Step 1: Define the Adapter Interface

Create a TypeScript module that conforms to `MediaStorageAdapter` from `@core/plugin-sdk` (defined in [`src/core/plugin-sdk/capabilities.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugin-sdk/capabilities.ts)). Your implementation must provide:

- **Identification**: A unique `id`, human-readable `label`, and supported `roles` array.
- **Serving Mode**: Either `'public-url'` or `'private'` to determine how assets are accessed.
- **Lifecycle Methods**: `beginWrite`, `finalizeWrite`, and `abortWrite`.

```typescript
import type {
  MediaStorageAdapter,
  MediaStorageBeginWriteInput,
  MediaStorageFinalizeWriteInput,
  MediaStorageUploadPlan,
  MediaStorageWriteResult,
} from '@core/plugin-sdk';

const s3Adapter: MediaStorageAdapter = {
  id: 's3',
  label: 'Amazon S3',
  roles: ['original', 'variant'],
  servingMode: 'public-url',
  
  async beginWrite(input: MediaStorageBeginWriteInput): Promise<MediaStorageUploadPlan> {
    const storagePath = `${input.suggestedStoragePath}`;
    const signedPutUrl = await getSignedS3PutUrl(storagePath); // Your signing logic
    
    return {
      storagePath,
      steps: [{ method: 'PUT', url: signedPutUrl, headers: {} }],
    };
  },

  async finalizeWrite({
    storagePath,
    uploadReceipts,
  }: MediaStorageFinalizeWriteInput): Promise<MediaStorageWriteResult> {
    return {
      storagePath,
      url: `https://my-bucket.s3.amazonaws.com/${storagePath}`,
      size: uploadReceipts[0].bytes,
    };
  },

  async abortWrite({ storagePath }: { storagePath: string }) {
    await deletePartialS3Object(storagePath);
  },
};

```

### Step 2: Register the Adapter

Inside your plugin's server entry point (the QuickJS bootstrap context), call the registration method:

```typescript
export function register() {
  api.cms.media.registerStorageAdapter(s3Adapter);
}

```

This API is gated by the `media.storage.adapter` permission defined in [`src/core/plugin-sdk/capabilities.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugin-sdk/capabilities.ts). Your plugin must declare this capability in its manifest, or the registration call will throw a security error.

### Step 3: Configure CSP Origins

If your adapter serves assets from a distinct origin (like `*.s3.amazonaws.com`), declare it via the optional `cspOrigins` field:

```typescript
const s3Adapter: MediaStorageAdapter = {
  // ... other properties
  cspOrigins: ['https://my-bucket.s3.amazonaws.com'],
};

```

During the publish pipeline, [`server/publish/frontendInjections.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/frontendInjections.ts) automatically merges these origins into the site's Content Security Policy headers.

## Integrating with the Admin UI

Once registered, your adapter appears in the media storage settings panel automatically. The admin UI reads available adapters from the registry and persists user selection to `active_media_storage_adapter`.

To programmatically interact with adapter selection, use the React hooks provided by `@core/media-storage`:

```tsx
import { useMediaStorageAdapters } from '@core/media-storage';
import { selectAdapter } from '@core/media-storage/actions';

function AdapterPicker({ role }: { role: MediaAssetRole }) {
  const adapters = useMediaStorageAdapters();
  const dispatch = useDispatch();

  return (
    <select
      value={adapters.elected[role] ?? ''}
      onChange={(e) => dispatch(selectAdapter({ role, adapterId: e.target.value }))}
    >
      {adapters.all.map((a) => (
        <option key={a.id} value={a.id}>
          {a.label}
        </option>
      ))}
    </select>
  );
}

```

## Summary

Extending Instatic media storage with custom adapters leverages a secure, two-phase upload architecture that keeps heavy I/O operations in the host while allowing plugins to control storage backends.

- **Register adapters** via `api.cms.media.registerStorageAdapter()` in [`src/core/plugins/mediaStorageRegistry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/mediaStorageRegistry.ts) after declaring the `media.storage.adapter` permission.
- **Implement the contract** with `beginWrite`, `finalizeWrite`, and `abortWrite` methods that return signed URLs rather than handling raw bytes.
- **Persist elections** per media role in [`server/repositories/mediaStorageAdapters.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/mediaStorageAdapters.ts), enabling different backends for originals versus variants.
- **Secure the pipeline** by letting the host execute all uploads in [`src/core/mediaUploadExecutor.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/mediaUploadExecutor.ts), satisfying the byte-isolation tests.
- **Configure CSP** origins in [`server/publish/frontendInjections.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/frontendInjections.ts) when serving from external domains.

## Frequently Asked Questions

### What permissions are required to register a custom storage adapter?

Your plugin must declare the `media.storage.adapter` permission in its manifest. The permission system is enforced in [`src/core/plugin-sdk/capabilities.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugin-sdk/capabilities.ts), and registration calls will fail silently or throw if this capability is missing. This prevents unauthorized plugins from redirecting media uploads to arbitrary endpoints.

### How does Instatic handle large file uploads with custom adapters?

Large files stream directly from the client through the host to the signed URLs returned by your adapter's `beginWrite` method. Because [`src/core/mediaUploadExecutor.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/mediaUploadExecutor.ts) handles the actual `fetch` calls using Bun's native streams, uploads never buffer in the QuickJS sandbox memory. This architecture prevents the "heap limit exceeded" errors common in sandboxed plugin environments.

### Can a single adapter handle multiple media roles?

Yes. The `roles` array in your `MediaStorageAdapter` definition can include any combination of `'original'`, `'variant'`, `'avatar'`, `'font'`, and `'plugin-pack'`. The election system in [`server/repositories/mediaStorageAdapters.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/mediaStorageAdapters.ts) tracks separate adapter choices per role, so you could store originals on S3 while keeping avatars on local disk, all within the same adapter codebase.

### How do I migrate existing media to a new custom adapter?

Changing the elected adapter in `active_media_storage_adapter` only affects future uploads. Existing media rows retain their original `storagePath` and URLs in the database. To migrate historical assets, you would need to implement a backfill script that reads existing media records, transfers bytes to the new backend, and updates the storage metadata via the repository layer—operations that should happen outside the adapter interface to maintain data integrity.