R RenderComp
remotion personalized-video automation programmatic-video marketing

How to Generate Personalized Videos at Scale with Remotion

How to Generate Personalized Videos at Scale with Remotion

Personalized video at scale means producing one unique video per data row — each with the recipient’s name, their numbers, their images — without editing a single timeline by hand. The pattern that makes this practical is straightforward: write one Remotion composition that reads its content from props, then feed it a dataset and render every row in parallel. One template, thousands of outputs.

Because a Remotion composition is a React component, “personalization” is just passing different props. A CSV row, a database record, or a CRM API response becomes a props object; that props object becomes a video. The three parts that make this reliable at volume are inputProps (per-recipient data injected at render time), calculateMetadata (fetching data and setting each video’s duration and dimensions before render), and @remotion/lambda (running each render as an independent, horizontally scalable job).

This guide walks through the full architecture — from data source to delivered files — with the specific Remotion APIs that carry each stage. The goal is a pipeline you can point at a spreadsheet and trust to produce correct, on-brand video for every entry.


What “Personalized Video at Scale” Means

A personalized video is a 1:1 artifact: one video that belongs to exactly one recipient. At scale, you generate these from structured data rather than manual editing. A few common shapes:

  • Sales and onboarding — “Welcome, Priya” with the account’s plan name and start date.
  • Year-in-review / wrapped — each user’s own totals, top items, and milestones.
  • Event and webinar — a personal reminder card with the attendee’s session time.
  • E-commerce and post-purchase — the product the customer bought, rendered into a thank-you clip.

The defining property is that the data drives the content, and often the structure too. Some recipients have three line items and some have seven; some names are short and some wrap to two lines. A hand-built template with fixed slots struggles here. A code-driven template does not, because layout, timing, and even the video’s length can be computed from the data at render time.

The mental model is the same one that underpins all programmatic video in Remotion: every frame is a function of the frame number, and every composition is a function of its props.

data row → props → React composition → frames → MP4

The Architecture, End to End

Here is the pipeline as a numbered sequence. Each stage maps to a concrete Remotion concept.

  1. Data source. Read your rows from wherever they live — a CSV export, a SQL query, or a CRM/API response. Each row is the input for one video.
  2. Row → props. Transform each row into a typed props object matching your composition’s schema ({ name, plan, total, avatarUrl, ... }).
  3. Bundle once. Build (or deploy) your Remotion project a single time. The same bundle serves every render, so this cost is paid once per pipeline run, not once per video.
  4. Resolve per-video metadata. For each row, calculateMetadata can fetch any extra data and compute that video’s durationInFrames, width, and height before the frames are drawn.
  5. Fan out the renders. Dispatch one render job per row. Because renders are independent, they scale horizontally — locally you loop; on Lambda you run them concurrently.
  6. Collect and deliver. Each job writes an MP4 (and optionally a still thumbnail) to storage. A delivery step emails, embeds, or hands off the URLs.

The rest of this guide details the stages that are Remotion-specific: passing props, calculateMetadata, parallel rendering, and the practical concerns around assets, thumbnails, and delivery.


Passing Per-Recipient Data with inputProps and defaultProps

A composition declares the shape of the data it expects and provides sensible fallbacks with defaultProps. Keep the component pure: it reads props and renders — it never reaches out for its own data.

import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";

export type WelcomeProps = {
  name: string;
  plan: string;
  avatarUrl: string;
};

export const Welcome: React.FC<WelcomeProps> = ({ name, plan, avatarUrl }) => {
  const frame = useCurrentFrame();

  // Animate over frames — never with CSS transitions/animations,
  // which do not render in Remotion. Drive motion from useCurrentFrame().
  const opacity = interpolate(frame, [0, 20], [0, 1], {
    extrapolateRight: "clamp",
    easing: Easing.bezier(0.16, 1, 0.3, 1),
  });
  const translateY = interpolate(frame, [0, 20], [24, 0], {
    extrapolateRight: "clamp",
  });

  return (
    <AbsoluteFill
      style={{
        backgroundColor: "#0B84FF",
        alignItems: "center",
        justifyContent: "center",
        color: "#fff",
        fontFamily: "-apple-system, Segoe UI, Roboto, sans-serif",
      }}
    >
      <div style={{ opacity, translate: `0px ${translateY}px`, textAlign: "center" }}>
        <div style={{ fontSize: 84, fontWeight: 700 }}>Welcome, {name}</div>
        <div style={{ fontSize: 40, opacity: 0.85 }}>You're on the {plan} plan</div>
      </div>
    </AbsoluteFill>
  );
};

Register the composition with defaultProps so the Studio and any render have a valid starting point:

import { Composition } from "remotion";
import { Welcome, WelcomeProps } from "./Welcome";

export const RemotionRoot: React.FC = () => (
  <Composition
    id="Welcome"
    component={Welcome}
    durationInFrames={120}
    fps={30}
    width={1080}
    height={1080}
    defaultProps={{
      name: "there",
      plan: "Starter",
      avatarUrl: "https://example.com/placeholder.png",
    } satisfies WelcomeProps}
  />
);

At render time you override defaultProps with the row’s data. From the CLI you pass --props; programmatically you pass inputProps to the render call. Looping over a dataset and rendering one video per row is the entire batch:

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

const recipients = await loadRecipientsFromCsv("./recipients.csv");

// Bundle once, reuse for every render.
const serveUrl = await bundle({ entryPoint: "./src/index.ts" });

for (const person of recipients) {
  const composition = await selectComposition({
    serveUrl,
    id: "Welcome",
    inputProps: person, // { name, plan, avatarUrl }
  });

  await renderMedia({
    composition,
    serveUrl,
    codec: "h264",
    inputProps: person,
    outputLocation: `out/${person.id}.mp4`,
  });
}

The inputProps you pass to selectComposition and renderMedia are deep-merged over defaultProps, so each video receives exactly its recipient’s fields. Nothing about the component changes between rows — only the data.


Dynamic Duration and Dimensions with calculateMetadata

Personalized data is uneven. One user’s recap has 3 highlights; another’s has 12. A fixed durationInFrames would either rush the long ones or pad the short ones. calculateMetadata solves this by computing metadata per render, before any frame is drawn.

calculateMetadata is an async function on the <Composition>. It receives the resolved props (and an abortSignal) and returns any of durationInFrames, width, height, fps, transformed props, or defaultOutName. Returned values override the composition’s static props. Per the Remotion docs (docs.remotion.dev/docs/dynamic-metadata), it runs both in the Studio and at render time, so preview and output stay consistent.

import { Composition, CalculateMetadataFunction } from "remotion";
import { Recap, RecapProps } from "./Recap";

const FPS = 30;
const SECONDS_PER_HIGHLIGHT = 3;

const calculateMetadata: CalculateMetadataFunction<RecapProps> = async ({
  props,
  abortSignal,
}) => {
  // Fetch the recipient's data by id — the CSV only carried the key.
  const res = await fetch(`https://api.example.com/recap/${props.userId}`, {
    signal: abortSignal, // cancels stale requests when props change in Studio
  });
  const data = await res.json();

  const seconds = 2 + data.highlights.length * SECONDS_PER_HIGHLIGHT;

  return {
    durationInFrames: Math.ceil(seconds * FPS),
    // Vertical for a Stories-style recipient, square otherwise.
    width: data.vertical ? 1080 : 1080,
    height: data.vertical ? 1920 : 1080,
    props: { ...props, highlights: data.highlights },
    defaultOutName: `recap-${props.userId}`,
  };
};

export const RemotionRoot: React.FC = () => (
  <Composition
    id="Recap"
    component={Recap}
    fps={FPS}
    durationInFrames={120}
    width={1080}
    height={1080}
    defaultProps={{ userId: "demo", highlights: [] } satisfies RecapProps}
    calculateMetadata={calculateMetadata}
  />
);

Two things make this powerful for personalization. First, the CSV or job payload can carry just an ID; calculateMetadata fetches the full record. That keeps the batch payload small and the data authoritative. Second, each video’s length and aspect ratio become a function of its own data — a genuinely per-recipient timeline rather than a one-size template.


Parallel Rendering at Volume with @remotion/lambda

The property that makes “at scale” real is that each render is independent. No render depends on another’s output, so the work is embarrassingly parallel. Locally that means a loop (or a worker pool); for thousands of videos, @remotion/lambda distributes the work across AWS Lambda and scales horizontally.

Setup is a one-time investment: deploy the render function and upload your bundle as a Lambda “site.” After that, dispatching a render is a single function call per recipient. The key function is renderMediaOnLambda, and you track progress with getRenderProgress.

import { renderMediaOnLambda, getRenderProgress } from "@remotion/lambda/client";

const common = {
  region: "us-east-1" as const,
  functionName: "remotion-render-xxxxx",
  serveUrl: "https://remotionlambda-xxxxx.s3.amazonaws.com/sites/welcome/index.html",
  composition: "Welcome",
  codec: "h264" as const,
};

async function renderOne(person: WelcomeProps & { id: string }) {
  const { renderId, bucketName } = await renderMediaOnLambda({
    ...common,
    inputProps: person, // per-recipient data
  });

  // Poll until the file is ready.
  while (true) {
    const progress = await getRenderProgress({
      renderId,
      bucketName,
      functionName: common.functionName,
      region: common.region,
    });
    if (progress.done) return progress.outputFile; // URL to the MP4
    if (progress.fatalErrorEncountered) throw new Error(progress.errors[0]?.message);
    await new Promise((r) => setTimeout(r, 1000));
  }
}

// Fan out — dispatch many at once, bounded by your concurrency limit.
const urls = await Promise.all(recipients.map(renderOne));

Each renderMediaOnLambda call is its own render, so you can launch many concurrently — the practical ceiling is your AWS Lambda concurrency limit, which is configurable. Cost tracks compute time per render and stays roughly constant per video regardless of batch size. For teams on Google Cloud, @remotion/cloudrun provides the same distributed model. Either way, the composition and props are identical to what you preview locally in the Studio — Lambda changes where the frames are rendered, not how.


Practical Concerns: Assets, Thumbnails, and Delivery

Asset caching. Personalized videos usually pull remote images — avatars, product shots, logos. Fetching the same brand asset for every render is wasteful. Bundle shared, static assets into the project’s public/ folder and reference them with staticFile() so they ship with the site and are not re-downloaded per render. Reserve remote URLs for the genuinely per-recipient images. Where possible, serve those from a CDN with long cache lifetimes so repeated renders hit warm caches.

Personalized thumbnails. A personalized video deserves a personalized poster frame — the still that shows in an email or a player before playback. Render one still per recipient with renderStillOnLambda (or renderStill locally), pointing at the same composition and inputProps with a chosen frame. That gives you a matching name-on-screen thumbnail for the same data, without re-encoding the whole clip.

import { renderStillOnLambda } from "@remotion/lambda/client";

const still = await renderStillOnLambda({
  ...common,
  imageFormat: "jpeg",
  frame: 30, // 1s in at 30fps; a moment where the name is on screen
  inputProps: person,
});
// still.url → personalized thumbnail

Delivery. Renders land in object storage (S3 for Lambda) as URLs. From there, delivery is your application’s concern: store the URL against the recipient, embed it with @remotion/player for in-app playback, or send it by email. Because every job is keyed by a stable recipient ID, use that ID in defaultOutName (or the output key) so files are predictable and idempotent — a re-run overwrites cleanly instead of duplicating.

Idempotency and retries. Treat each render as a retryable unit. If one row fails (a bad avatar URL, a transient fetch error), retry just that row — the independence that makes rendering parallel also makes recovery cheap.


Summary Table

StageRemotion mechanismWhat it does
Per-recipient data ininputProps / --props over defaultPropsInjects one row’s fields into the composition
Composition contractTyped props on <Composition>Declares the data shape and fallbacks
Fetch + dynamic timingcalculateMetadataFetches full records; sets duration, dimensions, props per video
AnimationuseCurrentFrame() + interpolate()Frame-driven motion (CSS transitions do not render)
Parallel renderingrenderMediaOnLambda / @remotion/cloudrunRuns each render independently and horizontally scales
ThumbnailsrenderStillOnLambda / renderStillPersonalized poster frame per recipient
Shared assetsstaticFile() + public/Ships common assets with the bundle; avoids re-fetch

FAQ

Q: How does Remotion know which data goes into which video? You pass it explicitly. Each render call receives an inputProps object (the CLI flag is --props) that is merged over the composition’s defaultProps. One row of your dataset becomes one inputProps object, which becomes one video. Nothing is inferred — the mapping is data you control.

Q: What is the difference between defaultProps and inputProps? defaultProps are the fallback values declared on the <Composition>; they make the Studio and any render valid without extra input. inputProps are the per-render overrides you supply at render time. Remotion merges inputProps over defaultProps, so you only pass the fields that change per recipient.

Q: Can each video have a different length or aspect ratio? Yes. calculateMetadata returns durationInFrames, width, and height per render, computed from that video’s props. A recap with more highlights runs longer; a Stories-format recipient renders vertical. Returned values override the composition’s static settings.

Q: How many videos can I render in parallel? Each render is independent, so parallelism is bounded by your infrastructure rather than Remotion. On @remotion/lambda the ceiling is your AWS Lambda concurrency limit, which is configurable. Locally, it is your machine’s cores or worker pool size.

Q: Why can’t I use CSS transitions or keyframe animations? Remotion renders frame by frame and captures each frame independently, so time-based CSS transitions and animations do not render as motion. Drive every animation from useCurrentFrame() and map it with interpolate() (or spring) so the value is deterministic for each frame.

Q: Where does the personalized data come from — can it be a live API? Both. You can build the inputProps from a CSV or database at dispatch time, and you can fetch additional data inside calculateMetadata using the recipient’s ID. The abortSignal argument lets Remotion cancel stale fetches when props change in the Studio.

Q: Do I need a Remotion license for this? Remotion is free for personal and open-source use; commercial use requires a company license. Pricing is set per company — check the current terms at remotion.dev/docs/license before you build a production pipeline.


Build Faster with RenderComp

Personalization at scale is a solved architecture in Remotion — the work that remains is designing compositions that read cleanly from props. That is exactly where a template library saves time.

RenderComp offers production-ready Remotion templates — welcome cards, data recaps, lower thirds, social openers, and more — each shipping as TypeScript source with typed prop interfaces designed to accept inputProps from your render pipeline. You start from a composition that already reads its content from data, adapt it to your brand, and point it at your dataset.

Browse the collection at rendercomp.com and start rendering personalized video from day one.

Now available

Get 1,000+ Remotion Templates

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

View pricing →