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 updatedUploads 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
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
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
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:
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
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:
- OPFS storage — the file is written to the browser's Origin Private File System. A blob URL is created for immediate playback.
- Editor state — the file appears in the media panel instantly. The user can drag it to the timeline right away.
- Background upload —
adapter.upload(file)is called. A sync indicator appears on the media card. - Permanent URL — when the upload completes, the permanent URL is associated with the OPFS entry.
- 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:
- Timeline cleanup — any timeline items referencing the file are removed first.
- Remote delete — if your adapter implements
delete, it's called with the permanent URL to remove the file from your storage. - OPFS removal — the local copy is removed from the browser's file system.
- 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:
- The editor checks if a local OPFS copy exists for each permanent URL.
- If found, the local blob URL is used for playback (faster, no network dependency).
- The permanent URL mapping is re-registered so the next save serializes correctly.
- 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.
Configuring the library tabs
Each media panel (video, images, sounds) shows its library as up to two tabs: Stock and My Library. By default the tabs adapt to what actually has content, so you rarely need to configure anything:
- Stock shows only when that panel has stock adaptors (e.g. a Pexels adaptor for video or images). No adaptors → no Stock tab.
- My Library always shows — uploads work locally via OPFS whether or not you pass a
localMediaadapter. - The active tab defaults to the first visible tab.
This means passing no stock adaptors for a media type gives you a "My Library only" panel with no configuration:
// No stock sources → only the My Library (uploads) tab shows
<ReactVideoEditor
projectId="my-project"
adaptors={{ video: [], images: [], audio: [], localMedia: mediaAdapter }}
/>The mediaLibrary prop
For explicit control over which tabs appear, their order, and their labels, pass the optional mediaLibrary prop:
<ReactVideoEditor
projectId="my-project"
mediaLibrary={{
tabs: ['uploads', 'stock'], // which tabs + order (authoritative)
labels: { uploads: 'Brand Assets' }, // rename tabs
}}
/>| Field | Type | Description |
|---|---|---|
tabs | MediaLibraryTabId[] | Which tabs to show, in this order. Authoritative when set — exactly these tabs render, in this order, regardless of adaptors. Omit to derive tabs from what has content. |
labels | Partial<Record<MediaLibraryTabId, string>> | Override tab labels. Defaults: stock → "Stock", uploads → "My Library". |
MediaLibraryTabId is 'stock' | 'uploads'. Both types are exported for typing your config:
import type { MediaLibraryConfig, MediaLibraryTabId } from '@reactvideoeditor/react-video-editor/types';When to use tabs vs. adaptors
For most apps you don't need mediaLibrary at all — just pass (or omit) stock adaptors and the tabs follow. Reach for mediaLibrary.tabs when you want to force a tab set that doesn't match your adaptors (e.g. hide Stock even though a Pexels adaptor is present), or mediaLibrary.labels to rename a tab.
Deprecated: hideUploads
The internal hideUploads prop still works but is deprecated. Use mediaLibrary={{ tabs: ['stock'] }} to hide the uploads tab instead.
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.