R RenderComp
remotion batch-rendering csv nodejs typescript

Batch Render Remotion Videos from a CSV Data File

By RenderComp Team Editorial policy

Remotion treats every frame as a pure function of props and a frame number. That model has a direct consequence for batch work: if you can render one video by passing props to a React component, you can render hundreds by mapping any data source over the same component. The expensive part is bundling, not rendering, and that distinction shapes the whole approach.

Calling bundle() from @remotion/bundler runs webpack over your project and writes a static build to disk. For a batch of fifty personalized videos, that step belongs outside the loop. The result is a serveUrl string you reuse across every call to renderMedia(). Each call receives a different inputProps object derived from a CSV row and writes to a different output path. Webpack runs once; Chromium runs once per video.

Video length is the second variable. The composition object you pass to renderMedia() carries a durationInFrames value. When that value differs per row, calculateMetadata in the composition definition pulls the duration from props at render time, and selectComposition() in the render script resolves it with the actual row data before Chromium touches a single frame.


The composition

This composition renders a name and a headline with a spring entrance. The durationInFrames prop lives in the schema so each CSV row can set its own video length.

// src/compositions/PersonCard.tsx
import React from "react";
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
import { z } from "zod";

export const personCardSchema = z.object({
  name: z.string(),
  headline: z.string(),
  durationInFrames: z.number().int().min(30).default(90),
});

type Props = z.infer<typeof personCardSchema>;

export const PersonCard: React.FC<Props> = ({ name, headline }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const progress = spring({
    frame,
    fps,
    config: { damping: 14, stiffness: 120 }, // settles near frame 18 at 30 fps
  });

  const translateY = interpolate(progress, [0, 1], [24, 0]);

  return (
    <AbsoluteFill style={{ background: "#0f0f0f", justifyContent: "center", alignItems: "center" }}>
      <div style={{ opacity: progress, transform: `translateY(${translateY}px)`, textAlign: "center" }}>
        <p style={{ color: "#fff", fontSize: 64, fontWeight: 700, margin: 0 }}>{name}</p>
        <p style={{ color: "#aaa", fontSize: 32, margin: "12px 0 0" }}>{headline}</p>
      </div>
    </AbsoluteFill>
  );
};

The durationInFrames prop is received by the schema but not read inside the component. The composition root passes it to calculateMetadata instead.

// src/Root.tsx
import React from "react";
import { Composition } from "remotion";
import { PersonCard, personCardSchema } from "./compositions/PersonCard";

export const RemotionRoot: React.FC = () => (
  <Composition
    id="PersonCard"
    component={PersonCard}
    schema={personCardSchema}
    defaultProps={{ name: "Preview Name", headline: "Preview Role", durationInFrames: 90 }}
    fps={30}
    width={1920}
    height={1080}
    durationInFrames={90}
    calculateMetadata={({ props }) => ({
      durationInFrames: props.durationInFrames,
    })}
  />
);

calculateMetadata runs before the first frame renders. Returning { durationInFrames: props.durationInFrames } overrides the static durationInFrames={90} on the <Composition> tag with the current row’s value. Without it, every output video is exactly 90 frames at 30 fps regardless of what the CSV column contains.


Parsing the CSV

Column headers must match prop names. A fourth column, outputFile, names each output file.

name,headline,durationInFrames,outputFile
Ana Ruiz,Engineering Lead,120,ana-ruiz.mp4
Ben Okafor,Product Manager,90,ben-okafor.mp4
Cleo Tan,Design Systems,150,cleo-tan.mp4

csv-parse/sync turns the file into an array of plain objects with a single call.

// scripts/batch-render.ts
import { readFileSync } from "fs";
import { parse } from "csv-parse/sync";

interface CsvRow {
  name: string;
  headline: string;
  durationInFrames: string; // CSV values are always strings
  outputFile: string;
}

const raw = readFileSync("data/people.csv", "utf-8");
const rows = parse(raw, {
  columns: true,        // use first row as property names
  skip_empty_lines: true,
  trim: true,           // strip whitespace from cell values
}) as CsvRow[];

Every cell in a CSV is a string. durationInFrames arrives as "120", not 120. Passing it directly to Zod’s z.number() validator throws, so convert numeric columns with Number() before assembling inputProps.


The render script

Bundle once, then call selectComposition() and renderMedia() for each row.

import path from "path";
import { mkdirSync } from "fs";
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";

const OUT_DIR = "out";
mkdirSync(OUT_DIR, { recursive: true });

async function main() {
  const serveUrl = await bundle({
    entryPoint: path.resolve("src/index.ts"),
    onProgress: (p) => process.stdout.write(`\rbundling: ${p}%`),
  });
  console.log("\nbundle ready");

  for (const row of rows) {
    const inputProps = {
      name: row.name,
      headline: row.headline,
      durationInFrames: Number(row.durationInFrames),
    };

    // Resolves calculateMetadata with the actual row props
    const composition = await selectComposition({
      serveUrl,
      id: "PersonCard",
      inputProps,
    });

    const outputLocation = path.join(OUT_DIR, row.outputFile);

    await renderMedia({
      composition,
      serveUrl,
      codec: "h264",
      outputLocation,
      inputProps,
      onProgress: ({ progress }) =>
        process.stdout.write(`\r${row.name}: ${Math.round(progress * 100)}%`),
    });

    console.log(`\nwrote ${outputLocation}`);
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

selectComposition() is what connects calculateMetadata to the current row. If you skip it and pass a composition object from an earlier getCompositions() call, the durationInFrames on that object reflects the composition’s default props, not the row you are currently rendering. The output file would have the wrong length.


Controlled concurrency

Sequential rendering is safe and leaves no room for output collisions. For batches where total wall-clock time matters, parallel rendering is possible if you cap the number of simultaneous Chromium processes. Calling Promise.all() over the full row array without a limit spawns as many instances as there are rows, which exhausts available memory quickly.

A worker pool keeps concurrency bounded without any external dependency.

const CONCURRENCY = 4;

async function renderBatch(serveUrl: string, rows: CsvRow[]) {
  const queue = [...rows];

  const workers = Array.from({ length: CONCURRENCY }, async () => {
    while (queue.length > 0) {
      const row = queue.shift();
      if (!row) return;

      const inputProps = {
        name: row.name,
        headline: row.headline,
        durationInFrames: Number(row.durationInFrames),
      };

      const composition = await selectComposition({
        serveUrl,
        id: "PersonCard",
        inputProps,
      });

      await renderMedia({
        composition,
        serveUrl,
        codec: "h264",
        outputLocation: path.join(OUT_DIR, row.outputFile),
        inputProps,
      });

      console.log(`done: ${row.outputFile}`);
    }
  });

  await Promise.all(workers);
}

queue.shift() inside the async worker runs on a single Node.js thread, so no row is processed twice. Start with CONCURRENCY = 4 and raise the value while watching peak RSS; on most hardware the bottleneck is CPU encoding, not I/O or Chromium startup time.

Now available

Get 1,000+ Remotion Templates

Pay once — no subscription. Lifetime updates. TypeScript-first.

View pricing →

Free 50

Get the 50 templates as a ZIP

Enter your email and the ZIP link arrives right away.

Send me the 50-template ZIP. I agree to receive RenderComp template updates and product news, including the paid library (a few emails, one-click unsubscribe). Privacy policy

You do not have to use email. The GitHub repository stays public and needs no signup. Open the repository