Every course platform, bootcamp, and online community eventually needs the same thing: certificates. And most developers reach for one of these solutions:
- A design tool (Canva, Figma) — manual, doesn't scale
- PDF generation libraries (PDFKit, pdfmake) — painful layout control
- Canvas API — powerful but verbose and hard to style
- A third-party certificate SaaS — expensive and locked-in
There's a simpler path. Certificates are just styled documents. HTML and CSS are the best layout tools ever invented. So let's use them.
In this post we'll build a certificate generator that takes a name and course title, renders a pixel-perfect certificate image, and returns it as a downloadable PNG — in under 100 lines of Node.js.
What We're Building
A Node.js endpoint that accepts a name and course title, injects them into an HTML certificate template, renders it to a 1600x1130 PNG, and returns the image for download or storage.
POST /certificates/generate
{ "name": "Jane Doe", "course": "Advanced TypeScript" }
→ returns certificate.png
Step 1: Design the Certificate Template in HTML
Here's the key insight: stop thinking about certificates as "documents" and start thinking about them as "web pages at a fixed size."
Your entire design lives in one HTML string. You have full access to Flexbox, Grid, custom fonts, gradients, borders, shadows — everything CSS offers.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Source+Sans+3:wght@300;400;600&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1600px;
height: 1130px;
background: #fdfaf5;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Source Sans 3', sans-serif;
}
.certificate {
width: 1480px;
height: 1010px;
border: 3px solid #c9a84c;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px;
text-align: center;
}
.certificate::before {
content: '';
position: absolute;
inset: 12px;
border: 1px solid #c9a84c;
opacity: 0.5;
pointer-events: none;
}
.corner {
position: absolute;
width: 60px;
height: 60px;
border-color: #c9a84c;
border-style: solid;
opacity: 0.8;
}
.corner.tl { top: 24px; left: 24px; border-width: 3px 0 0 3px; }
.corner.tr { top: 24px; right: 24px; border-width: 3px 3px 0 0; }
.corner.bl { bottom: 24px; left: 24px; border-width: 0 0 3px 3px; }
.corner.br { bottom: 24px; right: 24px; border-width: 0 3px 3px 0; }
.header-label {
font-family: 'Source Sans 3', sans-serif;
font-weight: 300;
font-size: 16px;
letter-spacing: 6px;
text-transform: uppercase;
color: #c9a84c;
margin-bottom: 16px;
}
.title {
font-family: 'Playfair Display', serif;
font-size: 72px;
font-weight: 700;
color: #1a1a2e;
line-height: 1;
margin-bottom: 48px;
}
.presented-to {
font-size: 18px;
color: #888;
letter-spacing: 3px;
text-transform: uppercase;
margin-bottom: 20px;
}
.recipient-name {
font-family: 'Playfair Display', serif;
font-size: 64px;
color: #1a1a2e;
font-weight: 400;
font-style: italic;
margin-bottom: 40px;
line-height: 1.1;
}
.divider {
width: 120px;
height: 2px;
background: linear-gradient(90deg, transparent, #c9a84c, transparent);
margin: 0 auto 40px;
}
.for-completing {
font-size: 18px;
color: #888;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 16px;
}
.course-name {
font-family: 'Playfair Display', serif;
font-size: 36px;
color: #1a1a2e;
font-weight: 700;
margin-bottom: 56px;
max-width: 800px;
}
.footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
width: 100%;
padding: 0 80px;
}
.signature-block {
text-align: center;
}
.signature-line {
width: 200px;
height: 1px;
background: #1a1a2e;
margin-bottom: 8px;
}
.signature-label {
font-size: 13px;
letter-spacing: 2px;
text-transform: uppercase;
color: #888;
}
.date-block {
text-align: center;
}
.date-value {
font-family: 'Playfair Display', serif;
font-size: 20px;
color: #1a1a2e;
margin-bottom: 8px;
}
.date-label {
font-size: 13px;
letter-spacing: 2px;
text-transform: uppercase;
color: #888;
}
</style>
</head>
<body>
<div class="certificate">
<div class="corner tl"></div>
<div class="corner tr"></div>
<div class="corner bl"></div>
<div class="corner br"></div>
<div class="header-label">Certificate of Completion</div>
<div class="title">Achievement</div>
<div class="presented-to">This certifies that</div>
<div class="recipient-name">{{NAME}}</div>
<div class="divider"></div>
<div class="for-completing">has successfully completed</div>
<div class="course-name">{{COURSE}}</div>
<div class="footer">
<div class="signature-block">
<div class="signature-line"></div>
<div class="signature-label">Instructor Signature</div>
</div>
<div class="date-block">
<div class="date-value">{{DATE}}</div>
<div class="date-label">Date of Completion</div>
</div>
</div>
</div>
</body>
</html>
Notice the {{NAME}}, {{COURSE}}, and {{DATE}} placeholders. We'll replace these with real data in Node.js before rendering.
Step 2: The Template Function
// certificate-template.js
export function buildCertificateHTML({ name, course, date }) {
const template = `...` // paste the HTML above here
return template
.replace('{{NAME}}', escapeHtml(name))
.replace('{{COURSE}}', escapeHtml(course))
.replace('{{DATE}}', date)
}
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
Always escape user input before injecting into HTML. Never skip this step.
Step 3: Render the HTML to an Image
Now we need to take that HTML string and get back a PNG. We'll send it to an HTML-to-image API — this gives us full CSS support (including web fonts) without managing a headless browser ourselves.
// render-certificate.js
export async function renderCertificate(html) {
const response = 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: 1600,
height: 1130,
format: 'png',
scale: 2
})
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Render failed: ${error}`)
}
return Buffer.from(await response.arrayBuffer())
}
We're using scale: 2 for retina quality — at 1x the text can look soft, especially at large font sizes. Retina output at 3200x2260 effective pixels looks sharp even when printed.
Step 4: Wire It Into Your API
// routes/certificates.js (Fastify example)
import { buildCertificateHTML } from './certificate-template.js'
import { renderCertificate } from './render-certificate.js'
import { formatDate } from './utils.js'
app.post('/certificates/generate', async (req, reply) => {
const { name, course } = req.body
if (!name || !course) {
return reply.status(400).send({ error: 'name and course are required' })
}
const html = buildCertificateHTML({
name,
course,
date: formatDate(new Date())
})
const imageBuffer = await renderCertificate(html)
return reply
.header('Content-Type', 'image/png')
.header('Content-Disposition', `attachment; filename="certificate-${Date.now()}.png"`)
.send(imageBuffer)
})
// utils.js
export function formatDate(date) {
return new Intl.DateTimeFormat('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
}).format(date)
}
Step 5: Test It
curl -X POST http://localhost:3000/certificates/generate \
-H "Content-Type: application/json" \
-d '{"name": "Jane Doe", "course": "Advanced TypeScript"}' \
--output certificate.png
Open certificate.png. That's your certificate.
Going Further
Store certificates in S3 or R2
Instead of returning the buffer directly, upload it to object storage and return a URL. This way certificates are permanent and shareable.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
const s3 = new S3Client({ region: 'us-east-1' })
async function storeCertificate(buffer, certificateId) {
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: `certificates/${certificateId}.png`,
Body: buffer,
ContentType: 'image/png',
ACL: 'public-read'
}))
return `https://${process.env.S3_BUCKET}.s3.amazonaws.com/certificates/${certificateId}.png`
}
Add a verification QR code
Generate a unique certificate ID, store it in your database, and embed a QR code linking to a verification page. Employers can scan it to confirm authenticity.
Batch generation
If you need to issue certificates to an entire cohort at once, send requests concurrently with Promise.all and a concurrency limiter like p-limit.
import pLimit from 'p-limit'
const limit = pLimit(5) // max 5 concurrent renders
const certificates = await Promise.all(
students.map(student =>
limit(() => generateAndStore(student))
)
)
Multiple templates
The template function takes a plain object. Add a template parameter and switch between designs — dark theme, landscape orientation, branded variants — without changing any of your rendering logic.
Why Not Just Use a PDF Library?
PDF generation libraries like PDFKit or pdfmake give you programmatic layout control, but you're working against their API instead of with CSS. Want a gradient background? Custom. Flexbox centering? Custom. Web fonts? Depends on the library.
HTML and CSS are the most battle-tested layout system in history. Every developer already knows them. Your designer can prototype in a browser and hand you the CSS directly. The template is inspectable, editable, and version-controllable as plain text.
The tradeoff is that you need something to render that HTML. A headless browser handles it perfectly — you just don't want to run one yourself if you don't have to.
Summary
- Design your certificate in HTML and CSS — you have full control over every pixel
- Use template literals with escaped placeholders for dynamic data
- Render via an HTML-to-image API to get a PNG back — no headless browser to manage
- Use
scale: 2for retina-quality output - Store in S3/R2 and return a permanent URL for production use
The free tier at renderpix.dev gives you 100 renders/month — enough to build and test your entire certificate pipeline without spending anything.
Questions about the implementation? Drop them in the comments.
Top comments (0)