# Instatic Media Workspace Storage Adapter System: Architecture and Implementation Guide

> Explore the Instatic Media workspace storage adapter system, a pluggable TypeScript architecture for seamless backend integration. Decouple media storage with local disk S3 or custom CDN support.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: architecture
- Published: 2026-07-31

---

**The Instatic Media workspace storage adapter system is a pluggable TypeScript architecture that decouples media storage from the CMS core through a role-based adapter interface, enabling seamless integration with local disk, S3, or custom CDN backends.**

The **Instatic Media workspace storage adapter system** powers the media management capabilities in CoreBunch/Instatic, providing a flexible abstraction layer that determines how and where digital assets are stored. This system allows developers to implement custom storage backends—such as AWS S3, Cloudflare R2, or proprietary CDNs—while maintaining a consistent contract with the CMS core. By separating storage concerns from content management, Instatic ensures that upload workflows, asset retrieval, and persistence logic remain modular and extensible.

## Core Architecture Components

The storage adapter system rests on three interconnected layers: a strict TypeScript interface, a central registry singleton, and a database-backed election mechanism for role-based adapter assignment.

### The MediaStorageAdapter Interface

Every storage adapter must implement the `MediaStorageAdapter` interface defined in [`src/core/plugin-sdk/types/media.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugin-sdk/types/media.ts). This contract mandates five critical methods and two identifying properties:

- **`id`** – A unique string identifier (the built-in local-disk adapter uses an empty string).
- **`roles`** – An array of `MediaAssetRole` values (`image`, `avatar`, `font`, etc.) that this adapter can handle.
- **`beginWrite(input)`** – Initiates an upload by returning a signed `MediaStorageUploadPlan` containing the destination URL and HTTP method.
- **`writeBytes(plan, stream)`** – Streams raw bytes to the storage destination (often a no-op when clients upload directly to signed URLs).
- **`finalizeWrite({ storagePath, uploadReceipts })`** – Commits the asset to permanent storage and updates CMS metadata.
- **`abortWrite({ storagePath })`** – Cleans up partial uploads on failure.

### MediaStorageRegistry Singleton

The `MediaStorageRegistry` class in [`src/core/plugins/mediaStorageRegistry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/mediaStorageRegistry.ts) maintains a singleton `Map<string, MediaStorageAdapter>` that holds all registered adapters. On server initialization, it automatically configures the built-in local-disk adapter via `configureLocalDisk()`. Plugins with the `media.storage.adapter` permission inject custom adapters through the registration API, which the registry stores for subsequent lookups.

The registry exposes two resolution methods:
- **`resolve(adapterId, role)`** – Returns the adapter assigned to a specific role, falling back to the local-disk adapter when the ID is empty.
- **`resolveForRead(adapterId)`** – Retrieves the adapter responsible for reading a stored asset, bypassing role validation.

### Adapter Election and Persistence

Which adapter handles uploads for each role is determined by the repository layer in [`server/repositories/mediaStorageAdapters.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/mediaStorageAdapters.ts). This module reads from and writes to the `active_media_storage_adapter` database table, allowing administrators to elect specific adapters per role through the admin UI. Once elected, the registry caches these assignments to minimize database queries during high-throughput upload operations.

## How the Upload Workflow Works

The Instatic Media workspace storage adapter system orchestrates uploads through a three-phase handshake that keeps the CMS core agnostic of storage implementation details.

### Begin Write Phase

When a user initiates an upload, the front-end calls the HTTP API exposed in [`server/handlers/cms/media.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/media.ts) (routes under `/admin/api/cms/media/*`). The handler queries `MediaStorageRegistry.resolve()` to determine the active adapter for the asset's role, then invokes `adapter.beginWrite({ role, filename, mimeType })`. This returns a `MediaStorageUploadPlan` containing:
- The HTTP method (`PUT` or `POST`)
- A pre-signed URL for direct client uploads
- Required headers for authentication

### Client Streaming

The client streams file bytes directly to the signed URL provided in the upload plan. This bypasses the Instatic server for the actual data transfer, reducing bandwidth costs and latency. For adapters that do not support direct-to-storage uploads, the `writeBytes()` method handles the byte stream server-side before forwarding to the destination.

### Finalize and Abort Operations

After the client confirms successful upload, the handler calls `adapter.finalizeWrite({ storagePath, uploadReceipts })`, persisting the asset metadata in the CMS database. If the upload fails or is cancelled, `adapter.abortWrite({ storagePath })` triggers cleanup routines—such as deleting partial S3 objects or removing temporary disk files—ensuring storage consistency.

## Reading Stored Assets

Asset retrieval follows a simplified path. When a published page requests a media URL, the CMS extracts the adapter ID from the stored `storagePath` and invokes `MediaStorageRegistry.resolveForRead()`. For the local-disk adapter, this returns a relative filesystem path; for external adapters, it returns the public CDN URL cached during the finalize phase. This resolution occurs in [`server/handlers/cms/media.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/handlers/cms/media.ts) when serving media through the admin API or public routes.

## Implementing a Custom Storage Adapter

Developers can extend the system by implementing the `MediaStorageAdapter` interface and registering the instance through the plugin SDK.

### Example: S3-Compatible Adapter

```typescript
import type { MediaStorageAdapter, MediaStorageUploadPlan, MediaAssetRole } from '@core/plugin-sdk/types/media'

export const s3Adapter: MediaStorageAdapter = {
  id: 's3',
  roles: ['image', 'avatar'] as MediaAssetRole[],
  
  async beginWrite({ role, filename, mimeType }) {
    // Generate pre-signed PUT URL for your S3 bucket
    const signedUrl = await generatePresignedUrl(filename, mimeType, 'putObject')
    
    return {
      storagePath: `s3://${filename}`,
      uploadPlan: {
        method: 'PUT',
        url: signedUrl,
        headers: { 'Content-Type': mimeType }
      }
    } as MediaStorageUploadPlan
  },

  async writeBytes(plan, stream) {
    // Client uploads directly to S3; no server-side streaming required
    return Promise.resolve()
  },

  async finalizeWrite({ storagePath, uploadReceipts }) {
    // Verify uploadReceipts checksums or trigger post-processing
    console.log(`Asset committed at ${storagePath}`)
  },

  async abortWrite({ storagePath }) {
    // Delete partial upload from S3 bucket
    const key = storagePath.replace('s3://', '')
    await deleteS3Object(key)
  }
}

```

### Registering the Adapter

Plugins with storage permissions register adapters during initialization via the CMS API:

```typescript
// Inside plugin initialization code
await api.cms.media.registerStorageAdapter({
  id: s3Adapter.id,
  roles: s3Adapter.roles,
  // The host bridge forwards the full adapter implementation to MediaStorageRegistry
})

```

## Built-in Local Disk Adapter

Instatic ships with a default local-disk adapter implemented in the core plugin system. This adapter handles all roles by default when no external adapter is elected, storing files in a configurable directory on the host filesystem. It uses empty string `""` as its ID, which `MediaStorageRegistry.resolve()` treats as the universal fallback. The adapter implements streaming writes to the local filesystem and serves files through static middleware or direct file reads, depending on the deployment configuration.

## Summary

- The **Instatic Media workspace storage adapter system** provides a **TypeScript-based plugin architecture** that decouples storage backends from CMS logic through the `MediaStorageAdapter` interface.
- The **MediaStorageRegistry** ([`src/core/plugins/mediaStorageRegistry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/mediaStorageRegistry.ts)) maintains a singleton registry of adapters and provides resolution methods for read and write operations.
- **Role-based adapter election** persists in the `active_media_storage_adapter` table via [`server/repositories/mediaStorageAdapters.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/mediaStorageAdapters.ts), allowing per-role storage configuration.
- The **upload workflow** follows a three-phase pattern: `beginWrite` (generate signed URLs), client-side streaming, and `finalizeWrite` (commit metadata).
- **Custom adapters** integrate cleanly by implementing the interface and calling `api.cms.media.registerStorageAdapter()`, enabling support for S3, R2, Azure Blob, or proprietary CDNs without modifying core CMS code.

## Frequently Asked Questions

### What is the purpose of the MediaStorageAdapter interface?

The `MediaStorageAdapter` interface defines a strict contract that all storage backends must implement, ensuring the CMS core can initiate uploads, stream bytes, finalize assets, and abort failed transfers without knowing the underlying storage technology. This abstraction enables swapping between local disk, cloud object storage, or custom CDN implementations through configuration alone.

### How does Instatic decide which adapter to use for a specific media type?

Instatic resolves adapters by **role** using the `MediaStorageRegistry.resolve()` method, which checks the `active_media_storage_adapter` database table to find the elected adapter for the requested `MediaAssetRole` (such as `image` or `avatar`). If no specific adapter is elected, the system falls back to the built-in local-disk adapter.

### Can I use multiple storage adapters simultaneously in the same Instatic instance?

Yes. The registry supports multiple concurrent adapters, each handling distinct roles or overlapping capabilities. For example, you can configure the S3 adapter for `image` assets while keeping the local-disk adapter for `font` files, or route high-resolution photos to a CDN while storing thumbnails locally. Each role's active adapter is tracked independently in the database.

### What happens if an upload fails halfway through?

If the client fails to complete the upload or the finalize step encounters an error, the system invokes `adapter.abortWrite({ storagePath })`. This method triggers cleanup logic specific to the adapter—such as deleting partial S3 objects or removing temporary disk files—preventing orphaned data and reclaiming storage space immediately.