Placid Alternative: Rebuild Your Templates as HTML

Leaving Placid? Map layers, modifications and the URL API to HTML templates, then switch with a tested adapter in Node, Python and Ruby and a gradual rollout.

By Simon Chiu · Updated

Disclosure: RenderScreenshot is our product. Placid facts on this page come from its image API docs, URL API docs, URL API authentication docs and pricing page, all checked on 2026-10-18. We have no Placid account, so this page has no Placid renders, timings or side-by-side images. The adapter and rollout code ran against a local stub of both APIs, not the live services.

Placid is a template editor with an API. You design a card in its editor, give layers names such as title or photo, and your code fills them. RenderScreenshot renders HTML in Chromium. So the move is not a change of endpoint. It is a change of where the design lives: from Placid's editor to an HTML file in your repository. The OG image generator roundup compares the wider field; this page is about the move itself.

Short answer

Placid is the better fit when RenderScreenshot is the better fit when
Marketers edit templates and must not wait for a deploy Developers own templates and review them in pull requests
You need video and PDF from the same template, or the Editor SDK in your own app You make still images and PDFs, and also need website screenshots
Volume varies a lot by month and credit rollover helps You want to render your site's real CSS, fonts and components
You want unsigned URL-based images with no server code You can sign URLs on your server (every plan, including free)

How the two models map

Placid RenderScreenshot Notes
template_uuid An HTML file or a route on your site Rebuild once; the template is code from then on
layers.<name>.text A placeholder in the HTML, filled and escaped by your code The adapter below uses {{title.text}}
layers.<name>.image <img src="..."> in the template Chromium fetches the image during the render
layers.<name>.hide A hidden attribute or a CSS class
modifications.width / height viewport.width / viewport.height Placid keeps the template's aspect ratio. In HTML, the viewport sets the layout, so a new size can get a new layout
modifications.image_format (auto, jpg, png, webp) output.format (png, jpeg, webp, pdf) No auto. Pick PNG for flat cards and transparency, JPEG or WebP for photos
modifications.filename output.filename
create_now: true The default POST /v1/screenshot waits for the render and returns the image
Queue + polling_url Batches only: GET /v1/batch/:id
webhook_success webhook.url with events: ["completed", "failed"] Webhook events are signed
passthrough No equivalent Keep your own job ID with the request ID
transfer to S3 (keys in each request) storage with credentials saved in the dashboard Paid plans. storage.path must contain {hash} and {ext}
image_url X-Cache-URL header, or image.url in JSON Lasts until the cache TTL ends, 30 days at most
URL API /u/{template_uuid}?title[text]=... Signed GET /v1/screenshot?url=<your card page>&preset=og_card Our signature uses a separate rs_secret_ key; expires is required
URL API &s= HMAC (optional) signature HMAC-SHA256 (required) Placid allows unsigned URLs unless you restrict them. A public URL here should always be signed, because an api_key query parameter would expose your key

References for our side: HTML input, output options, webhooks, storage and signed URLs.

Rebuilding one template

Take a typical blog card: an author photo on the left, a title and a byline on the right. In HTML it is a short file. Layer names from Placid become placeholders, so the data your code sends does not change:

<!doctype html>
<html><head><meta charset="utf-8"><style>
  body { margin: 0; width: 1200px; height: 630px; display: flex; align-items: center; gap: 48px;
         padding: 0 80px; box-sizing: border-box; background: #111827; color: #fff; font-family: system-ui, sans-serif; }
  img { width: 220px; height: 220px; border-radius: 50%; object-fit: cover; }
  img[hidden] { display: none; }
  h1 { font-size: 64px; line-height: 1.1; margin: 0; }
  p { font-size: 30px; color: #9ca3af; margin: 16px 0 0; }
</style></head>
<body>
  <img src="{{photo.image}}" alt="" {{photo.hide}}>
  <div><h1>{{title.text}}</h1><p>{{author.text}}</p></div>
</body></html>

Three things change when you leave an editor:

  1. Text fitting is CSS now. In an editor, how a long title shrinks or wraps is a setting on the layer. In HTML you choose: -webkit-line-clamp to cut long titles, a smaller font size for long strings, or clamp() with container units. Test with the longest title you have.
  2. Fonts are files you load. Use @font-face in the template. Characters outside the font (emoji, CJK) need a fallback font, or they show as empty boxes.
  3. Escaping is your job. A title is data. Without escaping, a title such as <img src=x onerror=...> becomes markup in your card. The adapters below escape every text value.

The adapter: same layers, new renderer

Each adapter reads templates/<name>.html, fills the placeholders, and sends the HTML to POST /v1/screenshot. A layer value of hide: true fills {{layer.hide}} with hidden.

Node.js

// adapter.mjs: Placid-style layers in, PNG bytes out. Env: RS_API_KEY.
import { readFile } from 'node:fs/promises';

const BASE = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const escapeHtml = (v) => String(v).replace(/[&<>"']/g, (c) =>
  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);

export function fill(template, layers) {
  return template.replace(/\{\{(\w+)\.(\w+)\}\}/g, (_, layer, prop) => {
    const value = layers[layer]?.[prop];
    if (prop === 'hide') return value ? 'hidden' : '';
    return value == null ? '' : escapeHtml(value);
  });
}

export async function render(templateName, layers, { format = 'png', width = 1200, height = 630 } = {}) {
  const template = await readFile(new URL(`./templates/${templateName}.html`, import.meta.url), 'utf8');
  const res = await fetch(`${BASE}/v1/screenshot`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ html: fill(template, layers), viewport: { width, height }, output: { format } }),
    signal: AbortSignal.timeout(60_000),
  });
  if (!res.ok) {
    const { error } = await res.json().catch(() => ({}));
    throw new Error(`RenderScreenshot ${res.status} ${error?.code ?? ''}: ${error?.message ?? ''}`);
  }
  return { bytes: Buffer.from(await res.arrayBuffer()), cdnUrl: res.headers.get('x-cache-url') };
}

Python

# adapter.py: Placid-style layers in, PNG bytes out. Standard library only. Env: RS_API_KEY.
import html, json, os, re, urllib.error, urllib.request
from pathlib import Path

BASE = os.environ.get("RS_BASE_URL", "https://api.renderscreenshot.com")
TEMPLATES = Path(__file__).parent / "templates"


def fill(template: str, layers: dict) -> str:
    def sub(m):
        layer, prop = m.group(1), m.group(2)
        value = layers.get(layer, {}).get(prop)
        if prop == "hide":
            return "hidden" if value else ""
        return "" if value is None else html.escape(str(value), quote=True)

    return re.sub(r"\{\{(\w+)\.(\w+)\}\}", sub, template)


def render(template_name: str, layers: dict, fmt: str = "png", width: int = 1200, height: int = 630):
    page = fill((TEMPLATES / f"{template_name}.html").read_text(), layers)
    req = urllib.request.Request(
        f"{BASE}/v1/screenshot",
        data=json.dumps({"html": page, "viewport": {"width": width, "height": height}, "output": {"format": fmt}}).encode(),
        headers={"Authorization": f"Bearer {os.environ['RS_API_KEY']}", "Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=60) as res:
            return res.read(), res.headers.get("X-Cache-URL")
    except urllib.error.HTTPError as e:
        error = json.loads(e.read() or b"{}").get("error", {})
        raise RuntimeError(f"RenderScreenshot {e.code} {error.get('code')}: {error.get('message')}") from e

Ruby

# adapter.rb: Placid-style layers in, PNG bytes out. Standard library only. Env: RS_API_KEY.
require 'cgi'
require 'json'
require 'net/http'

module CardAdapter
  BASE = ENV.fetch('RS_BASE_URL', 'https://api.renderscreenshot.com')
  TEMPLATES = File.join(__dir__, 'templates')

  def self.fill(template, layers)
    template.gsub(/\{\{(\w+)\.(\w+)\}\}/) do
      layer, prop = Regexp.last_match(1), Regexp.last_match(2)
      value = layers.dig(layer.to_sym, prop.to_sym)
      next(value ? 'hidden' : '') if prop == 'hide'

      value.nil? ? '' : CGI.escapeHTML(value.to_s)
    end
  end

  def self.render(template_name, layers, format: 'png', width: 1200, height: 630)
    page = fill(File.read(File.join(TEMPLATES, "#{template_name}.html")), layers)
    uri = URI("#{BASE}/v1/screenshot")
    req = Net::HTTP::Post.new(uri, 'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}",
                                   'Content-Type' => 'application/json')
    req.body = { html: page, viewport: { width:, height: }, output: { format: } }.to_json
    res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 60) { |http| http.request(req) }
    unless res.is_a?(Net::HTTPSuccess)
      error = (JSON.parse(res.body) rescue {}).fetch('error', {})
      raise "RenderScreenshot #{res.code} #{error['code']}: #{error['message']}"
    end
    [res.body, res['X-Cache-URL']]
  end
end

We ran all three on 2026-10-18 (Node 22.18.0, Python 3.15.0, Ruby 4.0.7) against a local stub that logs each request body. Each sent the same JSON: the template with the title escaped (&lt;and back?&gt;), a 1200x630 viewport and png. The Python run with hide: True added the hidden attribute, and a request with a bad key raised an error with the API's error code and a non-zero exit.

A gradual rollout

Do not switch every card at once. This wrapper sends a share of cards to the new renderer, keeps Placid for the rest, and falls back to Placid when a render fails. A hash of the card key picks the bucket, so one card does not flip between providers on every request.

// rollout.mjs. Env: RS_API_KEY, PLACID_TOKEN, RS_SHARE (0-100), PLACID_BLOG_CARD_UUID.
import { createHash } from 'node:crypto';
import { render as renderHtml } from './adapter.mjs';

const PLACID_TEMPLATES = { 'blog-card': process.env.PLACID_BLOG_CARD_UUID };
const share = Number(process.env.RS_SHARE ?? 0);
const bucket = (key) => createHash('sha256').update(key).digest().readUInt16BE(0) % 100;

async function renderPlacid(templateName, layers) {
  const res = await fetch('https://api.placid.app/api/rest/images', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.PLACID_TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ template_uuid: PLACID_TEMPLATES[templateName], layers, create_now: true }),
    signal: AbortSignal.timeout(60_000),
  });
  const image = await res.json();
  // create_now can still return "queued" when Placid is busy; poll image.polling_url in that case.
  if (!res.ok || image.status !== 'finished') throw new Error(`Placid ${res.status} ${image.status ?? ''}`);
  return { provider: 'placid', url: image.image_url };
}

export async function renderCard(cardKey, templateName, layers) {
  if (bucket(cardKey) >= share) return renderPlacid(templateName, layers);
  try {
    const { bytes, cdnUrl } = await renderHtml(templateName, layers);
    return { provider: 'renderscreenshot', url: cdnUrl, bytes };
  } catch (err) {
    console.warn(`renderscreenshot failed for ${cardKey}, using Placid: ${err.message}`);
    return renderPlacid(templateName, layers);
  }
}

Against the stub, 200 card keys with RS_SHARE=25 went 44 to RenderScreenshot and 156 to Placid. With RS_SHARE=100 and a broken key, all 200 fell back to Placid. Start at 5 to 10%, compare the two images for the same card by eye, then raise the share.

Replacing the URL API in meta tags

The Placid URL API puts layer values in the image URL. The equivalent here is a card page on your own site, rendered through a signed GET URL:

  1. Add a route such as /cards/blog/:slug that renders the HTML template for one post at 1200x630.
  2. On each page render, sign GET /v1/screenshot?url=https://yoursite.com/cards/blog/my-post&preset=og_card with an expires up to 30 days ahead. The SDKs do this with generateUrl / generate_url; the signed URL docs have the rules.
  3. Put the signed URL in og:image. The first crawler fetch renders the card.

Signed URLs work only for GET render requests, so every option must be a flat query parameter. A card page keeps that list short, because the design and the data are in the page.

Price at three volumes

Placid prices in credits: one image is one credit for canvases up to 4,000 px, a PDF page is two, and video is 10 credits per 10 seconds. Unused credits roll over up to twice the monthly amount, and there are no overage fees: rendering stops until you upgrade. The dollar prices on Placid's pricing page load in the browser and were not in our text capture on 2026-10-18, so check them there.

Images per month Placid plan (credits per month) RenderScreenshot plan
1,000 Pro (2,500) Starter: 2,500 credits for $10/month
10,000 Business (25,000) Growth: 12,000 credits for $40/month
100,000 VIP (100,000) More than Scale's 60,000 credits ($100/month); contact us

Our credit rule is one credit per successful render, whatever the format or scale. Website screenshots and PDFs use the same credits. See pricing for current plans.

When to stay with Placid

  • Non-developers own the designs. An editor lets a marketer move a logo without a pull request. This is the main reason to stay.
  • You use video, or the Editor SDK. We render still images and PDFs only.
  • You rely on Placid's no-code integrations listed on its pricing page. Our integrations cover tools such as n8n, Make and Zapier, but check that the ones you use are there.
  • Your volume swings. Rollover credits suit a team that renders a lot in one month and little in the next.

If your cards already exist as pages or components on your site, the case goes the other way: the template is already written. The OG image API page shows that workflow, and the Bannerbear vs Placid comparison covers the other editor-first option, and the APITemplate.io alternative covers a third.

Rebuild one template, run the adapter with a free API key, and compare the result with your current Placid image.

Last verified: 2026-10-18. Placid docs and pricing page checked on that date. Adapters tested against a local stub with Node 22.18.0, Python 3.15.0 and Ruby 4.0.7; not run against live Placid or RenderScreenshot.

Frequently asked questions

Can I export my Placid templates to HTML?
No. A Placid template is a design in Placid's editor, identified by a template UUID. You rebuild it once as an HTML file. Each layer name becomes a placeholder in the file, so the layers object your code already sends can stay the same.
What replaces the Placid URL API in og:image tags?
A signed GET URL. Your server signs a URL that points at a card page on your own site, with preset=og_card, and puts it in the tag. The first fetch renders the card. Signed URLs must expire within 30 days, so generate them when the page is rendered, not once at publish time.
Placid rolls unused credits over. Does RenderScreenshot?
No. Credits reset each billing period. Placid keeps unused credits up to twice the monthly amount, according to its pricing page. If your volume is very uneven from month to month, take that into account.
Is there a test mode with free watermarked previews?
No. Every successful render costs one credit, and a render that fails costs nothing. For template work, render locally in Chromium (or Puppeteer) while you design, and call the API when the template is done.
Can I keep Placid for some templates and move others?
Yes. The rollout wrapper on this page picks a provider per card. Move the templates your developers own first, and keep the ones your marketing team edits in Placid's editor.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.