Minimal OpenCut‑style Video Editor Clone

A quick‑start TypeScript project that lets you upload, list, and play videos locally

Welcome to another official UVF IT guide – this time we’ll build a tiny web‑based video editor inspired by OpenCut. You’ll get a TypeScript‑Node backend with SQLite and a React front‑end that can upload and play videos, all running on your laptop in under an hour.

The stack is completely free‑tier: Node 18+, Express, better‑sqlite3, Vite‑React, and no external paid APIs. It’s a stripped‑down version – no trimming, effects, or AI – but it shows the core data flow of OpenCut’s editor API.

Before you start

Minimal OpenCut Clone Architecture
LOCAL STACK HTTP JSON SQL queries file I/O video src URL React UI (Vite) uploads & playback Express API (TS) CRUD + file serve SQLite DB video metadata Filesystem uploaded videos
1

Phase 1: Scaffold the project

We start by creating a monorepo that keeps the UI and API separate but lets them share TypeScript settings via npm workspaces. This layout mirrors real‑world micro‑service projects while staying simple enough for a beginner.

The commands below initialise the repo, add two workspace folders, and install the core tools: Vite for the React front‑end, Express for the API, and better‑sqlite3 for the database layer. All dependencies are installed locally, so no global packages are required.

mkdir opencut-clone && cd opencut-clone
npm init -y
# Enable workspaces
cat > package.json <<'EOF'
{
  "private": true,
  "workspaces": ["frontend","backend"]
}
EOF
mkdir frontend backend
# Frontend: Vite + React + TypeScript
cd frontend
npm init vite@latest . -- --template react-ts
npm install
cd ..
# Backend: Express + TypeScript + better-sqlite3
cd backend
npm init -y
npm install express better-sqlite3
npm install -D typescript @types/node @types/express ts-node-dev
npx tsc --init --rootDir src --outDir dist --esModuleInterop true --moduleResolution node
mkdir src
cd ..
2

Phase 2: Data model & SQLite setup

The backend needs a tiny SQLite database to remember each uploaded video. We’ll define a videos table with id, filename, title, and uploaded_at columns, and expose a helper to run queries.

On the first request the code will create the database file and the table if they do not exist, keeping the setup completely automatic for developers.

backend/src/db.ts

import Database from 'better-sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';

// Resolve a stable location for the SQLite file next to the compiled code
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dbPath = path.resolve(__dirname, '../../data/videos.db');

// Initialise the DB (creates file if missing)
const db = new Database(dbPath);

// Ensure the videos table exists
const initStmt = `
  CREATE TABLE IF NOT EXISTS videos (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    filename TEXT NOT NULL,
    title TEXT NOT NULL,
    uploaded_at TEXT NOT NULL
  )`;
db.exec(initStmt);

export function addVideo(filename: string, title: string) {
  const stmt = db.prepare('INSERT INTO videos (filename, title, uploaded_at) VALUES (?, ?, datetime('now'))');
  const info = stmt.run(filename, title);
  return info.lastInsertRowid as number;
}

export function listVideos() {
  const stmt = db.prepare('SELECT * FROM videos ORDER BY uploaded_at DESC');
  return stmt.all();
}

export function getVideo(id: number) {
  const stmt = db.prepare('SELECT * FROM videos WHERE id = ?');
  return stmt.get(id);
}
3

Phase 3: API routes for upload & list

In this step we create two tiny endpoints: one to return a JSON list of all stored videos and another to accept a multipart file upload. The upload route saves the file to a local videos folder and records its name and original title in memory, which is enough for a minimal clone. We also expose the saved files through a static route so the frontend can stream them, and enable CORS so the React UI can call the API without browser blocks.

backend/src/server.ts

import express from 'express';
import cors from 'cors';
import multer from 'multer';
import path from 'path';
import fs from 'fs';

const app = express();
app.use(cors());
app.use('/videos', express.static(path.join(__dirname, '../videos')));

const upload = multer({ dest: path.join(__dirname, '../videos') });
interface VideoMeta { name:string; title:string; }
const videos: VideoMeta[] = [];

app.get('/api/videos', (req, res) => {
  res.json(videos);
});

app.post('/api/upload', upload.single('file'), (req, res) => {
  const file = req.file;
  const title = req.body.title || file?.originalname || 'Untitled';
  if (!file) return res.status(400).json({error:'No file'});
  const name = file.filename + path.extname(file.originalname);
  const newPath = path.join(file.destination, name);
  fs.renameSync(file.path, newPath);
  videos.push({name, title});
  res.json({success:true, name, title});
});

const PORT = 8787;
app.listen(PORT, () => console.log(`Backend listening on http://localhost:${PORT}`));
4

Phase 4: Simple React UI

Now we build a tiny React app that lets the user pick a video file, type a title, and upload it to the backend we just wrote. After a successful upload we refresh the list by fetching /api/videos and render each entry with an HTML5 <video> element for playback. The UI is deliberately simple – just a form and a list – so you can see the whole flow without extra libraries.

frontend/src/App.tsx

import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
import axios from 'axios';

interface Video { name:string; title:string; }

function App() {
  const [videos, setVideos] = useState<Video[]>([]);
  const [file, setFile] = useState<File | null>(null);
  const [title, setTitle] = useState('');

  const fetchVideos = async () => {
    const res = await axios.get('http://localhost:8787/api/videos');
    setVideos(res.data);
  };

  useEffect(() => { fetchVideos(); }, []);

  const handleFile = (e: ChangeEvent<HTMLInputElement>) => {
    if (e.target.files?.[0]) setFile(e.target.files[0]);
  };

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    if (!file) return alert('Select a file');
    const form = new FormData();
    form.append('file', file);
    form.append('title', title);
    await axios.post('http://localhost:8787/api/upload', form, {
      headers: { 'Content-Type': 'multipart/form-data' },
    });
    setFile(null);
    setTitle('');
    fetchVideos();
  };

  return (
    <div style={{padding:'1rem'}}>
      <h1>OpenCut Mini</h1>
      <form onSubmit={handleSubmit} style={{marginBottom:'1rem'}}>
        <input type="text" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
        <input type="file" accept="video/*" onChange={handleFile} required />
        <button type="submit">Upload</button>
      </form>
      <ul>
        {videos.map(v=>(
          <li key={v.name} style={{marginBottom:'1rem'}}>
            <strong>{v.title}</strong><br/>
            <video src={`http://localhost:8787/videos/${v.name}`} controls width="320" />
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;
5

Phase 5: Run & verify locally

With both sides ready we can start the Express server and the Vite development server for the React UI. Open the browser at the Vite URL, use the form to upload a small MP4, and you should see the new entry appear instantly with a playable video. This confirms the end‑to‑end flow: file travels from the browser, is saved on the backend, and is served back for playback.

# From the project root
# 1. Install backend deps and start it
cd backend && npm i && npx ts-node src/server.ts &
# 2. Install frontend deps and start Vite dev server
cd ../frontend && npm i && npm run dev

Verifying the Stack

CheckCommand / Why it matters
Backend is listening on http://localhost:8787Run curl http://localhost:8787/api/videos and see a JSON array (empty at start)
Frontend loads at http://localhost:5173 without CORS errorsOpen devtools Network tab; requests to http://localhost:8787/* show status 200 and no blocked‑by‑CORS messages
Uploaded video appears in the list and playsSelect a small MP4, click Upload, then verify the <video> element renders and plays the streamed file

What’s next?

You now have the skeleton of OpenCut’s editor API. From here you can add trimming endpoints, store project JSON, or plug in FFmpeg for server‑side rendering – all while keeping the same minimal stack.