A Repeatable Delivery Workflow for Agency Remotion Video Series
By RenderComp Team Editorial policy
When an agency ships a monthly video series for ten clients, the naive approach is to open Remotion Studio, swap the data, hit render, and repeat. What breaks that process is human attention: a missed field, an output file saved over another client’s delivery, a render kicked off at the wrong resolution. Remotion’s model removes most of that surface area. Every frame is a pure function of a frame number and a props object, which means the distance between “a template” and “a factory” is a Node.js script.
The challenge is not the first render. It is the tenth client on the third month, when the original developer is no longer available and someone else needs to produce thirty videos from a spreadsheet before Friday. The delivery workflow is what survives that transition.
This article walks through a production-grade system: Zod-validated props that serve as the contract between data and template, a webpack bundle that is created once and reused across every render in a batch, a queue runner with controlled concurrency, and an output layout that makes finished files self-identifying without a separate naming spreadsheet.
Start With a Zod Schema, Not a TypeScript Interface
The most common mistake in agency Remotion work is defining composition props as a TypeScript interface and wiring the data manually. A Zod schema does everything an interface does, and also gives you runtime validation of incoming job data, which is exactly what you need when the source is a CMS export or a converted spreadsheet.
// src/compositions/ClientHighlight/schema.ts
import { z } from "zod";
export const ClientHighlightSchema = z.object({
clientName: z.string().min(1),
tagline: z.string().max(80),
accentColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
logoPath: z.string(), // relative to public/
scenes: z
.array(
z.object({
heading: z.string(),
bodyText: z.string().max(200),
durationSeconds: z.number().min(2).max(10),
})
)
.min(1)
.max(8),
});
export type ClientHighlightProps = z.infer<typeof ClientHighlightSchema>;
Pass the schema directly to the <Composition> component. Remotion Studio uses it to render input fields in the UI panel; the delivery script uses it to validate before a single frame is rendered. Both consumers work from the same definition, so the schema is the binding contract between your template and your data pipeline.
// src/Root.tsx
import { Composition } from "remotion";
import { ClientHighlight } from "./compositions/ClientHighlight";
import { ClientHighlightSchema } from "./compositions/ClientHighlight/schema";
import { calculateClientHighlightMetadata } from "./compositions/ClientHighlight/metadata";
export const RemotionRoot: React.FC = () => (
<Composition
id="ClientHighlight"
component={ClientHighlight}
schema={ClientHighlightSchema}
calculateMetadata={calculateClientHighlightMetadata}
defaultProps={{
clientName: "Acme Corp",
tagline: "Move faster.",
accentColor: "#2563EB",
logoPath: "logos/acme.svg",
scenes: [
{ heading: "Q1 Highlights", bodyText: "Revenue grew 18% YoY.", durationSeconds: 4 },
],
}}
durationInFrames={120} // placeholder — calculateMetadata overrides this at render time
fps={30}
width={1920}
height={1080}
/>
);
Dynamic Duration via calculateMetadata
Hardcoding durationInFrames on a scene-based composition is a trap. A client with three scenes should not be padded to match a client with seven. calculateMetadata receives the fully resolved props and returns overrides for durationInFrames, fps, width, and height, giving each job the exact runtime its data requires.
// src/compositions/ClientHighlight/metadata.ts
import type { CalculateMetadataFunction } from "remotion";
import type { ClientHighlightProps } from "./schema";
export const calculateClientHighlightMetadata: CalculateMetadataFunction<
ClientHighlightProps
> = ({ props }) => {
const fps = 30;
const sceneFrames = props.scenes.reduce(
(acc, scene) => acc + Math.round(scene.durationSeconds * fps),
0
);
// Hold on the logo card for exactly one second before the video ends
return { durationInFrames: sceneFrames + fps, fps };
};
The key behavior to understand here: calculateMetadata runs on the server side during rendering, not inside the React component tree. That means it runs before any frame is rendered and its return value is authoritative. If you call selectComposition from @remotion/renderer before kicking off renderMedia, it runs calculateMetadata and gives you the resolved durationInFrames for that specific job. Always read the duration from there, not from your manifest.
import { selectComposition } from "@remotion/renderer";
const comp = await selectComposition({
serveUrl,
id: "ClientHighlight",
inputProps: validatedProps,
});
// comp.durationInFrames is the value returned by calculateMetadata
// for this specific set of props — use it for logging or progress math
console.log(`Rendering ${comp.durationInFrames} frames at ${comp.fps} fps`);
Bundle Once, Render Many
bundle() runs webpack over your Remotion project. For a batch of twelve client videos, you do not want twelve webpack runs. Bundle once at the start of the script, capture the serveUrl, and pass it to every renderMedia call.
// scripts/deliver.ts
import path from "node:path";
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { ClientHighlightSchema } from "../src/compositions/ClientHighlight/schema";
import type { ClientHighlightProps } from "../src/compositions/ClientHighlight/schema";
import rawJobs from "./jobs.json";
async function main() {
console.log("Bundling project…");
const serveUrl = await bundle({
entryPoint: path.resolve("./src/index.ts"),
onProgress: (p) => process.stdout.write(`\r webpack: ${p}%`),
});
console.log("\nBundle ready. Starting renders.\n");
const tasks: Array<() => Promise<void>> = [];
for (let i = 0; i < rawJobs.length; i++) {
const raw = rawJobs[i];
let props: ClientHighlightProps;
try {
props = ClientHighlightSchema.parse(raw);
} catch (err) {
console.error(`Job ${i} failed validation, skipping:`, err);
continue;
}
tasks.push(() => renderOne(serveUrl, props));
}
await runWithConcurrency(tasks, 2);
console.log("\nAll jobs complete.");
}
main().catch(console.error);
The serveUrl returned by bundle() points to a local HTTP server backed by a temporary webpack output directory. It remains valid for the lifetime of the process, which is exactly the scope needed here.
Controlled Concurrency Without Third-Party Packages
Rendering everything sequentially is safe but slow. Rendering everything in parallel saturates the CPU and causes out-of-memory crashes on long videos. The right answer is a bounded concurrency pool.
@remotion/renderer intentionally does not include one; it renders one video per renderMedia call. The implementation below requires no additional packages:
async function runWithConcurrency<T>(
tasks: Array<() => Promise<T>>,
limit: number
): Promise<T[]> {
const results: T[] = new Array(tasks.length);
let nextIndex = 0;
async function worker() {
while (nextIndex < tasks.length) {
const i = nextIndex++;
results[i] = await tasks[i]();
}
}
await Promise.all(Array.from({ length: limit }, worker));
return results;
}
A concurrency of 2 with the concurrency option on each renderMedia call set to 4 gives you 8 active threads, a reasonable fit for an 8-core machine. Going wider increases peak memory use in proportion to video length and frame size, not just CPU load.
async function renderOne(serveUrl: string, props: ClientHighlightProps) {
const comp = await selectComposition({
serveUrl,
id: "ClientHighlight",
inputProps: props,
});
const outputPath = buildOutputPath(props);
await renderMedia({
composition: comp,
serveUrl,
codec: "h264",
outputLocation: outputPath,
inputProps: props,
concurrency: 4,
timeoutInMilliseconds: 60_000 * 10,
onProgress: ({ progress }) => {
process.stdout.write(
`\r [${props.clientName}] ${Math.round(progress * 100)}%`
);
},
});
console.log(`\n Saved → ${outputPath}`);
}
Predictable Output Paths
A delivery folder full of files named out.mp4 and render_final_v2.mp4 costs real time when a client manager has to match them to briefing documents. The output path should encode enough context to be self-identifying.
function buildOutputPath(props: ClientHighlightProps): string {
// Result: dist/2026-08-24/acme-corp/ClientHighlight.mp4
const date = new Date().toISOString().slice(0, 10);
const clientSlug = props.clientName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
return path.join("dist", date, clientSlug, "ClientHighlight.mp4");
}
The date prefix makes past batches easy to locate. The per-client directory lets you zip each subdirectory independently for delivery, and including the composition name in the filename matters the moment a client receives two video types in one batch. renderMedia creates intermediate directories automatically, so no mkdir -p is needed before the call.
The Job Manifest Format
The manifest is a JSON array that maps directly to the Zod schema. Keep it flat and machine-writable; a small script that converts a CSV export from a spreadsheet to this format is worth writing once and reusing every month.
[
{
"clientName": "Acme Corp",
"tagline": "Move faster.",
"accentColor": "#2563EB",
"logoPath": "logos/acme.svg",
"scenes": [
{ "heading": "Q1 Highlights", "bodyText": "Revenue grew 18% YoY.", "durationSeconds": 4 },
{ "heading": "Team Growth", "bodyText": "Hired 23 engineers.", "durationSeconds": 3.5 }
]
},
{
"clientName": "Beacon Health",
"tagline": "Care, delivered.",
"accentColor": "#059669",
"logoPath": "logos/beacon.svg",
"scenes": [
{ "heading": "Patient Outcomes", "bodyText": "93% satisfaction.", "durationSeconds": 5 },
{ "heading": "New Clinics", "bodyText": "12 locations opened.", "durationSeconds": 4 },
{ "heading": "Research", "bodyText": "3 trials underway.", "durationSeconds": 3 }
]
}
]
When ClientHighlightSchema.parse() throws, the Zod error message names the exact field and the rule that failed, such as clientName: Required or accentColor: Invalid string, which is far more useful than a generic crash at render time. The per-job try/catch in the main loop lets a batch with one bad record continue to completion rather than stopping entirely.
Locking the Render Environment
Two things change visual output unexpectedly between runs: Node.js version and Chromium version. Remotion downloads its own Chromium build by default, so the browser version is effectively pinned as long as the remotion package version is pinned. Node.js is not.
Add an engines field to package.json and specify the exact version range rather than lts/*:
{
"engines": {
"node": ">=20.0.0 <22.0.0"
}
}
In CI, pin the Node version explicitly in your workflow definition. A remotion version bump should go through a dedicated PR rather than landing alongside data or template changes, so that any visual regression is attributable to the environment change and not obscured by content changes.
For font rendering, ship font files in your public/ directory and reference them with a @font-face declaration loaded inside the composition. This means no network calls at render time, no CDN dependency, and identical font output whether the render runs on a developer’s laptop or a CI runner with no outbound internet access.
Wrapping Up
The workflow here holds up because each layer has a single, well-defined responsibility. The Zod schema is the contract: bad data fails loudly before rendering starts, and the error message names the exact field that failed. Because the bundle is created once, each new client in the manifest costs one renderMedia call. The output path encodes the batch date and client identity, making finished files self-identifying without a separate naming document.
As compositions grow more complex, the same structure extends without changes to the runner. calculateMetadata can make async calls to a CMS or database to resolve asset URLs before rendering begins, and inputProps can carry deeply nested objects as long as they pass schema validation. To scale the batch, adjust one number in the concurrency wrapper.
Templates designed with this manifest-driven model in mind — like the ones in the RenderComp catalog — make it straightforward to drop a new jobs.json and run the same delivery script month after month. The initial setup cost is paid once; the ongoing cost is updating a JSON file.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →