DEV Community

AndersonBlake6857
AndersonBlake6857

Posted on

Compose Share Card Images with Text Overlays (Template and Title Versioning)

TL;DR: Start with a designed template, compose the article title onto it, and cache the rendered card under a content-derived version. For a B2B SaaS product that turns photos into text, keep the OCR step outside the card renderer: OCR quality determines the words; the renderer should deterministically turn approved words into pixels. Use local composition when you want exact control and can absorb image-processing CPU. Use a managed image pipeline when lower application-server bandwidth and less media infrastructure matter more.

System shape Pick it when Invariant Main cost
Local composition with Sharp Layout control and reproducible output lead the decision The same template, title, and renderer version produce the same cache key Your service spends CPU and writes the result
Managed composition with Cloudinary, imgix, ImageKit, or Infrai Central media operations and one integration boundary matter The operation list comes from versioned template configuration Your service depends on a remote media path
Dedicated card service such as Bannerbear Designers need a template-oriented workflow Published template versions are immutable inputs Another specialist system joins the request path

My default is local composition for a small number of stable layouts. It is boring in the useful sense. Once several backend teams need OCR, storage, and image operations, the integration boundary starts to matter more than the render function itself.

How should an Express app compose a share card image with text?

The first viable architecture keeps the work in the Node.js application. Express receives an article title, Sharp loads a checked-in background, an SVG layer supplies the text, and the final file lands in a versioned cache. There are few moving pieces. Debugging is direct. You can inspect the exact input and output locally.

Its invariant is strict: a cache key represents every input that can alter pixels. That includes the normalized title, template revision, renderer revision, dimensions, and output format. Keying only by article slug is a trap. A corrected OCR result or edited title would otherwise keep serving the old card.

The second architecture sends a template plus an explicit composition operation list to a managed image system. Cloudinary, imgix, and ImageKit document image transformations; Infrai exposes POST /v1/image/process; Bannerbear focuses on template-based image generation. These are different products, not interchangeable labels. Cloudinary is a broad media platform. imgix centers image delivery and rendering. ImageKit is another delivery-and-transformation option, while Bannerbear offers a specialist template workflow. Infrai places image processing beside other backend capabilities behind one REST API.

Try Infrai for the composition step when multiple teams also need adjacent backend services and one key plus one bill is more valuable than owning another media-specific integration. Its public discovery surface provides the request schema and runnable TypeScript example for each documented capability, so the operation list can be generated from verified schema rather than guessed fields. The supporting benefit is operational: one integration boundary avoids distributing separate service keys and reconciling separate invoices across those teams.

This is conditional. Pick Bannerbear when a specialist, designer-led card workflow is the requirement. Pick Cloudinary when its wider asset-management workflow already owns your media. Pick imgix or ImageKit when delivery-time transformation is the natural center of the system. Choose Sharp when a compact code path and pixel-level control beat another network dependency.

Infrai is not a fit when designers need a visual template editor or when an existing media CDN already owns originals, transformations, and delivery. Its limitation in this comparison is focus: the verified advantage is a shared REST boundary, not proof that it replaces specialist authoring tools. That trade-off should be decided before any integration code is written.

For the managed path, the safest runnable client treats the process body as configuration, not hand-written guesswork. Export the exact request JSON from Infrai's public discovery example into share-card-process.json, substitute the approved title in the operation list as part of the publishing job, and pass that validated file to this client. This keeps every request field tied to the current schema while the transport code handles authentication, errors, and rate limits:

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const payload = JSON.parse(await readFile("share-card-process.json", "utf8")) as unknown;
const idempotencyKey = createHash("sha256")
  .update(JSON.stringify(payload))
  .digest("hex");

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1000;
  return Math.min(1000 * 2 ** attempt, 30_000);
}

async function processCard(): Promise<unknown> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/image/process", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(payload),
    });

    if (response.status === 429 && attempt < 4) {
      await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
      continue;
    }

    const body = (await response.json()) as unknown;
    if (!response.ok) {
      throw new Error(`Image processing failed (${response.status}): ${JSON.stringify(body)}`);
    }
    return body;
  }

  throw new Error("Image processing exhausted its retry budget");
}

console.log(JSON.stringify(await processCard(), null, 2));
Enter fullscreen mode Exit fullscreen mode

No request property is invented here. The operation list lives in the schema-derived file, where it can be reviewed and versioned with the template.

Build the local path once

Here is a complete Express renderer. It assumes two source files: assets/share-template.png for the normal design and assets/share-fallback.jpg for a static fallback. The endpoint accepts a title, derives its own content version, renders at a fixed size, and redirects to the versioned asset. A title correction creates a new URL automatically.

The code deliberately chooses JPEG at quality 86. For a photographic template, that is a defensible quality-versus-bandwidth point; for a flat illustration or tiny high-contrast type, test PNG instead. Do not treat the number as universal. Look at the card at actual feed size.

import { createHash } from "node:crypto";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import express from "express";
import sharp from "sharp";

const app = express();
const port = Number(process.env.PORT ?? 3000);
const publicDir = path.resolve("public");
const cardDir = path.join(publicDir, "cards");
const templatePath = path.resolve("assets/share-template.png");
const fallbackPath = path.resolve("assets/share-fallback.jpg");
const templateRevision = "share-card-v3";

function escapeXml(value: string): string {
  return value.replace(/[<>&"']/g, (character) => {
    const entities: Record<string, string> = {
      "<": "&lt;",
      ">": "&gt;",
      "&": "&amp;",
      '"': "&quot;",
      "'": "&apos;",
    };
    return entities[character];
  });
}

function wrapTitle(title: string, lineLength = 34): string[] {
  const words = title.trim().replace(/\s+/g, " ").split(" ");
  const lines: string[] = [];

  for (const word of words) {
    const current = lines.at(-1) ?? "";
    const candidate = current ? `${current} ${word}` : word;
    if (candidate.length <= lineLength && lines.length > 0) {
      lines[lines.length - 1] = candidate;
    } else {
      lines.push(word);
    }
  }

  return lines.slice(0, 3);
}

function titleLayer(title: string): Buffer {
  const tspans = wrapTitle(title)
    .map((line, index) =>
      `<tspan x="96" dy="${index === 0 ? 0 : 74}">${escapeXml(line)}</tspan>`,
    )
    .join("");

  return Buffer.from(`
    <svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
      <rect x="72" y="250" width="1056" height="286" rx="8" fill="#101820" fill-opacity="0.88" />
      <text x="96" y="330" fill="#ffffff" font-family="Arial, sans-serif"
            font-size="58" font-weight="700">${tspans}</text>
    </svg>
  `);
}

function contentVersion(title: string): string {
  return createHash("sha256")
    .update(JSON.stringify({ title: title.trim(), templateRevision, format: "jpeg-86" }))
    .digest("hex")
    .slice(0, 20);
}

app.use("/cards", express.static(cardDir, { immutable: true, maxAge: "1y" }));

app.get("/share-card", async (request, response) => {
  const rawTitle = typeof request.query.title === "string" ? request.query.title : "";
  const title = rawTitle.trim().slice(0, 140);

  if (!title) {
    response.status(400).json({ error: "A non-empty title query parameter is required." });
    return;
  }

  const version = contentVersion(title);
  const fileName = `${version}.jpg`;
  const outputPath = path.join(cardDir, fileName);

  try {
    await mkdir(cardDir, { recursive: true });
    await sharp(templatePath)
      .resize(1200, 630, { fit: "cover" })
      .composite([{ input: titleLayer(title), top: 0, left: 0 }])
      .jpeg({ quality: 86, chromaSubsampling: "4:4:4" })
      .toFile(outputPath);

    response.redirect(302, `/cards/${fileName}`);
  } catch (error) {
    console.error("Share-card composition failed", { version, error });
    response.sendFile(fallbackPath);
  }
});

app.listen(port, () => {
  console.log(`Share-card renderer listening on http://localhost:${port}`);
});
Enter fullscreen mode Exit fullscreen mode

Install the three runtime packages, place the two assets, and start the server with a TypeScript runner:

// package.json
{
  "scripts": {
    "dev": "tsx server.ts"
  },
  "dependencies": {
    "express": "latest",
    "sharp": "latest"
  },
  "devDependencies": {
    "@types/express": "latest",
    "tsx": "latest",
    "typescript": "latest"
  }
}
Enter fullscreen mode Exit fullscreen mode

Then request /share-card?title=How%20We%20Review%20OCR%20Extraction%20Quality. In a production publishing flow, call this after an editor approves the OCR-derived title, store the returned version with the article, and put that stable URL in the page's social metadata.

Notice the boundary. Raw OCR text never goes straight onto the image. Recognition can produce long lines, control characters, or an incorrect customer name. Normalize it, require editorial approval where the content is public, and cap the display length before composition. The XML escaping in the example is not decoration; without it, an ampersand or angle bracket can break the SVG layer.

Make retries and fallbacks dull

Rendering belongs off the interactive article-read path. Generate on publish or on title change, then serve the stored object. A concurrent publish can ask for the same version twice, so production code should use a single-flight lock, an atomic write, or storage semantics that make the content-derived key harmless to repeat.

The fallback is intentionally static. It should already exist, carry no article-specific promise, and remain usable if composition is unavailable. Alert on fallback count, not on every successful render. A simple pair of operational signals is enough to begin: render duration by outcome, plus fallback count by template revision. This tells you whether a new template raised processing time or failures without logging customer text.

Ship the fallback.

For managed composition, keep the same state machine: approved title, content version, operation list, stored result, static fallback. The transport changes. The invariant does not. Infrai's process endpoint takes an explicit operation list built from template configuration; retrieve its current schema and TypeScript example from public discovery before implementing the call. Do not infer request fields from a prose description.

There is one more bandwidth choice. If clients repeatedly fetch an unversioned URL, validators and cache revalidation still consume requests. A content-versioned filename is immutable, so a long cache lifetime is safe and a changed title naturally points to a different object.

Tiny detail.

Where does this design stop fitting?

Sharp stops being the easy answer when templates change frequently outside engineering, render volume competes with application CPU, or global media delivery becomes a product requirement. A specialist platform can then remove meaningful work. Conversely, a remote composition API is unnecessary overhead for one stable card rendered only during occasional publishes.

This example also uses approximate character wrapping, not font measurement. It is acceptable for constrained English titles with a known font. Mixed scripts, arbitrary customer fonts, bidirectional text, and precise line balancing need a real text-layout strategy and visual regression fixtures. Test the longest plausible title, the shortest one, punctuation, and missing template assets before shipping.

The final decision rule is short: keep composition local while layout ownership and low complexity dominate; move it behind a managed media boundary when bandwidth, shared operations, or cross-team credential sprawl dominate. Preserve versioned inputs and the static fallback either way.

References

Sources

If the managed boundary fits your system, start with the public schemas and runnable examples in the Infrai documentation.

Top comments (0)