RVE LogoReact Video EditorDOCS
RVE SDK/Quick Start/Setup Sounds

Setup Sounds

Integrate LotsOfSounds audio into your video editor via a server-side proxy

RVE has a built-in LotsOfSounds audio adaptor. It uses a proxy pattern. Your server holds the API key. The editor calls your local routes and never calls the LotsOfSounds API directly.

Why a proxy?

The editor runs in the browser. If you called the LotsOfSounds API directly from the client, your API key would be exposed in network requests for anyone to steal. By routing through your own server, the key stays secret and never leaves your backend.

How it works

Editor UI calls /api/sounds/search?q=..., which calls your server and api.lotsofsounds.com
Editor UI calls /api/sounds/stream/[id], which calls your server and api.lotsofsounds.com

If you do not set adaptors.audio, the editor uses the built-in LotsOfSounds adaptor. You only add two server-side proxy routes.

Step 1: Get a LotsOfSounds API key

Sign up at lotsofsounds.com and get an API key. Add it to your environment:

NEXT_LOTSOFSOUNDS_API_KEY=your_api_key_here

Step 2: Create the search proxy route

Create app/api/sounds/search/route.ts:

import { NextRequest, NextResponse } from 'next/server';

const LOTSOFSOUNDS_API_URL = 'https://api.lotsofsounds.com';

export async function GET(request: NextRequest) {
  const apiKey = process.env.NEXT_LOTSOFSOUNDS_API_KEY;
  if (!apiKey) {
    return NextResponse.json({ error: 'API key not configured' }, { status: 500 });
  }

  const { searchParams } = new URL(request.url);
  const params = new URLSearchParams();
  const q = searchParams.get('q');
  if (q) params.set('q', q);
  params.set('limit', searchParams.get('limit') || '20');
  params.set('page', searchParams.get('page') || '1');

  const response = await fetch(
    `${LOTSOFSOUNDS_API_URL}/api/v1/sounds?${params.toString()}`,
    { headers: { 'x-api-key': apiKey } }
  );

  if (!response.ok) {
    return NextResponse.json(
      { error: `API error: ${response.status}` },
      { status: response.status }
    );
  }

  return NextResponse.json(await response.json());
}

Step 3: Create the stream proxy route

Create app/api/sounds/stream/[id]/route.ts. The route forwards the browser Range header to the audio file. The browser uses range requests to seek in a sound, so the route must return the upstream status (200, 206, or 416) and the range headers.

import { NextRequest, NextResponse } from 'next/server';

const LOTSOFSOUNDS_API_URL = 'https://api.lotsofsounds.com';

/** The upstream headers that the client needs for range requests. */
const PASSTHROUGH_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges'];

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const apiKey = process.env.NEXT_LOTSOFSOUNDS_API_KEY;
  if (!apiKey) {
    return NextResponse.json({ error: 'API key not configured' }, { status: 500 });
  }

  const { id } = await params;
  if (!id) {
    return NextResponse.json({ error: 'Sound ID is required' }, { status: 400 });
  }

  // 1. Get a signed download URL. The API key stays on the server.
  const downloadResponse = await fetch(
    `${LOTSOFSOUNDS_API_URL}/api/v1/sounds/${encodeURIComponent(id)}/download`,
    { headers: { 'x-api-key': apiKey } }
  );

  if (!downloadResponse.ok) {
    return NextResponse.json(
      { error: `API error: ${downloadResponse.status}` },
      { status: downloadResponse.status }
    );
  }

  const data = await downloadResponse.json();
  const signedUrl = data?.data?.download_url;

  if (!signedUrl) {
    return NextResponse.json({ error: 'No download URL returned' }, { status: 502 });
  }

  // 2. Fetch the audio with the Range header from the browser.
  const range = request.headers.get('range');
  const audioResponse = await fetch(signedUrl, range ? { headers: { Range: range } } : undefined);

  if (![200, 206, 416].includes(audioResponse.status)) {
    return NextResponse.json({ error: 'Failed to fetch audio file' }, { status: 502 });
  }

  // 3. A 416 has no audio, so only the Content-Range header goes back.
  if (audioResponse.status === 416) {
    const contentRange = audioResponse.headers.get('content-range');
    return new NextResponse(null, {
      status: 416,
      headers: contentRange ? { 'Content-Range': contentRange } : undefined,
    });
  }

  // 4. Return the upstream status (200 or 206) and the range headers.
  const headers = new Headers({ 'Cache-Control': 'public, max-age=3600' });
  for (const name of PASSTHROUGH_HEADERS) {
    const value = audioResponse.headers.get(name);
    if (value) headers.set(name, value);
  }

  return new NextResponse(audioResponse.body, { status: audioResponse.status, headers });
}

Forward the Range header

If the route always returns 200 with the full file, the browser cannot seek in the sound. Then a sound can start late or play from the wrong position.

Step 4: Open the Sound panel

If you do not set adaptors.audio, the editor uses the built-in LotsOfSounds adaptor. The Sound panel shows in the editor sidebar with search and pagination.

The built-in LotsOfSounds adaptor is not a public export. If you set adaptors.audio, your array replaces the built-in adaptor. To keep LotsOfSounds and add other sources, write an adaptor for each source in your app.

Custom sound adaptors

A custom adaptor implements the SoundAdaptor type. search returns the audio items for one page of results. getAudioUrl returns the URL that the editor plays.

import { ReactVideoEditor } from '@reactvideoeditor/react-video-editor/react-video-editor';
import type { SoundAdaptor } from '@reactvideoeditor/react-video-editor/types';

const mySoundAdaptor: SoundAdaptor = {
  name: 'my-sounds',
  displayName: 'My Sounds',
  description: 'Sound effects from my library',
  requiresAuth: false,

  search: async ({ query = '', page = 1, perPage = 20 }) => {
    const params = new URLSearchParams({ q: query, page: String(page), limit: String(perPage) });
    const response = await fetch(`/api/my-sounds?${params}`);
    const result = await response.json();

    return {
      items: result.sounds.map((sound) => ({
        id: sound.id,
        title: sound.name,
        artist: 'My Library',
        duration: sound.durationSeconds, // seconds
        file: `/api/my-sounds/stream/${sound.id}`,
        // Optional. Use null for a value that your source does not measure.
        timing: { onsetMs: sound.onsetMs, peakMs: sound.peakMs, tailMs: sound.tailMs },
        loudness: { integratedLufs: sound.lufs, truePeakDb: sound.truePeakDb },
      })),
      totalCount: result.total,
      hasMore: page * perPage < result.total,
    };
  },

  getAudioUrl: (audio) => audio.file,
};

<ReactVideoEditor
  projectId="my-project"
  adaptors={{ audio: [mySoundAdaptor] }}
/>

Each audio item has these fields:

FieldTypeDescription
id*stringA unique id for the sound
title*stringThe name in the Sound panel
artist*stringThe author or the source
duration*numberThe length of the sound, in seconds
file*stringThe URL of the audio file
thumbnailstringA waveform or cover image URL
attribution{ author?, source?, license?, url? }Licence and credit data
timing{ onsetMs, peakMs, tailMs } | nullTime points in milliseconds from the start of the file. onsetMs is the time where the sound becomes audible. peakMs is the loudest point (the "hit"). tailMs is the time where the sound becomes silent again. Each value can be null.
loudness{ integratedLufs, truePeakDb } | nullThe integrated loudness in LUFS and the true peak in dBTP. Each value can be null.

The built-in LotsOfSounds adaptor fills timing and loudness from the LotsOfSounds API.

Sounds load before they start

The editor mounts each sound 30 frames before its start frame. Thus a short sound loads in time and does not play late. If your audio route is slow, make sure that it supports range requests and caching.