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

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, 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. 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, while the execution engine resides in 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, 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). 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.
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:

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. 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:

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

During the publish pipeline, 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:

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.

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, 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 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 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →