DEV Community

Cover image for One JSON template, 500 unique videos: name-binding vs {{ mustache }}
Renderly.Video
Renderly.Video

Posted on • Originally published at renderly.video

One JSON template, 500 unique videos: name-binding vs {{ mustache }}

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:

Two scenes from a rendered penthouse listing video. The opening card reads 1200 Highline Avenue, Belltown Seattle, $4,250,000 over a slowly panning shot of a modern apartment building. The closing card shows the same price as a large headline with the same address beneath it, over an interior shot, with a Schedule a private viewing button.

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"
}
Enter fullscreen mode Exit fullscreen mode

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:

  • text and shape layers → the content field is replaced
  • image, video, and sound layers → the src field 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 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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? */);
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"
  }'
Enter fullscreen mode Exit fullscreen mode

You get a job back immediately:

{
  "success": true,
  "data": {
    "jobId": "cmd8x2f9k0001qp3v7h2n4x8t",
    "status": "PROCESSING",
    "mode": "template",
    "creditsUsed": 1,
    "estimatedDurationMinutes": 0.9
  }
}
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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(" ");
}
Enter fullscreen mode Exit fullscreen mode

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`);
}
Enter fullscreen mode Exit fullscreen mode

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)