How to Set Up FFmpeg in React (Vite, 2025)

tech

How to Set Up FFmpeg in React (Vite, 2025)

0) What we’re building

  • Load FFmpeg.wasm in the browser
  • Convert an uploaded video to MP3 (sample task)
  • Works in dev and production builds

1) Install the right packages

# React app (skip if you already have one)
npm create vite@latest my-app -- --template react
cd my-app

# FFmpeg packages (new API)
npm i @ffmpeg/ffmpeg @ffmpeg/core @ffmpeg/util

Why 3 packages?

  • @ffmpeg/ffmpeg – the JS wrapper (gives you FFmpeg)
  • @ffmpeg/core – the actual WebAssembly + worker binaries
  • @ffmpeg/util – helpers like fetchFile

2) Vite config (COOP/COEP + WASM as an asset)

Create or edit vite.config.ts (or .js):

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  // Make sure the .wasm files are served/copied correctly
  assetsInclude: ['**/*.wasm'],
  server: {
    // Needed for SharedArrayBuffer in browsers (ffmpeg can use threads)
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
  },
  build: {
    target: 'esnext',
  },
})

If you deploy behind Nginx, mirror these two headers in your site config:

add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;

3) The “new way” to initialize FFmpeg

Key trick: import the URLs of the core and wasm files with ?url.

Create src/lib/ffmpeg.ts:

import { FFmpeg } from '@ffmpeg/ffmpeg'
import { fetchFile } from '@ffmpeg/util'

// Import the *URL strings* for core + wasm
import coreURL from '@ffmpeg/core?url'
import wasmURL from '@ffmpeg/core/wasm?url'

// Create a singleton so we only load once
let ffmpegInstance: FFmpeg | null = null

export async function getFFmpeg() {
  if (ffmpegInstance) return ffmpegInstance

  const ffmpeg = new FFmpeg()
  await ffmpeg.load({ coreURL, wasmURL }) // <-- the important part
  ffmpegInstance = ffmpeg
  return ffmpeg
}

// Example: transcode any uploaded file to MP3
export async function toMp3(file: File): Promise<Blob> {
  const ffmpeg = await getFFmpeg()

  // Put the input file into FFmpeg's virtual FS
  await ffmpeg.writeFile('input', await fetchFile(file))

  // Run FFmpeg
  await ffmpeg.exec(['-i', 'input', '-vn', '-acodec', 'libmp3lame', 'out.mp3'])

  // Read the output back
  const data = await ffmpeg.readFile('out.mp3')
  return new Blob([data], { type: 'audio/mpeg' })
}

4) A simple React component

Create src/App.tsx:

import { useState } from 'react'
import { toMp3 } from './lib/ffmpeg'

export default function App() {
  const [busy, setBusy] = useState(false)
  const [url, setUrl] = useState<string | null>(null)

  async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0]
    if (!file) return
    setBusy(true)
    try {
      const mp3 = await toMp3(file)
      setUrl(URL.createObjectURL(mp3))
    } finally {
      setBusy(false)
    }
  }

  return (
    <main style={{ padding: 24, fontFamily: 'system-ui' }}>
      <h1>FFmpeg + React (Vite)</h1>
      <input type="file" accept="video/*,audio/*" onChange={handleFile} />
      {busy && <p>Transcoding… this runs fully in your browser.</p>}
      {url && (
        <p>
          <audio controls src={url} />
          <a href={url} download="output.mp3">Download MP3</a>
        </p>
      )}
    </main>
  )
}

Run it:

npm run dev

If the browser console warns about SharedArrayBuffer or threads, double-check those COOP/COEP headers in the Vite config (and on your production server).


5) Common “gotchas” (learned the hard way)

1. Wrong imports

Use the new style:

import { FFmpeg } from '@ffmpeg/ffmpeg'
import coreURL from '@ffmpeg/core?url'
import wasmURL from '@ffmpeg/core/wasm?url'
await new FFmpeg().load({ coreURL, wasmURL })

Do not use the old createFFmpeg API with these versions.

2. Versions mismatch / empty dist folders

Install all three packages together:

npm i @ffmpeg/ffmpeg @ffmpeg/core @ffmpeg/util

3. WASM not found in build

Keep assetsInclude: ['**/*.wasm'] in vite.config and use ?url imports.

4. Cross-origin isolation

For threaded builds, you need:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Add in Vite server.headers and also in your production server (Nginx, etc.).

5. “TypeError: ffmpeg.writeFile is not a function”

You’re mixing APIs. On the new API, use ffmpeg.writeFile, ffmpeg.exec, ffmpeg.readFile. Don’t copy FS examples from older blog posts.


6) Bonus: convert to GIF (quick snippet)

export async function toGif(file: File): Promise<Blob> {
  const ffmpeg = await getFFmpeg()
  await ffmpeg.writeFile('input.mp4', await fetchFile(file))
  await ffmpeg.exec([
    '-i', 'input.mp4',
    '-vf', 'fps=12,scale=480:-1:flags=lanczos',
    '-loop', '0',
    'out.gif'
  ])
  const data = await ffmpeg.readFile('out.gif')
  return new Blob([data], { type: 'image/gif' })
}

7) Production notes

  • Host with HTTPS (some browsers restrict SharedArrayBuffer on insecure origins).
  • If you use a CDN, make sure the COOP/COEP headers survive.
  • Large files consume RAM — FFmpeg runs in the browser. Consider chunked workflows for huge videos.

That’s it — the minimal, actually-working React + Vite setup for FFmpeg.wasm in 2025.

Related Posts

Raylib on iOS: Building a C++ Game with Xcode
tech

Raylib on iOS: Building a C++ Game with Xcode

A practical guide to getting a C++ raylib game running on iOS, based on what I learned while porting Kasino. Covers raylib-iOS, Xcode, UIScene, the iOS Simulator, and physical devices.

The Mesh Internet: How Reticulum and NomadNet Work
tech

The Mesh Internet: How Reticulum and NomadNet Work

An introduction to Reticulum, MeshChat, and NomadNet—tools for building decentralized mesh networks and communication systems.

Best Free Tools for Indie Game Dev (2025)
tech

Best Free Tools for Indie Game Dev (2025)

Editors, art tools, audio, and pipeline helpers you can use on a $0 budget.