Automated Video Generation with Remotion: Using the Renderer API
Automated Video Generation with Remotion: Using the Renderer API
Every digital platform eventually hits the same wall. You need more video — more variants, more personalisation, more frequency — than any human team can produce. Product pages need demo clips. Marketing pipelines need a unique cut for each audience segment. Internal tools need weekly summary videos generated overnight and ready in inboxes by morning.
The traditional answer is to hire more editors or buy into a proprietary video-generation platform and accept its limitations. The better answer, if you are comfortable with Node.js, is to build your own pipeline with Remotion’s server-side renderer. You write video logic once in React, then drive it programmatically from any trigger: a REST endpoint, a webhook, a cron job, or a queue worker. The result is a fully automated video generation system that you own completely.
This guide covers the complete picture, from understanding the renderMedia function to handling output and managing cost at scale.
The Opportunity: Video at Programmatic Scale
Programmatic video is not a niche technique. It is the production model behind the videos on e-commerce sites that show each product in motion, the personalised year-in-review clips that streaming platforms send to every subscriber, and the real-time sports highlight reels that news outlets publish minutes after a match ends.
What makes programmatic video compelling is the relationship between effort and output. Writing a React component that renders a product showcase takes a few hours. Once that component exists, generating it for ten thousand products takes the same server time as generating it for ten — you just pass different inputProps to each render job. The marginal cost of additional output approaches zero once the template is built.
Remotion is the framework that makes this practical for web developers. Rather than learning a proprietary scripting language or fighting with video editing software APIs, you write standard React and TypeScript. The renderer compiles your components to video frames using headless Chromium, then encodes those frames into an MP4, WebM, or any other format you choose.
Remotion’s renderMedia Function — Server-Side Rendering
The core of any automated Remotion pipeline is the renderMedia function exported from the @remotion/renderer package. This is a Node.js function that accepts a pre-bundled Remotion project, a composition ID, encoding options, and your dynamic data, then produces a video file.
Installation
npm install @remotion/renderer
# or
pnpm add @remotion/renderer
Import
import { renderMedia, selectComposition } from "@remotion/renderer";
import { bundle } from "@remotion/bundler";
Core function signature
await renderMedia({
composition, // VideoConfig — from selectComposition()
serveUrl, // string — path to bundle or hosted URL
codec, // "h264" | "h265" | "vp8" | "vp9" | "mp3" | "wav" | "aac" | "gif" | "prores"
outputLocation, // string — absolute or relative path to output file
inputProps, // Record<string, unknown> — dynamic data for the composition
onProgress, // (progress: RenderMediaOnProgress) => void — optional callback
chromiumOptions, // object — optional headless browser config
timeoutInMilliseconds, // number — optional, default 30000
concurrency, // number | string — number of parallel render threads
});
The codec and outputLocation fields are the minimum you need beyond the composition and serveUrl. Everything else is optional but worth understanding before you go to production.
Codec guidance
"h264"— the right default for most use cases. Broad compatibility, reasonable file size."h265"— better compression than h264, but limited browser support without a dedicated player."vp8"/"vp9"— open codecs that work well for WebM output."gif"— no audio, limited colours, but universally supported for looping clips."prores"— high-quality intermediate format for further post-production.
The onProgress callback
onProgress receives a RenderMediaOnProgress object that includes renderedFrames, encodedFrames, encodedDoneIn, renderedDoneIn, and stitchStage. This is the hook you use to update a database record, push a WebSocket event, or write to a log.
Setting Up a Node.js Render Server
A production render server needs three things: a bundled copy of your Remotion project, a function that accepts job parameters and calls renderMedia, and a mechanism to run that function in response to external events.
Step 1: Bundle the project once at startup
Bundling compiles your React components and their dependencies into a static directory that the renderer can access. You typically do this once when the server starts, then reuse the bundle path for every render job.
import { bundle } from "@remotion/bundler";
import path from "path";
let bundlePath: string;
async function initBundle(): Promise<void> {
bundlePath = await bundle({
entryPoint: path.resolve("./src/index.ts"),
// webpackOverride is optional — use it only if you need custom loaders
});
console.log("Bundle ready at", bundlePath);
}
Step 2: Select the composition
Before calling renderMedia, you need a VideoConfig object that describes the composition — its dimensions, frame rate, and duration. The selectComposition function retrieves this from the bundle.
import { selectComposition } from "@remotion/renderer";
async function getComposition(compositionId: string, inputProps: Record<string, unknown>) {
return selectComposition({
serveUrl: bundlePath,
id: compositionId,
inputProps,
});
}
Passing inputProps to selectComposition matters when your composition’s durationInFrames or other metadata is computed from the input data.
Step 3: Render to file
import { renderMedia } from "@remotion/renderer";
async function renderVideo(
compositionId: string,
inputProps: Record<string, unknown>,
outputPath: string
): Promise<void> {
const composition = await getComposition(compositionId, inputProps);
await renderMedia({
composition,
serveUrl: bundlePath,
codec: "h264",
outputLocation: outputPath,
inputProps,
onProgress: ({ renderedFrames, encodedFrames }) => {
const total = composition.durationInFrames;
console.log(`Rendered ${renderedFrames}/${total} — Encoded ${encodedFrames}/${total}`);
},
});
}
Triggering Renders from a REST API Endpoint
Wrapping the render function in an HTTP server lets any other service in your stack kick off a video generation job without knowing anything about Remotion. The client sends a payload, the server queues the render, and either returns a job ID for polling or calls back a webhook when done.
import express from "express";
import { v4 as uuidv4 } from "uuid";
import path from "path";
import { renderMedia, selectComposition } from "@remotion/renderer";
const app = express();
app.use(express.json());
// In-memory job store — replace with Redis or a database in production
const jobs = new Map<string, { status: string; progress: number; outputPath?: string }>();
app.post("/render", async (req, res) => {
const { compositionId, props } = req.body;
if (!compositionId || !props) {
return res.status(400).json({ error: "compositionId and props are required" });
}
const jobId = uuidv4();
const outputPath = path.resolve(`./output/${jobId}.mp4`);
jobs.set(jobId, { status: "queued", progress: 0 });
res.json({ jobId });
// Run render asynchronously — do not await in the request handler
(async () => {
try {
jobs.set(jobId, { status: "rendering", progress: 0 });
const composition = await selectComposition({
serveUrl: bundlePath,
id: compositionId,
inputProps: props,
});
await renderMedia({
composition,
serveUrl: bundlePath,
codec: "h264",
outputLocation: outputPath,
inputProps: props,
onProgress: ({ renderedFrames }) => {
const pct = Math.round((renderedFrames / composition.durationInFrames) * 100);
jobs.set(jobId, { status: "rendering", progress: pct });
},
});
jobs.set(jobId, { status: "done", progress: 100, outputPath });
} catch (err) {
jobs.set(jobId, { status: "error", progress: 0 });
console.error("Render failed:", err);
}
})();
});
app.get("/render/:jobId", (req, res) => {
const job = jobs.get(req.params.jobId);
if (!job) return res.status(404).json({ error: "Job not found" });
res.json(job);
});
app.listen(3001, () => console.log("Render server on :3001"));
This pattern is intentionally simple. The render runs in the background, the client polls /render/:jobId until status is "done", then downloads the file. For higher throughput, replace the in-memory map with a proper job queue (BullMQ, pg-boss, or similar).
Passing Dynamic Data via inputProps
inputProps is the bridge between your external data and the visual content of the video. Any JSON-serialisable value you pass in inputProps becomes available inside your Remotion composition as standard React props.
In the render call:
await renderMedia({
composition,
serveUrl: bundlePath,
codec: "h264",
outputLocation: "./output/product-42.mp4",
inputProps: {
productName: "Wireless Headphones Pro",
price: "¥29,800",
imageUrl: "https://cdn.example.com/products/42.jpg",
ctaText: "Shop Now",
brandColor: "#1a73e8",
},
});
In the Remotion composition:
import { useCurrentFrame, useVideoConfig } from "remotion";
interface ProductProps {
productName: string;
price: string;
imageUrl: string;
ctaText: string;
brandColor: string;
}
export const ProductShowcase: React.FC<ProductProps> = ({
productName,
price,
imageUrl,
ctaText,
brandColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const opacity = Math.min(1, frame / (fps * 0.5)); // fade in over 0.5 s
return (
<div style={{ background: brandColor, opacity, width: "100%", height: "100%" }}>
<img src={imageUrl} alt={productName} />
<h1>{productName}</h1>
<p>{price}</p>
<button>{ctaText}</button>
</div>
);
};
Register the composition in your root file with default props that match the shape of your inputProps:
<Composition
id="ProductShowcase"
component={ProductShowcase}
durationInFrames={150}
fps={30}
width={1080}
height={1080}
defaultProps={{
productName: "Default Product",
price: "¥0",
imageUrl: "",
ctaText: "Learn More",
brandColor: "#000000",
}}
/>
The defaultProps are used when previewing in the Remotion Studio and as the fallback when a prop is not provided at render time. Always keep the shape consistent between defaultProps and your inputProps at the call site.
Progress Tracking and Render Status
The onProgress callback gives you granular visibility into a running render. The RenderMediaOnProgress object includes:
| Field | Type | Description |
|---|---|---|
renderedFrames | number | Frames fully rendered by headless Chrome |
encodedFrames | number | Frames encoded into the output file |
encodedDoneIn | number | null | Milliseconds encoding took, once complete |
renderedDoneIn | number | null | Milliseconds rendering took, once complete |
stitchStage | string | Current stage: "encoding" or "muxing" |
progress | number | Overall 0–1 progress value |
A typical pattern for a production system is to write these values to a Redis key or a database column that a front-end or another service can read on demand:
onProgress: ({ progress, renderedFrames, encodedFrames }) => {
await redis.set(
`render:${jobId}`,
JSON.stringify({
progress: Math.round(progress * 100),
renderedFrames,
encodedFrames,
updatedAt: Date.now(),
}),
"EX",
3600 // expire after 1 hour
);
},
For user-facing dashboards, a Server-Sent Events (SSE) or WebSocket stream fed from this Redis key gives smooth real-time progress bars without polling overhead.
Output Handling: File System, S3, and Cloud Storage
By default renderMedia writes to a local file path specified in outputLocation. In a containerised or serverless environment you typically want the file somewhere persistent immediately after rendering.
Upload to S3 after render
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import fs from "fs";
const s3 = new S3Client({ region: "ap-northeast-1" });
async function uploadToS3(localPath: string, s3Key: string): Promise<string> {
const body = fs.createReadStream(localPath);
await s3.send(
new PutObjectCommand({
Bucket: process.env.OUTPUT_BUCKET!,
Key: s3Key,
Body: body,
ContentType: "video/mp4",
})
);
fs.unlinkSync(localPath); // clean up local file
return `https://${process.env.OUTPUT_BUCKET}.s3.amazonaws.com/${s3Key}`;
}
// After renderMedia completes:
const videoUrl = await uploadToS3(outputPath, `renders/${jobId}.mp4`);
Streaming directly to cloud storage
For very large files, writing to disk first and then uploading doubles the I/O. A more efficient approach is to use renderFrames and stitchFramesToVideo separately, piping the output stream directly to an S3 multipart upload. This is more complex to set up but eliminates the disk intermediate.
For most pipelines, the write-then-upload pattern is simpler and the latency difference is negligible unless you are generating videos longer than a few minutes.
Real-World Use Cases
Personalised marketing videos An e-commerce platform stores customer name, recently viewed products, and loyalty status in a CRM. A nightly job pulls this data, calls the render API once per active customer, and sends the resulting video in a morning email. Each customer receives a clip that mentions them by name and features the exact products they browsed.
Product catalogue automation
An online retailer has forty thousand SKUs. Rather than producing static images, they generate a ten-second animated showcase per product automatically when a new item is added to the database. The inputProps carry the product photo URL, title, price, and brand colours. The render server processes the queue overnight.
News and live data visualisation
A financial data company runs a render job every 15 minutes that pulls the latest market figures, passes them to a data-visualisation composition as inputProps, and publishes the resulting video to social channels. No human editor is involved — the entire pipeline from data fetch to published video runs on a cron schedule.
Internal reporting A SaaS company generates a weekly metrics summary video for each client account. The video shows key KPIs from the previous week, animated as charts and callouts. The render is triggered every Monday at 6 AM by a cron job; the output link is emailed automatically before the client’s team starts their week.
Cost and Performance Considerations
CPU concurrency
The concurrency parameter in renderMedia controls how many headless browser tabs run in parallel. The default is half your available CPU cores. Increasing it speeds up individual renders but increases memory pressure. A safe starting point on a server with 4 cores is concurrency: 2. Test your specific composition before increasing further.
Memory usage Each concurrent browser tab uses roughly 200–400 MB depending on composition complexity. A server running 4 concurrent tabs needs at least 2 GB of available RAM. Under-allocating memory is the most common source of cryptic render failures.
Render time benchmarks (approximate)
| Duration | Resolution | Concurrency | Typical render time |
|---|---|---|---|
| 15 sec | 1080×1080 | 2 | 30–60 sec |
| 30 sec | 1920×1080 | 4 | 45–90 sec |
| 60 sec | 1920×1080 | 8 | 60–120 sec |
These figures vary significantly based on composition complexity, number of remote assets loaded, and server hardware.
Cold starts
bundle() takes 10–30 seconds depending on project size. Call it once at server startup, not inside the render handler. Similarly, if you reuse a running server across many renders, keep the bundle path in module-level scope.
Cost estimates A t3.medium EC2 instance (2 vCPUs, 4 GB RAM) costs approximately $0.03–$0.04/hour on demand. At 2 concurrent renders with a 60-second average completion time, you can process roughly 120 renders/hour. That works out to less than $0.001 per render for the compute alone. At scale, consider spot instances to reduce cost by 60–80%.
Frequently Asked Questions
Q: Can I run renderMedia inside a serverless function like AWS Lambda?
Yes, but standard Lambda has constraints. AWS Lambda functions are limited to 15 minutes of execution time and 10 GB of ephemeral storage. Remotion provides a dedicated @remotion/lambda package that handles chunked rendering and concurrency across multiple Lambda invocations — this is the recommended path for serverless rendering at scale. For short compositions on standard Lambda, the @remotion/renderer package works if you configure the execution timeout and ephemeral storage appropriately.
Q: What Node.js version does @remotion/renderer require? Remotion requires Node.js 16 or later. Node 18 LTS and Node 20 LTS are both well-tested. Avoid using odd-numbered (non-LTS) releases in production.
Q: How do I render multiple videos in parallel without running out of memory?
Use a job queue (BullMQ, p-queue, or similar) to control concurrency at the job level. Set a maximum of 2–4 simultaneous render jobs on a typical server, regardless of the server’s core count. Each render job already uses internal concurrency via the concurrency parameter; stacking too many jobs on top of each other multiplies memory usage rapidly.
Q: Can inputProps contain image URLs, and will Remotion load them during render?
Yes. Remotion’s headless Chrome instance fetches remote URLs during rendering. However, assets that are slow to load can increase render time and risk timeout. For production pipelines, pre-fetch all remote assets to local paths or a fast CDN before passing their URLs as inputProps. You can also use staticFile() for assets bundled with your project.
Q: Is it possible to watch a render’s progress from the browser in real time?
Yes. Feed the onProgress callback data into a Redis key or an in-memory store, then expose it via a Server-Sent Events endpoint. The browser subscribes to the SSE stream and receives incremental progress updates without polling. Alternatively, WebSockets work equally well if your stack already uses them.
Q: How do I handle failed renders without losing the job?
Wrap renderMedia in a try/catch and store the error in your job record with the full stack trace. Implement a retry counter — most transient failures (network timeouts fetching assets, occasional Chromium crashes) resolve on the second attempt. Set a maximum retry count of 3 and move permanently failed jobs to a dead-letter queue for investigation.
Q: Can I render the same composition at different aspect ratios?
Not directly via inputProps alone — the dimensions are set at the composition level. However, you can define multiple compositions in your root file (e.g., ProductShowcase-Square, ProductShowcase-Landscape) that share the same underlying component but differ in their width, height, and durationInFrames. Pass the appropriate compositionId in your render request.
Start Automating with RenderComp Templates
Building a render server from scratch takes time, but your compositions are the real intellectual property. RenderComp’s animation template library gives you production-ready Remotion compositions — lower thirds, kinetic text, data visualisations, product showcases, and more — that are designed from the ground up to accept inputProps.
Every template in the RenderComp library ships with TypeScript prop interfaces, documented defaultProps, and example render scripts. Drop them into your pipeline, wire up your data source, and you are generating automated videos in an afternoon rather than weeks.
Browse templates at rendercomp.com and find the composition that fits your use case.
Ready to ship
Get 1,400+ Remotion Templates
Lifetime license. TypeScript-first. Ship polished video in minutes, not days.
Get RenderComp →