Development of a Certificate System (PDF Generation) for LMS
A course completion certificate is a tangible achievement for students. Technically, it involves generating PDFs from templates with personal data, unique identification numbers, and QR codes for verification. While seemingly simple, the task has nuances: PDF quality, template customization, scaling during peak loads (semester end—thousands of certificates simultaneously).
Architecture
Student completes course → Grade >= passing_score?
↓ YES
Job queue (BullMQ/Celery)
↓
Certificate generator worker
├── Fetch student data + course data
├── Render HTML template
├── Generate PDF (Puppeteer/WeasyPrint)
├── Upload to S3
├── Save certificate record (DB)
└── Send email with PDF link
Generation is asynchronous—students receive a notification when the certificate is ready, rather than waiting for a synchronous response.
PDF Generation: Tools
Puppeteer / Playwright—renders HTML to PDF via Chromium. The best option for complex designs with CSS, fonts, gradients:
const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
const page = await browser.newPage();
const html = await renderCertificateHtml({
studentName: 'Ivan Petrov',
courseName: 'React Professional',
completionDate: '28 March 2026',
certificateId: 'CERT-2026-001234',
instructorName: 'Alexey Smirnov',
qrCodeDataUrl: await generateQrCode(`https://example.com/verify/CERT-2026-001234`),
});
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'A4',
landscape: true,
printBackground: true,
margin: { top: '0', right: '0', bottom: '0', left: '0' },
});
await browser.close();
WeasyPrint—Python library converting HTML/CSS to PDF. Faster than Puppeteer but supports complex CSS less well.
PDFKit (Node.js)—generates PDFs programmatically without HTML. Precise positioning control, but templates are harder to maintain.
Certificate Templates
HTML templates with Handlebars or Jinja2 stored in the database—instructors or administrators can change designs without deployment:
<!-- certificate-template.hbs -->
<!DOCTYPE html>
<html>
<head>
<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Open+Sans&display=swap');
body { margin: 0; width: 297mm; height: 210mm; font-family: 'Open Sans'; }
.container { position: relative; width: 100%; height: 100%; }
.background { position: absolute; width: 100%; height: 100%; }
.content { position: relative; z-index: 1; padding: 40mm 30mm; text-align: center; }
.student-name { font-family: 'Playfair Display'; font-size: 36pt; color: #1a1a2e; }
.course-name { font-size: 18pt; color: #16213e; margin: 8mm 0; }
.cert-id { font-size: 8pt; color: #666; position: absolute; bottom: 10mm; left: 15mm; }
.qr-code { position: absolute; bottom: 10mm; right: 15mm; width: 25mm; }
</style>
</head>
<body>
<div class="container">
<img class="background" src="{{backgroundUrl}}" />
<div class="content">
<p>This is to certify that</p>
<div class="student-name">{{studentName}}</div>
<p>has successfully completed the course</p>
<div class="course-name">{{courseName}}</div>
<p>{{completionDate}} · {{totalHours}} hours</p>
</div>
<div class="cert-id">ID: {{certificateId}}</div>
<img class="qr-code" src="{{qrCodeDataUrl}}" />
</div>
</body>
</html>
Data Model
CREATE TABLE certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
certificate_id VARCHAR(50) UNIQUE NOT NULL, -- CERT-2026-001234
student_id UUID REFERENCES users(id),
course_id UUID REFERENCES courses(id),
template_id UUID REFERENCES certificate_templates(id),
pdf_url VARCHAR(2000),
issued_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ, -- NULL if active
revoke_reason TEXT,
metadata JSONB DEFAULT '{}' -- score, hours, instructor
);
QR Code Verification
A public verification page without authorization: GET /verify/{certificate_id}—displays certificate data and confirms authenticity.
app.get('/verify/:certId', async (req, res) => {
const cert = await db.certificates.findOne({ certificateId: req.params.certId });
if (!cert || cert.revokedAt) {
return res.status(404).render('certificate-invalid');
}
res.render('certificate-valid', {
studentName: cert.student.name,
courseName: cert.course.name,
issuedAt: cert.issuedAt,
});
});
Scaling
Under peak load (semester end, 1000+ certificates), launch multiple workers in parallel. Puppeteer is memory-intensive (~200MB per browser instance)—use a pool of 3–5 browsers via puppeteer-cluster.
Timeline
Basic system with a single template, PDF generation via Puppeteer, S3 storage, and email delivery—4–5 days. Visual template editor for administrators and public verification—another 3–4 days.







