ScreenshotNeo

BlogHow-to

How to Build a Certificate Maker with Claude Code

Build a certificate maker with Claude Code, Express, and Bannerbear. Add a live preview, secure secrets, downloads, troubleshooting, and scaling guidance.

By the ScreenshotNeo team29 September 202610 min read

How to Build a Certificate Maker with Claude Code

A certificate maker is a small but useful application: a person enters a name, course, and date; the server renders those values into a branded template; the page shows a preview and offers a download. Claude Code can create the project structure and much of the implementation from a precise specification. This guide builds the complete flow with Node.js, Express, a single HTML page, and Bannerbear.

What you will build

The finished app has three parts:

  • An HTML form for recipient name, course title, and issue date.
  • An Express POST /api/certificates route that calls Bannerbear’s synchronous image-generation API.
  • A live result area that displays the returned image URL and provides a download link.

The browser never receives your Bannerbear credentials. It sends certificate data to your server, and your server talks to Bannerbear. This boundary is essential because API keys embedded in browser JavaScript can be copied by anyone who loads the page.

Prerequisites

  • Node.js 18 or later.
  • A Bannerbear account, project API key, and template UID.
  • Claude Code, installed through its native installer or a supported package manager.
  • Access to Claude Code through a paid Claude plan or a supported Console or third-party provider account.

Create a new directory and start Claude Code from that directory:

The certificate maker flow: form submission, server-side rendering request, and image preview.
The certificate maker flow: form submission, server-side rendering request, and image preview.
mkdir certificate-maker
cd certificate-maker
claude

Claude Code’s current installation documentation lists native installation plus Homebrew, WinGet, apt, dnf, and apk options. If the claude command is not found, finish installation and confirm that the installer added it to your PATH before debugging application code.

Create the Bannerbear template

In Bannerbear, create a project and a certificate template. Add text layers with these exact names:

Layer name Value supplied by the app Typical content
recipient_name Recipient’s name Jordan Lee
course_title Course or achievement Advanced Data Analysis
issue_date Date displayed on the certificate 2026-09-29

Keep the logo, border, signature, seal, and other fixed decoration in the template. Copy the project API key and template UID from the project settings. Layer names are case-sensitive; a mismatch means the request can succeed while a value remains unchanged.

Give Claude Code a complete specification

A detailed prompt produces a more coherent first pass than a sequence of vague requests. Paste this into Claude Code:

Create a Node.js Express app with a single HTML page. The page must contain a form with recipient name, course title, and issue date fields. On submit, the frontend must POST JSON to /api/certificates. The Express endpoint must call the Bannerbear synchronous API with a template UID and dynamic text layers named recipient_name, course_title, and issue_date, then return the generated image URL as JSON. The page must display the image below the form and provide a download button. Read the Bannerbear API key and template UID from a .env file. Add validation, useful error responses, loading and error states, and a .gitignore entry for .env. Keep the Bannerbear request server-side.

Review every file Claude creates. Ask it to explain the request payload and response handling before you add styling or extra features.

Project setup

Install the server dependencies:

npm init -y
npm install express dotenv
npm install --save-dev nodemon

Add scripts to package.json:

{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  }
}

Create .env and keep it local:

BANNERBEAR_API_KEY=replace_with_project_key
BANNERBEAR_TEMPLATE_UID=replace_with_template_uid
PORT=3000

Add this to .gitignore:

node_modules/
.env

Implement the Express endpoint

The following server validates the three fields, maps them to Bannerbear’s dynamic modifications, and returns the image URL. The exact Bannerbear endpoint and authorization format should follow the API documentation for your account; the route below shows the application structure and request handling.

import express from "express";
import dotenv from "dotenv";

// Set "type": "module" in package.json for this import syntax.
dotenv.config();
const app = express();
const port = process.env.PORT || 3000;

app.use(express.json());
app.use(express.static("public"));

app.post("/api/certificates", async (req, res) => {
  const { recipientName, courseTitle, issueDate } = req.body || {};
  if (![recipientName, courseTitle, issueDate].every(value => typeof value === "string" && value.trim())) {
    return res.status(400).json({ error: "recipientName, courseTitle, and issueDate are required" });
  }
  if (!process.env.BANNERBEAR_API_KEY || !process.env.BANNERBEAR_TEMPLATE_UID) {
    return res.status(500).json({ error: "Bannerbear configuration is missing" });
  }

  const payload = {
    template: process.env.BANNERBEAR_TEMPLATE_UID,
    modifications: [
      { name: "recipient_name", text: recipientName.trim() },
      { name: "course_title", text: courseTitle.trim() },
      { name: "issue_date", text: issueDate.trim() }
    ]
  };

  try {
    const response = await fetch("https://sync.api.bannerbear.com/v2/images", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${process.env.BANNERBEAR_API_KEY}`
      },
      body: JSON.stringify(payload)
    });
    const data = await response.json();
    if (!response.ok) {
      return res.status(response.status).json({ error: data?.message || "Certificate rendering failed" });
    }
    if (!data.image_url) {
      return res.status(502).json({ error: "Renderer returned no image URL" });
    }
    res.json({ imageUrl: data.image_url });
  } catch (error) {
    console.error("Bannerbear request failed", error);
    res.status(502).json({ error: "Unable to reach certificate renderer" });
  }
});

app.listen(port, () => console.log(`Certificate maker listening on http://localhost:${port}`));

If your Node configuration does not use ES modules, ask Claude Code to convert the file to CommonJS with require. Do not mix both module systems casually.

Build the live preview page

Create public/index.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Certificate maker</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; }
    label { display: block; margin: 1rem 0 .35rem; font-weight: 600; }
    input, button { font: inherit; padding: .65rem; width: 100%; box-sizing: border-box; }
    button { margin-top: 1rem; cursor: pointer; }
    #result { margin-top: 2rem; } #preview { max-width: 100%; display: block; }
    #error { color: #a00; min-height: 1.5rem; }
  </style>
</head>
<body>
  <h1>Create a certificate</h1>
  <form id="certificate-form">
    <label for="recipientName">Recipient name</label>
    <input id="recipientName" name="recipientName" required maxlength="120">
    <label for="courseTitle">Course title</label>
    <input id="courseTitle" name="courseTitle" required maxlength="160">
    <label for="issueDate">Issue date</label>
    <input id="issueDate" name="issueDate" type="date" required>
    <button id="submit" type="submit">Generate certificate</button>
  </form>
  <p id="error" role="alert"></p>
  <section id="result" hidden>
    <img id="preview" alt="Generated certificate preview">
    <a id="download" download="certificate.png">Download certificate</a>
  </section>
  <script>
    const form = document.querySelector("#certificate-form");
    const button = document.querySelector("#submit");
    const error = document.querySelector("#error");
    const result = document.querySelector("#result");
    const preview = document.querySelector("#preview");
    const download = document.querySelector("#download");
    form.addEventListener("submit", async (event) => {
      event.preventDefault(); error.textContent = ""; result.hidden = true;
      button.disabled = true; button.textContent = "Generating…";
      const body = Object.fromEntries(new FormData(form));
      try {
        const response = await fetch("/api/certificates", {
          method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body)
        });
        const data = await response.json();
        if (!response.ok) throw new Error(data.error || "Generation failed");
        preview.src = data.imageUrl; download.href = data.imageUrl; result.hidden = false;
      } catch (e) { error.textContent = e.message; }
      finally { button.disabled = false; button.textContent = "Generate certificate"; }
    });
  </script>
</body>
</html>

Set "type": "module" in package.json if you used the server above, then run npm run dev and open http://localhost:3000.

Validation, accessibility, and download details

  • Use HTML required and maxlength attributes, then validate again on the server.
  • Normalize whitespace, but do not silently alter names or dates in ways users cannot see.
  • Use an explicit image alt description and a visible error region.
  • Keep the download filename stable and include a server-generated identifier if several certificates may be created in one session.
  • Do not trust a client-supplied image URL for privileged operations. If you later proxy files through your server, allow only URLs returned by the renderer.

Design iteration with Claude

Claude’s design and artifact tools can help explore typography, spacing, colors, and the form layout before you commit to CSS. Review the artifact, iterate on the visual direction, export the result, and bring it into Claude Code with /design or /design-sync. Keep the certificate template itself in Bannerbear so a design change can be applied consistently to future certificates.

Options for a production certificate maker

Concern Practical choice
Output Use the generated image URL for a simple download; add a PDF renderer when recipients need print-ready documents.
Bulk issuance Read rows from a trusted data source, validate every row, and record the returned URL and request status.
Authentication Require a signed-in operator or an invitation link before allowing certificate generation.
Abuse control Add rate limits and a maximum text length before calling the rendering API.
Audit trail Store recipient, template version, timestamp, and renderer response ID without storing secrets in logs.
Template changes Version templates instead of changing a live template while a batch is running.
A clean capture removes overlays before producing the final certificate image.
A clean capture removes overlays before producing the final certificate image.

Performance, reliability, and cost

The synchronous request keeps the example easy to understand, but each generation depends on a network call and the renderer’s response time. Disable the submit button during a request, set a client-side waiting state, and enforce a server timeout in production. For large batches, use a queue, retry only transient failures with backoff, and make jobs idempotent so a retry does not create an unintended duplicate.

Measure your own generation time, error rate, and batch throughput. The referenced tutorial does not publish a performance benchmark or guaranteed throughput figure. Keep rendered URLs and request metadata so you can investigate failures without replaying every request. Your main variable cost is the rendering service plan and any hosting, storage, email, or authentication services you add; calculate cost per certificate from your actual volume rather than assuming a fixed rate.

Troubleshooting

claude: command not found

Claude Code is not installed or is missing from PATH. Complete the official installation for your operating system, restart the terminal, and run claude from the project directory.

401 or 403 from Bannerbear

Check that the project API key is current, belongs to the intended project, and is loaded from .env. Restart the Node process after changing environment variables. Never paste the key into frontend code.

The request succeeds but text is missing

Compare the payload names with the template layer names exactly: recipient_name, course_title, and issue_date. Confirm that each layer is editable text rather than fixed artwork.

The browser reports a CORS error

Serve the HTML from the same Express origin as the API during development. If you deploy separate origins, configure CORS deliberately and restrict allowed origins; do not use a wildcard for an authenticated operator interface.

Inspect the JSON response and verify that imageUrl is present. A successful HTTP status without an image URL should be treated as an upstream failure. Check browser network logs and the renderer response body.

Users submit duplicate certificates

Disable the button while waiting, assign an idempotency key to each intentional submission, and store completed requests. A timeout does not prove that the upstream job failed.

Or skip the browser setup

If you already have a certificate page or template and only need a clean image or PDF capture, ScreenshotNeo provides a single GET request. Its API accepts a URL and returns PNG, JPEG, WebP, or PDF; see the ScreenshotNeo documentation for all options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. An MCP server gives Claude, Cursor, and other MCP clients take_screenshot, get_page_info, and capture_pdf tools. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Sign up for the free ScreenshotNeo plan.

FAQ

Can Claude Code generate the certificate artwork itself?

It can generate the application code and help refine a design. The reference implementation delegates final image rendering to a Bannerbear template.

Should the date be generated on the server?

Use a server-side date when the issue date must be authoritative. Accept a form date only when an authorized operator is allowed to choose it.

Can I replace Bannerbear with an in-house renderer?

Yes, but you must implement text measurement, font loading, image composition, output storage, and failure handling yourself. Compare setup time, layout control, output formats, secret handling, hosting cost, batch throughput, and portability.

How do I add a logo per organization?

Keep the logo as a controlled template input or select among server-side approved assets. Validate the organization before passing an asset reference to the renderer.

Can I email certificates automatically?

After generation, enqueue an email job that uses the returned image URL. Record delivery status separately from rendering status so a mail failure does not cause an unnecessary second certificate.