RVE LogoReact Video EditorDOCS
RVE SDK/Quick Start/Setup Media Library

Setup Media Library

Connect your own storage backend so users can upload, browse, and reuse their media across sessions

RVE stores uploaded files locally in the browser using OPFS by default. This means uploads work out of the box — but they're device-local and disappear if the user clears browser storage.

The localMedia adapter lets you plug in your own storage backend so uploads are persisted to the cloud, and users can browse their full library from any device.

How it works

User uploads file
  → stored in OPFS (instant, local)
  → adapter.upload(file) fires in background
  → permanent URL saved alongside OPFS entry

User opens "My Library" tab
  → OPFS items render immediately
  → adapter.load() fetches remote items
  → results are merged and deduped

User deletes file
  → adapter.delete(url) removes from your storage
  → OPFS copy removed
  → media panel updated

Uploads are stored locally in OPFS first, so the file is immediately available in the editor. The adapter upload runs in the background — if it fails, the file is still usable locally. Each item in the grid shows a sync indicator while uploading.

When the editor saves state, uploaded files are serialized using the permanent URL from your storage (not a temporary blob URL). On page refresh, the editor resolves permanent URLs back to local OPFS copies for instant playback.

The LocalMediaAdapter interface

interface LocalMediaAdapter {
  /** Upload a file to your storage. Return a permanent URL. */
  upload: (file: File) => Promise<MediaUploadResult>;

  /** Load paginated items for the "My Library" grid. */
  load: (params: MediaLibraryLoadParams) => Promise<MediaLibraryLoadResult>;

  /** Delete a file from your storage. Called when a user deletes from the media panel. */
  delete: (url: string) => Promise<void>;
}

upload

Called in the background after a file is stored in OPFS. Return a permanent URL that the editor will use when saving state.

interface MediaUploadResult {
  url: string;        // Permanent URL to the uploaded file
  name?: string;      // Display name override
  thumbnail?: string; // Thumbnail URL (for video/image items)
}

delete

Called when the user deletes a file from the media panel. Receives the permanent URL of the file. Deleting removes it from both OPFS (local) and your storage backend in one action. Remote-only items (loaded via load()) also show a delete button, allowing users to remove files from your storage directly.

async delete(url: string): Promise<void>

load

Called when the user opens a media panel. Provides paginated access to the user's remote library. OPFS items are rendered immediately — remote-only items from load() are merged in and deduped.

interface MediaLibraryLoadParams {
  type: 'video' | 'image' | 'audio';
  cursor?: string;   // Pagination cursor from a previous response
  limit: number;      // Number of items to return
}

interface MediaLibraryLoadResult {
  items: MediaLibraryItem[];
  nextCursor?: string; // Omit or return undefined when there are no more pages
}

Each item must match one of the media type shapes:

// Common fields
interface MediaLibraryItemBase {
  id: string;
  url: string;
  name: string;
  size?: number;
  createdAt: number; // Unix timestamp (ms)
}

// Video items
{ ...base, type: 'video', duration: number, width: number, height: number, thumbnail: string }

// Image items
{ ...base, type: 'image', width: number, height: number, thumbnail: string }

// Audio items
{ ...base, type: 'audio', duration: number }

Step 1: Create your API routes

You need server-side routes for uploading, listing, and optionally deleting. Here's an example using Next.js App Router and S3-compatible storage:

Upload route

app/api/media/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({ region: process.env.AWS_REGION });

export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const file = formData.get('file') as File;

  if (!file) {
    return NextResponse.json({ error: 'No file provided' }, { status: 400 });
  }

  const key = `media/${Date.now()}-${file.name}`;
  const buffer = Buffer.from(await file.arrayBuffer());

  await s3.send(
    new PutObjectCommand({
      Bucket: process.env.S3_BUCKET,
      Key: key,
      Body: buffer,
      ContentType: file.type,
    })
  );

  const url = `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${key}`;

  return NextResponse.json({ url, name: file.name });
}

List route

app/api/media/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const type = searchParams.get('type') || 'video';
  const cursor = searchParams.get('cursor');
  const limit = parseInt(searchParams.get('limit') || '20', 10);

  // Query your database for the user's uploaded media
  const { items, nextCursor } = await db.media.list({
    type,
    cursor,
    limit,
    userId: getCurrentUserId(),
  });

  return NextResponse.json({ items, nextCursor });
}

Delete route

app/api/media/delete/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { S3Client, DeleteObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({ region: process.env.AWS_REGION });

export async function POST(request: NextRequest) {
  const { url } = await request.json();

  if (!url || typeof url !== 'string') {
    return NextResponse.json({ error: 'Missing url' }, { status: 400 });
  }

  // Extract the S3 key from the URL
  const key = new URL(url).pathname.slice(1);

  await s3.send(
    new DeleteObjectCommand({
      Bucket: process.env.S3_BUCKET,
      Key: key,
    })
  );

  return NextResponse.json({ ok: true });
}

Step 2: Create the adapter

The adapter runs client-side and calls your API routes:

lib/media-adapter.ts
import type { LocalMediaAdapter } from '@reactvideoeditor/react-video-editor/types';

export const mediaAdapter: LocalMediaAdapter = {
  async upload(file: File) {
    const formData = new FormData();
    formData.append('file', file);

    const res = await fetch('/api/media/upload', {
      method: 'POST',
      body: formData,
    });

    if (!res.ok) throw new Error('Upload failed');

    return res.json(); // { url, name?, thumbnail? }
  },

  async load({ type, cursor, limit }) {
    const params = new URLSearchParams({ type, limit: String(limit) });
    if (cursor) params.set('cursor', cursor);

    const res = await fetch(`/api/media?${params}`);
    if (!res.ok) throw new Error('Failed to load library');

    return res.json(); // { items, nextCursor? }
  },

  async delete(url: string) {
    const res = await fetch('/api/media/delete', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url }),
    });

    if (!res.ok) throw new Error('Delete failed');
  },
};

Step 3: Pass the adapter to the editor

page.tsx
import { ReactVideoEditor } from '@reactvideoeditor/react-video-editor/react-video-editor';
import { mediaAdapter } from '@/lib/media-adapter';

export default function VideoEditorPage() {
  return (
    <ReactVideoEditor
      projectId="my-project"
      adaptors={{
        localMedia: mediaAdapter,
      }}
    />
  );
}

Upload lifecycle

When a user uploads a file, the following happens:

  1. OPFS storage — the file is written to the browser's Origin Private File System. A blob URL is created for immediate playback.
  2. Editor state — the file appears in the media panel instantly. The user can drag it to the timeline right away.
  3. Background uploadadapter.upload(file) is called. A sync indicator appears on the media card.
  4. Permanent URL — when the upload completes, the permanent URL is associated with the OPFS entry.
  5. State serialization — when the editor saves (autosave or manual), blob URLs are replaced with permanent URLs in the serialized state. Files that haven't finished uploading are serialized as asset:{id} references.

Upload failures are non-blocking

If upload() rejects, the file remains usable locally via OPFS. The media card shows a retry button — clicking it re-reads the file from OPFS and retries the upload. Files from previous sessions that never completed their upload also show the retry button automatically.

Delete lifecycle

When a user deletes a file from the media panel:

  1. Timeline cleanup — any timeline items referencing the file are removed first.
  2. Remote delete — if your adapter implements delete, it's called with the permanent URL to remove the file from your storage.
  3. OPFS removal — the local copy is removed from the browser's file system.
  4. UI update — the file disappears from both the local and remote sections of the media panel.

If the file only exists locally (no adapter, or upload hasn't completed), only the OPFS copy is removed.

onSave and unresolved uploads

The onSave callback is gated — it will not fire while any files on the timeline still have unresolved uploads (i.e. they still use internal asset: references instead of permanent URLs). This prevents your backend from receiving state that other devices can't resolve.

Local autosave (IDB) still writes asset: references since they resolve via OPFS on the same device. The consumer onSave only fires once all timeline assets have permanent URLs from your adapter.

URL resolution on refresh

When the editor loads a saved state that contains permanent URLs:

  1. The editor checks if a local OPFS copy exists for each permanent URL.
  2. If found, the local blob URL is used for playback (faster, no network dependency).
  3. The permanent URL mapping is re-registered so the next save serializes correctly.
  4. If no local copy exists, the permanent URL is used directly.

This means users get instant playback from local storage when available, while the saved state always contains portable, permanent URLs.

Without an adapter

If you don't pass localMedia, the editor uses OPFS-only storage. Uploads work, files appear in the media panel, and everything serializes using asset:{id} references. This is the default behavior and requires no configuration.