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.resizescales 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: 2doubles 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-facein 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.