You have 500 property listings and someone wants a walkthrough video for each one. You're not opening an editor 500 times.
The shape of the answer is obvious: one template, 500 rows of data, one render per row. The part that's actually interesting is how a template binds a variable to a layer, because the first thing everyone reaches for ({{ price }} string substitution) falls apart the moment one of your variables is a 40 MB mp4.
Node.js below, with the raw HTTP shown so it ports anywhere.
What actually comes out
Mechanics in a second. First, a real render, because "template-based video" is a phrase that could mean anything. This is the real-estate-penthouse template from the catalog: 31 seconds, six scenes, 1920x1080. Opening card and closing card:
Every string you can read in either frame came from a three-key payload:
{
"listing_address": "1200 Highline Avenue",
"city_location": "Belltown, Seattle",
"price": "$4,250,000"
}
That is the entire dynamic surface of a 62-overlay template. The typography, the amber accent, the Ken Burns motion, the beds/baths/sq-ft panel, the closing CTA: all locked. Point those three keys at a row of an MLS export and you get one video per listing.
Full 31-second render on the template page.
The mechanic
Any overlay carrying isDynamic: true becomes a named variable. The overlay's name is the key. At render time the engine deep-clones the template, walks the overlays, and for each dynamic overlay whose name appears in your replacements object, swaps a type-appropriate field:
-
textandshapelayers → thecontentfield is replaced -
image,video, andsoundlayers → thesrcfield is replaced
That per-type rule is the whole trick, and it's the thing token substitution can't express. Here's a trimmed slice of a real template:
{
"durationInFrames": 1620,
"fps": 30,
"width": 1920,
"height": 1080,
"replacements": {
"headline": "1428 Maple Grove\nLane",
"price": "$1,250,000",
"photo_exterior": "https://cdn.example.com/1428-exterior.jpg",
"clip_tour": "https://cdn.example.com/1428-tour.mp4"
},
"overlays": [
{ "type": "text", "name": "headline", "content": "1428 Maple Grove\nLane", "isDynamic": true },
{ "type": "text", "name": "price", "content": "$1,250,000", "isDynamic": true },
{ "type": "image", "name": "photo_exterior", "src": "https://...jpg", "isDynamic": true },
{ "type": "video", "name": "clip_tour", "src": "https://...mp4", "isDynamic": true },
{ "type": "text", "content": "Just Listed", "isDynamic": false }
]
}
Two things to notice.
The replacements block at the top holds defaults. Your render request sends its own replacements, which merge over these, request wins. Anything you omit keeps the default, so a partial payload still renders instead of failing. After the merge, the replacements block is stripped from the props handed to the renderer.
The last overlay has isDynamic: false. "Just Listed" is now permanent brand framing: it renders identically on all 500 videos while everything above it changes. Locked layers are a feature, not an oversight.
One key fills every layer that claims it
Go back to the demo above. price renders twice: small and grey under "OFFERED AT" in the opening card, then as the hero line in the closing card. listing_address renders twice as well, and city_location three times.
The binding is name to every matching layer, not name to one layer. That penthouse template carries seven overlays with isDynamic: true and only three distinct names between them:
["listing_address", "city_location", "price"] // 3 keys
// x2 x3 x2 // across 7 overlays, 6 scenes
Which is why you never think about scene structure when you build a payload. You describe the listing once. The template decides where the address appears, how often, and in what treatment. Rename a key and you break every layer bound to it at once, so treat those names as an interface, not a label.
Worth being precise about one thing before the next section: the penthouse template exposes text only. Its three variables are all text overlays, with the photography baked in. The real-estate-listing template used in the snippets below exposes eight variables including five image slots and a video slot, and that's the one the next argument is about. Same engine, very different amount of surface area. How much a template exposes is the template author's call, which is why reading variables off the API beats assuming.
Why not {{ mustache }}
Three reasons, and the third is the one that actually decides it.
Accidental matches. A token scanner walks text looking for delimiters. It will happily rewrite {{ price }} inside a caption, an alt text, or a user-supplied string you never meant to touch. A named binding maps to exactly one layer, by id, before any text is read.
No escaping problem. You're never parsing user content for delimiters, so there's no delimiter to escape, and no "what if the address literally contains braces" edge case.
Binary media. This is the killer:
// Token substitution is fine here.
caption.replace("{{ price }}", "$1,250,000");
// Now do the same for a 40 MB mp4.
timeline.replace("{{ clip_tour }}", /* ...what, exactly? */);
You cannot string-interpolate a video file into a placeholder. You can point a video layer's src at a different URL. Name-binding treats text and media identically, one key to one layer to one field, which means the same mechanism that swaps a headline swaps a walkthrough clip. Token syntax needs a second, different mechanism for media, and now you have two systems.
Don't guess the variable names
This is the bit I'd have wanted on day one. You don't read the template JSON to learn its interface. You ask for it:
curl https://renderly.video/api/v1/templates \
-H "Authorization: Bearer $RENDERLY_API_KEY"
Each template comes back with its variables (the names of every isDynamic overlay) and its defaults. So discovery is programmatic:
const res = await fetch("https://renderly.video/api/v1/templates", {
headers: { Authorization: `Bearer ${process.env.RENDERLY_API_KEY}` },
});
const { data } = await res.json();
const tpl = data.find(t => t.id === "real-estate-listing");
console.log(tpl.variables);
// ["photo_exterior", "headline", "price", "clip_tour",
// "photo_living", "photo_open", "photo_kitchen", "photo_dining"]
Eight variables on that one: two text fields, five image slots, one video slot. Validate your column mapping against that array at startup and you turn a silent "why is scene 4 still the default photo" into a loud error.
Render one
One POST per row. Template id, that row's replacements, and optionally a one-off webhook URL:
curl -X POST https://renderly.video/api/v1/renders \
-H "Authorization: Bearer $RENDERLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"templateId": "real-estate-listing",
"replacements": {
"headline": "1428 Maple Grove\nLane",
"price": "$1,250,000",
"photo_exterior": "https://cdn.example.com/1428-exterior.jpg",
"clip_tour": "https://cdn.example.com/1428-tour.mp4"
},
"webhookUrl": "https://yourapp.com/webhooks/renderly"
}'
You get a job back immediately:
{
"success": true,
"data": {
"jobId": "cmd8x2f9k0001qp3v7h2n4x8t",
"status": "PROCESSING",
"mode": "template",
"creditsUsed": 1,
"estimatedDurationMinutes": 0.9
}
}
Status values are uppercase: PENDING, PROCESSING, COMPLETED, FAILED. If you write if (status === "pending") you will wait forever, which is a five-minute bug that feels like a thirty-minute one.
mode echoes which of the three request shapes you used: template (a public template id), project (one of your own saved projects), or direct (raw inputProps, no template at all). Send none of the three and you get a 400 telling you so. Send a template id that isn't public and you get a 403, not a 404, which is a useful distinction when you're debugging why your own draft template won't render.
Poll GET /api/v1/renders/:jobId if you must, but prefer the webhook. render.completed arrives with a download URL, render.failed arrives with a reason.
Two things worth knowing about the payload
Replacements can be flat. Any top-level key the endpoint doesn't recognize gets folded into replacements for you. These are equivalent:
{ templateId: "real-estate-listing", replacements: { price: "$1,250,000" } }
{ templateId: "real-estate-listing", price: "$1,250,000" }
Handy when you're piping a flat CSV row straight in. If a key appears in both places, the nested replacements object wins. I'd still use the explicit nested form in anything long-lived, because the flat form silently swallows typos: misspell templateId as templatedId and it becomes a replacement named templatedId instead of throwing.
\n in text content gives you a real line break. Look again at that default: "1428 Maple Grove\nLane". That's deliberate. A long address on a single line gets shrunk to fit the frame, and a 40-character street name renders at a size nobody can read on a phone. Break it yourself where it makes sense:
function addressForVideo(street) {
const words = street.split(" ");
if (words.length < 3) return street;
const mid = Math.ceil(words.length / 2);
return words.slice(0, mid).join(" ") + "\n" + words.slice(mid).join(" ");
}
Also worth knowing before you budget: credits are duration x rate x resolution multiplier, rounded up to the nearest 0.5. The multiplier is 1x at 1080p, 2x at 2K, 4x at 4K and above. So that 54-second listing costs 1 credit at 1080p and 4 at 4K, and a 12-second clip costs 0.5 rather than some fraction you can't reason about.
Render 500
Now it's just a loop, and the only real decision is concurrency. Don't await in a for loop over 500 rows unless you enjoy waiting:
const BATCH = 20;
async function renderListing(listing) {
const res = await fetch("https://renderly.video/api/v1/renders", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RENDERLY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
templateId: "real-estate-listing",
replacements: {
headline: addressForVideo(listing.address),
price: listing.priceFormatted,
photo_exterior: listing.photos.exterior,
photo_kitchen: listing.photos.kitchen,
clip_tour: listing.tourClip,
},
webhookUrl: "https://yourapp.com/webhooks/renderly",
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json();
}
for (let i = 0; i < listings.length; i += BATCH) {
const settled = await Promise.allSettled(
listings.slice(i, i + BATCH).map(renderListing)
);
const failed = settled.filter(s => s.status === "rejected");
if (failed.length) console.error(`batch ${i / BATCH}: ${failed.length} failed`);
}
Note the five keys against eight available variables. The three photo slots I left out keep their template defaults, which is exactly what you want when a listing only shipped two usable photos.
Retries, HMAC verification on the webhook callback, and what changes at 10,000 rows are their own topic. I wrote that one up separately: Generate 1,000+ personalized videos with a REST API.
When to skip all of this
If you're making one video, edit one video. The tagging work only pays off on repeatability: same structure, changing data, more than a handful of times. A launch film or a bespoke brand piece fails that test, and forcing it into a template is how you end up with a template that has forty variables and serves nobody.
The break-even is lower than it feels, though. A dozen videos already covers the cost of marking up the layers.
Wrap-up
The whole model is four lines: mark a layer isDynamic, give it a stable name, bind that name in replacements, and let the layer type decide whether content or src gets swapped. Because the binding is by name and not by scanning text, it works the same for a headline and a walkthrough clip, and it doesn't break when your data contains braces.
Full version with the cost breakdown and the spreadsheet and no-code paths: Dynamic Video Templates: Variable-Based Video Creation. Or grab a key at renderly.video and render one from the template above.
How are you handling media variables in your own templating layer? I'm curious whether anyone has made token syntax work cleanly for binary assets, because I couldn't.

Top comments (0)