Generate Marketing Images from HTML Templates

One HTML template, nine ad and social sizes, one batch request. A container-query template we tested at each size, the batch call, and what it costs.

By Simon Chiu · Updated

You have one campaign and a spec sheet of sizes: a medium rectangle, a leaderboard, a skyscraper, a square for social, a story and a link card. A designer can draw nine artboards once. Doing it for every product, price change and language is where the time goes. This page shows the developer route: write the creative once as an HTML template, then let a headless browser render it at every size. It is part of our use cases; for single social cards, the OG image API page is the better start.

What we tested

We built one HTML template (below) and rendered it locally with Chromium through Puppeteer 25.14.0 at nine sizes on 2026-10-18. For each size, a script checked that the brand, headline and button stayed inside the frame. All nine fit. One problem showed up only when we looked at the images: in the three wide banners, the headline "Spring sale: 30% off annual plans" was cut to one line with an ellipsis. The layout was correct; the copy was too long for the format. The fix is in the template section.

We did not call the RenderScreenshot API for this page, so there are no API renders or API timings here. The requests below follow the batch endpoint reference.

The request set

One URL, nine viewports. Each row is one item in the batch.

Format Viewport Request item
Medium rectangle 300x250 "viewport": {"width": 300, "height": 250}
Leaderboard 728x90 "viewport": {"width": 728, "height": 90}
Wide skyscraper 160x600 "viewport": {"width": 160, "height": 600}
Half page 300x600 "viewport": {"width": 300, "height": 600}
Mobile banner 320x50 "viewport": {"width": 320, "height": 50}
Billboard 970x250 "viewport": {"width": 970, "height": 250}
Square post 1080x1080 "viewport": {"width": 1080, "height": 1080}
Story 1080x1920 "viewport": {"width": 1080, "height": 1920}
Link card 1200x628 "preset": "twitter_card_large"

The last row uses a preset because one already exists for that size. Use og_card (1200x630) when the image goes in an og:image tag.

How it works

flowchart LR
  A[Campaign data<br/>headline, price, CTA] --> B[Template page on your site<br/>/creative/spring-sale]
  B --> C[POST /v1/batch<br/>same URL, 9 viewports]
  C --> D[Chromium renders<br/>each viewport]
  D --> E[CDN URLs in results]
  E --> F[Your job downloads files<br/>to the ad library or DAM]

The template is a normal page in your app, so it uses your fonts, your colors and your build. The batch endpoint takes URLs, not raw HTML, which is why the template is a page. If you would rather not host it, send the markup to POST /v1/screenshot with the html input, once per size.

The template

The idea: the template does not know its size. CSS container queries read the aspect ratio of the frame and pick a layout. cqmin units scale type with the smaller side, so the text grows with the canvas.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
  * { box-sizing: border-box; margin: 0; }
  html, body { width: 100%; height: 100%; }
  body { font-family: system-ui, sans-serif; background: #0b1220; color: #fff; }
  .ad { container-type: size; width: 100%; height: 100%; }
  .frame { height: 100%; display: grid; gap: 4cqmin; padding: 6cqmin;
           grid-template: "brand" auto "headline" 1fr "cta" auto / 1fr; }
  .brand { grid-area: brand; font-weight: 700; font-size: clamp(10px, 5cqmin, 40px); color: #93c5fd; }
  .headline { grid-area: headline; font-weight: 800; line-height: 1.05; font-size: clamp(14px, 11cqmin, 120px);
              overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; align-self: center; }
  .cta { grid-area: cta; justify-self: start; background: #f59e0b; color: #111; font-weight: 700;
         font-size: clamp(10px, 5cqmin, 44px); padding: 1.5cqmin 4cqmin; border-radius: 999px; }
  .headline .short { display: none; }
  /* Wide, short banners (728x90, 970x250, 320x50): one row, short copy */
  @container (aspect-ratio > 3) {
    .frame { grid-template: "brand headline cta" 1fr / auto 1fr auto; align-items: center; padding: 0 3cqw; gap: 2cqw; }
    .headline { font-size: clamp(12px, 38cqh, 64px); -webkit-line-clamp: 1; }
    .headline .long { display: none; }
    .headline .short { display: inline; }
    .brand, .cta { font-size: clamp(9px, 22cqh, 32px); }
  }
  /* Tall skyscrapers and stories (160x600, 300x600, 1080x1920) */
  @container (aspect-ratio < 0.6) {
    .headline { font-size: clamp(16px, 17cqw, 140px); -webkit-line-clamp: 5; }
  }
</style>
</head>
<body>
  <div class="ad"><div class="frame">
    <div class="brand">Acme Cloud</div>
    <div class="headline"><span class="long">Spring sale: 30% off annual plans</span><span class="short">30% off annual</span></div>
    <div class="cta">Start free</div>
  </div></div>
</body>
</html>

The short span is the fix for the wide banners. In your app, both strings come from campaign data. Escape them when you put them into the page (your template engine does this by default; raw string concatenation does not), because a headline is user-editable text and could otherwise inject markup.

To catch the ellipsis problem before a human does, check for clipped text, not only for boxes outside the frame. Our first check compared scrollHeight with clientHeight and flagged every size, because a line height of 1.05 lets glyphs stick out by a pixel or two. A tolerance of half a line fixed it:

// Run in the page (page.evaluate) at each size. Returns the elements whose text was cut.
[...document.querySelectorAll('.brand, .headline, .cta')].filter((el) => {
  const half = parseFloat(getComputedStyle(el).lineHeight) / 2;
  return el.scrollWidth > el.clientWidth + 1 || el.scrollHeight - el.clientHeight > half;
}).map((el) => el.className);

With the long headline only, this flagged the 728x90, 320x50 and 970x250 banners. With the short copy in place, all nine sizes passed. Run it in a Puppeteer loop in CI whenever someone edits the template.

The batch call

This Node 22 script sends one batch with all nine items and saves the results. Nine items is below the limit of 10 for a synchronous batch, so the response holds the results. Your plan also caps the batch size; see rate limits.

// node render-campaign.mjs https://example.com/creative/spring-sale
import { writeFile } from 'node:fs/promises';

const url = process.argv[2];
const sizes = [[300, 250], [728, 90], [160, 600], [300, 600], [320, 50], [970, 250], [1080, 1080], [1080, 1920]];
const requests = [
  ...sizes.map(([width, height]) => ({ url, viewport: { width, height }, output: { format: 'png' } })),
  { url, preset: 'twitter_card_large' },
];

const res = await fetch('https://api.renderscreenshot.com/v1/batch', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ requests }),
});
const batch = await res.json();
if (!res.ok) throw new Error(`${res.status} ${batch.error?.code}: ${batch.error?.message}`);

for (const item of batch.results) {
  if (item.status !== 'completed') {
    console.error(`item ${item.position} failed: ${item.error}`);
    process.exitCode = 1;
    continue;
  }
  const { image_url, width, height } = item.image;
  const file = Buffer.from(await (await fetch(image_url)).arrayBuffer());
  await writeFile(`creative-${width}x${height}.png`, file);
}

Download the files soon after the batch. The CDN links last until the cache TTL ends (30 days at most), so an ad library should keep its own copy. On paid plans you can send each render straight to your bucket with the storage option.

Settings that matter for ad creative

  • Viewport, not resize. The viewport sets the layout. output.resize scales a finished image and keeps the layout of the larger size, which is right for a review board of thumbnails and wrong for an ad slot.
  • Scale. viewport.scale: 2 doubles the pixels for a sharper file on high-density screens. Ad networks differ on whether they accept a 2x file for a 1x slot, so check each spec before you switch it on.
  • Format and weight. PNG keeps flat colors and text sharp. Many networks cap file weight, so if a creative has a photo, try "output": {"format": "jpeg", "quality": 85} or WebP and check the size.
  • Fonts. Load the brand font with @font-face in the template and wait for it. A late font is the most common reason a render does not match the design.
  • Copy length per format. Decide in the data model which formats get short copy. The template can only hide text; it cannot write a shorter headline.

What it costs

Each successful image is one credit, whatever the size, format or scale. A team that ships 40 campaigns a month in these nine sizes makes 360 images. That fits the Starter plan (2,500 credits for $10 a month). At 1,000 campaigns a month (9,000 images), the Growth plan has 12,000 credits for $40. Failed renders are not charged. Check current plans on the pricing page.

When an editor tool is the better fit

If the people who change the creative do not write HTML, a template editor such as Bannerbear or Placid lets them edit the design without a deploy. The Bannerbear vs Placid comparison covers those two editor tools. HTML wins when the templates belong to developers, live in the same repository as the site and should go through code review.

Start with the template above, host it on a test route and send the batch with a free API key.

Last tested: 2026-10-18. Template checked locally with Puppeteer 25.14.0 (Chromium) at nine sizes. The batch script follows the documented API and was not run against the live service.

Frequently asked questions

Can one HTML template really cover a 728x90 leaderboard and a 1080x1920 story?
Yes, if the layout reacts to the shape of the frame. We used CSS container queries on aspect ratio: wide banners switch to a single row, tall formats get a bigger headline with more lines. The same file rendered at nine sizes without overflow in our local Chromium test. The one thing that did not fit was the long headline in the wide banners, so give the template a short headline for those.
Can I send raw HTML in a batch request?
No. POST /v1/batch takes URLs. Either host the template as a page on your site and batch its URL at different viewport sizes, or send the HTML to POST /v1/screenshot once per size.
How many credits does a campaign cost?
One credit per successful image. A campaign in nine sizes costs nine credits, whatever the format or scale. Failed renders are not charged.
Should I use output.resize or a different viewport for each size?
A different viewport. The viewport decides the layout, so each size gets a layout made for it. output.resize only scales a finished image, which is right for review thumbnails, not for ad slots.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.