Certificates are one of those features that look trivial on paper and take days in practice. You need personalized text on a consistent design at high resolution, deliverable as a downloadable image, ideally without involving Canva, Photoshop, or a third-party service that charges per certificate. The fastest path is one you probably already know: HTML and CSS.
Design your certificate in HTML exactly as you would design a web page. Then use a render API to convert that HTML to a pixel-perfect PNG on demand. This tutorial walks through the full pipeline — from template design to a working API endpoint your users can hit to download their certificate.
A certificate is a fixed-size image with a known layout. Use position: absolute for precise placement, set the body to the exact pixel dimensions you want, and use overflow: hidden to prevent anything from spilling outside the frame. Here is a minimal but polished template:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@300;400&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1400px;
height: 990px;
overflow: hidden;
background: #fdfbf7;
font-family: 'Inter', sans-serif;
position: relative;
}
.border-frame {
position: absolute;
inset: 24px;
border: 2px solid #c9a84c;
border-radius: 4px;
}
.border-inner {
position: absolute;
inset: 32px;
border: 1px solid rgba(201,168,76,.4);
border-radius: 2px;
}
.content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px;
text-align: center;
gap: 20px;
}
.issuer {
font-size: 13px;
font-weight: 400;
letter-spacing: .18em;
text-transform: uppercase;
color: #8a7a5a;
}
.presents {
font-family: 'Playfair Display', serif;
font-style: italic;
font-size: 22px;
color: #a08040;
}
.recipient {
font-family: 'Playfair Display', serif;
font-size: 56px;
font-weight: 700;
color: #1a1a1a;
line-height: 1.1;
}
.divider {
width: 120px;
height: 1px;
background: #c9a84c;
}
.course {
font-size: 18px;
font-weight: 300;
color: #444;
max-width: 700px;
line-height: 1.5;
}
.date {
font-size: 13px;
color: #8a7a5a;
letter-spacing: .08em;
}
</style>
</head>
<body>
<div class="border-frame"></div>
<div class="border-inner"></div>
<div class="content">
<div class="issuer">Acme Academy</div>
<div class="presents">This certifies that</div>
<div class="recipient">Jane Smith</div>
<div class="divider"></div>
<div class="course">has successfully completed <strong>Advanced TypeScript Patterns</strong></div>
<div class="date">Awarded on February 20, 2026</div>
</div>
</body>
</html>
Open this in a browser first to verify the layout before involving the API. Getting pixel-perfect certificates right in HTML is much faster than iterating through an API call every time.
For print-quality output, use deviceScaleFactor: 2 in your render request. This doubles the pixel density — a 1400×990 template renders as a 2800×1980 image — giving crisp results when printed at A4 or Letter size.
Create a route that accepts name, course, and date as query parameters, builds the HTML, and returns the PNG. Here is a Next.js Route Handler, but the same pattern works in Express, Hono, or any Node.js HTTP framework:
import { NextRequest } from 'next/server'; const safe = (s: string) => s.replace(/[<>&"]/g, '').slice(0, 120); export async function GET(req: NextRequest) { const p = req.nextUrl.searchParams; const name = safe(p.get('name') ?? 'Recipient'); const course = safe(p.get('course') ?? 'Course'); const date = safe(p.get('date') ?? new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })); const html = buildCertificateHtml({ name, course, date }); const res = await fetch('https://renderpix.dev/v1/render', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': process.env.RENDERPIX_API_KEY!, }, body: JSON.stringify({ html, width: 1400, height: 990, format: 'png', deviceScaleFactor: 2, }), }); if (!res.ok) { return new Response('Render failed', { status: 500 }); } return new Response(await res.arrayBuffer(), { headers: { 'Content-Type': 'image/png', 'Content-Disposition': `attachment; filename="certificate-${name.replace(/\s+/g, '-').toLowerCase()}.png"`, 'Cache-Control': 'public, max-age=31536000, immutable', }, }); }
The Content-Disposition: attachment header triggers a browser download rather than opening the image inline. The long Cache-Control TTL is safe here because the certificate content is fully determined by the URL parameters — the same URL will always produce the same image.
Wire the endpoint to a download button. A plain anchor tag with the download attribute is all you need:
interface Props { name: string; course: string; completedAt: string; // ISO date string } export function CertificateDownload({ name, course, completedAt }: Props) { const date = new Date(completedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', }); const url = `/certificate?name=${encodeURIComponent(name)}&course=${encodeURIComponent(course)}&date=${encodeURIComponent(date)}`; return ( <a href={url} download> Download Certificate </a> ); }
The biggest practical challenge with certificate generation is typography overflow. A 14-character name like "Jane Smith" fits perfectly at 56 px. A 36-character name like "Alexandra Konstantinopoulou-Bergmann" does not. Two strategies work well:
font-size: clamp(36px, 4.5vw, 56px) on the recipient element. Chromium's layout engine applies the same responsive rules it would for a real web page.The first approach is simpler and handles most real-world names correctly. The second is worth adding only if you have very long names or multilingual content where character count is a poor proxy for rendered width.
For a full certificate generation API overview, see certificate generator API.
A free account includes 100 renders per month — enough to build and test your entire certificate pipeline.