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 dedupedUploads 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>;
}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)
}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 two server-side routes — one for uploading, one for listing. 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 });
}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? }
},
};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 an error indicator, but the file can still be added to the timeline and used for editing. The permanent URL simply won't be available until a successful upload.
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.
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.