Disclosure: RenderScreenshot is our product, and it overlaps with Bannerbear for one job: images generated from a template plus data, such as social cards and product cards. Bannerbear facts on this page come from its API reference and pricing page, both checked on 2026-10-18. We have no Bannerbear account and did not render with it, so this page has no side-by-side images or timings. The adapter code was tested against a local stub of our API's JSON response, not the live service.
The real choice here is not one vendor or the other. It is where your template lives. In Bannerbear, a template is a design in its web editor, and your code fills named layers with modifications. In RenderScreenshot, a template is HTML and CSS in your repository, rendered by Chromium. Everything else on this page follows from that.
TL;DR
| Stay with Bannerbear if | Move to RenderScreenshot if |
|---|---|
| Designers or marketers edit templates in a visual editor and should not need a deploy | Developers own the templates and want them reviewed in pull requests |
| You use animations, video, workflows or AI image tools | You only make still images and PDFs |
| You want hosted files and do not want to run storage | You are fine with 30-day CDN links, or copy files to your own bucket |
| Your volumes fit the plan you already pay for | You request several formats or 2x scale, and the credit multiplier hurts |
| You also need website screenshots: same key, same API |
The two template models
| Bannerbear | RenderScreenshot | |
|---|---|---|
| Where a template lives | Bannerbear's editor, identified by a template UID | Your code: an HTML string, a view or a page on your site |
| How data gets in | modifications.objects[], each with a layer name and properties such as text, color or background-image |
Variables in your HTML template, escaped by you or your template engine |
| Layout engine | Bannerbear's renderer, with options such as text auto-fit set on the layer | Chromium: any CSS, including grid, line-clamp, container queries |
| Fonts | Uploaded to or chosen in the editor | Web fonts in the template (@font-face or Google Fonts) |
| Versioning | Template edits in the editor apply to future renders | Git history and code review |
| Who can change a design | Anyone with editor access | Anyone who can merge to the repo |
Neither model is better in general. An editor is the right tool when the people who own the look of an image do not write code. HTML is the right tool when they do, because the template then shares the site's design tokens and fonts and goes through the same review.
API mapping
| Bannerbear (v5) | RenderScreenshot | Notes |
|---|---|---|
POST https://api.bannerbear.com/v5/images |
POST /v1/screenshot with html |
See content input |
Authorization: Bearer <key> |
Authorization: Bearer rs_live_... |
Bearer auth |
template (UID) |
The HTML you send, or a url to a template page on your site |
|
modifications.objects[] |
Values in your HTML | The adapter below keeps the array shape |
modifications.template.width/height |
viewport.width, viewport.height, or preset: "og_card" |
Presets |
formats: ["png", "jpg"] |
output.format: one of png, jpeg, webp, pdf |
One format per request, one credit each |
scale |
viewport.scale (1 to 3) |
Does not change the credit cost |
quality |
output.quality (1 to 100, JPEG and WebP) |
|
Async 202, then poll GET /v5/images/:uid or webhook |
Synchronous by default; webhooks optional | |
sync.api.bannerbear.com, 10-second limit |
The default request; wait.timeout up to 60 seconds |
|
files.png URL in the response |
image.url in JSON, or the X-Cache-URL header |
Expires with the cache TTL, at most 30 days |
| Instant URLs (Scale plan and above) | Signed URLs, all plans | HMAC-signed GET, 30-day maximum expiry |
| Custom S3 (Scale plan and above) | storage to your S3-compatible bucket (paid plans) |
Path template needs {hash} and {ext} |
| Rate limit: 60 POSTs per 10 seconds | Per-account limit by plan, shared by all keys | Rate limits |
Migrating one template
Take a typical product card: a title, a price, an accent colour and a product photo. In Bannerbear those are four layers named title, price, accent and photo. Your code sends something like [{ "name": "title", "text": "Desk lamp" }, ...].
The migration keeps that array. Each former layer becomes one variable in an HTML function, and a small adapter turns the array into a request. Your calling code does not change shape, which lets you move one template at a time.
Node.js
// adapter.mjs const API = process.env.RS_API_BASE ?? 'https://api.renderscreenshot.com'; const escapeHtml = (v) => String(v ?? '') .replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') .replaceAll('"', '"').replaceAll("'", '''); // The former Bannerbear template, rebuilt as HTML. One field per former layer. export function productCard(layers) { const title = escapeHtml(layers.title?.text); const price = escapeHtml(layers.price?.text); const accent = /^#[0-9a-f]{3,8}$/i.test(layers.accent?.color ?? '') ? layers.accent.color : '#22c55e'; // https only, and no quotes, parentheses, backslashes or spaces that could end the CSS url(). const photo = /^https:\/\/[^\s'"()\\]+$/.test(layers.photo?.image_url ?? '') ? escapeHtml(layers.photo.image_url) : ''; return `<!doctype html><html><head><meta charset="utf-8"><style> body{margin:0;width:1200px;height:630px;display:grid;grid-template-columns:1fr 520px; font-family:system-ui,sans-serif;background:#111827;color:#f9fafb} .text{padding:72px;display:flex;flex-direction:column;justify-content:center;gap:24px} h1{margin:0;font-size:60px;line-height:1.1} .price{font-size:44px;font-weight:700;color:${accent}} .photo{background:#1f2937 center/cover no-repeat} </style></head><body> <div class="text"><h1>${title}</h1><div class="price">${price}</div></div> <div class="photo" style="background-image:url('${photo}')"></div> </body></html>`; } // Accepts the same modifications array you sent to Bannerbear. export async function createImage(template, modifications) { const layers = Object.fromEntries(modifications.map((m) => [m.name, m])); const res = await fetch(`${API}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ html: template(layers), viewport: { width: 1200, height: 630 }, output: { format: 'png' }, wait: { until: 'networkidle' }, // let the product photo finish loading cache: { ttl: 2592000 }, }), }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`); return { url: body.image.url, expiresAt: body.cache?.expires_at }; }
import { createImage, productCard } from './adapter.mjs'; const { url } = await createImage(productCard, [ { name: 'title', text: 'Walnut desk lamp' }, { name: 'price', text: '$49' }, { name: 'accent', color: '#f59e0b' }, { name: 'photo', image_url: 'https://example.com/lamp.jpg' }, ]);
Three details in that template are deliberate:
- Escaping. Bannerbear treats layer text as text. In HTML, a product name with
<or&would become markup, so every text value is escaped. - Allow-lists for non-text values. The colour must look like a hex colour, and the photo must be an
https://URL with no quotes, parentheses, backslashes or spaces. Anything else falls back to a default. HTML escaping alone is not enough inside astyleattribute, because the browser decodes'back to a quote before the CSS parser sees it, and the value could closeurl('...')and add CSS of its own. wait.until: "networkidle". The product photo is a remote image. The wait options make sure it has loaded before capture.
Python
import html import json import os import re import urllib.error import urllib.request API = os.environ.get('RS_API_BASE', 'https://api.renderscreenshot.com') def product_card(layers): e = lambda name, field='text': html.escape(str(layers.get(name, {}).get(field, '')), quote=True) accent = layers.get('accent', {}).get('color', '') accent = accent if re.fullmatch(r'#[0-9a-fA-F]{3,8}', accent) else '#22c55e' return f'''<!doctype html><html><head><meta charset="utf-8"><style> body{{margin:0;width:1200px;height:630px;display:grid;grid-template-columns:1fr 520px; font-family:system-ui,sans-serif;background:#111827;color:#f9fafb}} .text{{padding:72px;display:flex;flex-direction:column;justify-content:center;gap:24px}} h1{{margin:0;font-size:60px;line-height:1.1}} .price{{font-size:44px;font-weight:700;color:{accent}}} </style></head><body><div class="text"><h1>{e('title')}</h1><div class="price">{e('price')}</div></div></body></html>''' def create_image(template, modifications): layers = {m['name']: m for m in modifications} body = json.dumps({ 'html': template(layers), 'viewport': {'width': 1200, 'height': 630}, 'output': {'format': 'png'}, 'cache': {'ttl': 2592000}, }).encode() req = urllib.request.Request(f'{API}/v1/screenshot', data=body, method='POST', headers={ 'Authorization': f"Bearer {os.environ['RS_API_KEY']}", 'Content-Type': 'application/json', 'Accept': 'application/json', }) try: with urllib.request.urlopen(req, timeout=60) as res: data = json.load(res) except urllib.error.HTTPError as err: error = json.load(err).get('error', {}) raise RuntimeError(f"{err.code} {error.get('code')}: {error.get('message')}") from err return {'url': data['image']['url'], 'expires_at': data.get('cache', {}).get('expires_at')}
Ruby
require 'erb' require 'json' require 'net/http' API = ENV.fetch('RS_API_BASE', 'https://api.renderscreenshot.com') def product_card(layers) e = ->(name, field = 'text') { ERB::Util.html_escape(layers.dig(name, field).to_s) } accent = layers.dig('accent', 'color').to_s.match?(/\A#\h{3,8}\z/) ? layers.dig('accent', 'color') : '#22c55e' <<~HTML <!doctype html><html><head><meta charset="utf-8"><style> body{margin:0;width:1200px;height:630px;display:grid;grid-template-columns:1fr 520px; font-family:system-ui,sans-serif;background:#111827;color:#f9fafb} .text{padding:72px;display:flex;flex-direction:column;justify-content:center;gap:24px} h1{margin:0;font-size:60px;line-height:1.1} .price{font-size:44px;font-weight:700;color:#{accent}} </style></head><body><div class="text"><h1>#{e.('title')}</h1><div class="price">#{e.('price')}</div></div></body></html> HTML end def create_image(modifications) layers = modifications.to_h { |m| [m['name'], m] } uri = URI("#{API}/v1/screenshot") req = Net::HTTP::Post.new(uri, 'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}", 'Content-Type' => 'application/json', 'Accept' => 'application/json') req.body = JSON.generate(html: product_card(layers), viewport: { width: 1200, height: 630 }, output: { format: 'png' }, cache: { ttl: 2_592_000 }) res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 60) { |h| h.request(req) } body = JSON.parse(res.body) raise "#{res.code} #{body.dig('error', 'code')}: #{body.dig('error', 'message')}" unless res.is_a?(Net::HTTPSuccess) { url: body.dig('image', 'url'), expires_at: body.dig('cache', 'expires_at') } end
The Python and Ruby versions are shortened to the title and price layers; add the photo layer the same way as in Node. In a Rails app, render the template with a view instead; ERB escapes <%= %> output for you.
Tested on 2026-10-18 with Node 22.18, Python 3.15 and Ruby 4.0 against a local stub that checks the request (auth header, viewport, escaped <b> in a title with non-ASCII characters) and returns the API's JSON shape. Not run against the live API.
A gradual rollout
- Rebuild your most-used template in HTML. Render it locally in a browser at 1200×630 first; it is just a web page.
- Put both providers behind one function,
createImage(templateName, modifications), and choose the provider per template with a config flag. - Render a sample of real modification arrays with both and compare the files by eye, especially long titles and non-Latin text.
- Switch the template, watch for errors for a week, then move the next one.
- If you rely on Bannerbear's permanent file URLs, set up storage before switching, because our CDN links expire with the cache TTL.
Price at three volumes
Bannerbear lists these plans on its pricing page (checked 2026-10-18, monthly, USD). Credits are counted per image per format per scale step, so these numbers assume one format at scale 1:
| Images per month | Bannerbear | RenderScreenshot |
|---|---|---|
| 1,000 | Automate, $49/month (1,000 API credits) | Starter, $10/month (2,500 credits) |
| 10,000 | Scale, $149/month (10,000 API credits) | Growth, $40/month (12,000 credits) |
| 100,000 | Enterprise, from $299/month for 50,000 API credits; its credit selector goes higher, priced on the page | More than Scale's 60,000 credits ($100/month); contact us |
Two rules change these numbers in practice:
- Bannerbear multiplies. Its docs (checked 2026-10-15 for our Bannerbear vs Placid comparison) say a render costs one credit per format, multiplied by the scale. A PNG and a JPG at scale 2 is four credits. RenderScreenshot charges one credit per successful render, whatever the format or scale, and nothing for failures or for a reused render.
- Bannerbear's Instant URLs and custom S3 start at the Scale plan. Our signed URLs work on every plan, including the free one; custom storage needs a paid plan.
Bannerbear's plans also include AI credits, animations and workflows, which we do not offer. If you use them, the price comparison above is not like for like.
When to stay with Bannerbear
Stay when the template editor is the product you are paying for: a marketing team that changes designs every week without engineers, or a no-code workflow that chains image, video and AI steps. Stay too if you need animated output, which we do not render.
Move when your templates already feel like code: they are generated, parameterised and reviewed by developers, and the editor is one more place a design can drift from your site. For other routes, see the Placid alternative, the Bannerbear vs Placid comparison, the HTML/CSS to Image vs Bannerbear comparison, the APITemplate.io alternative and the full list of OG image generators. For signed og:image URLs, start at the OG image API.
Get a free key, paste one template's HTML into the adapter and render a dozen of your real modification arrays; 150 free credits a month are enough to judge the result.