Bannerbear Alternative: HTML Templates Instead of an Editor

Moving off Bannerbear? Rebuild a template as HTML, keep your modifications array with a tested Node, Python and Ruby adapter, and compare cost at three volumes.

By Simon Chiu · Updated

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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
  .replaceAll('"', '&quot;').replaceAll("'", '&#39;');

// 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 a style attribute, because the browser decodes &#39; back to a quote before the CSS parser sees it, and the value could close url('...') 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

  1. Rebuild your most-used template in HTML. Render it locally in a browser at 1200×630 first; it is just a web page.
  2. Put both providers behind one function, createImage(templateName, modifications), and choose the provider per template with a config flag.
  3. Render a sample of real modification arrays with both and compare the files by eye, especially long titles and non-Latin text.
  4. Switch the template, watch for errors for a week, then move the next one.
  5. 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.

Frequently asked questions

Can I import my Bannerbear templates into RenderScreenshot?
No. Bannerbear templates live in its editor and there is no export to HTML. You rebuild each template once as an HTML page or function; each named layer becomes one field in the template. Most social-card and product-card templates are a few dozen lines of HTML and CSS.
Does RenderScreenshot charge more for PNG plus JPG, or for 2x images?
No. Every successful render is one credit, whatever the format or scale. Bannerbear multiplies credits by the number of formats and by the scale, according to its docs.
Is there a synchronous API like Bannerbear's sync endpoint?
Yes, by default. POST /v1/screenshot waits for the render and returns the image bytes, or JSON with a CDN URL when you send Accept: application/json. Webhooks are optional for long jobs.
What replaces Bannerbear Instant URLs in og:image tags?
Signed URLs. You sign a GET URL on your server with an HMAC, put it in the tag, and the API renders on the first fetch. They work on every plan, including free, and must expire within 30 days.
Are image URLs permanent?
No. The CDN URL lasts until the cache TTL ends, 30 days at most. For permanent files, copy each render into your own S3-compatible bucket with the storage option, which is on paid plans.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.