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.
This article was originally published by DEV Community and written by Özgür S..
Read original article on DEV Community