Generate Certificates and Diplomas with HTML and CSS
Certificates are a great use case for HTML-to-PDF generation: they're visually designed documents, the content is dynamic (recipient name, course, date), and they need to look identical every time.
This guide covers how to build certificate templates in HTML and CSS, then render them to PDF via a headless Chromium API.
Why HTML/CSS for certificates
Certificate design tools (Canva, Adobe Illustrator) produce static templates. Every time you need a new recipient, someone manually edits the file. HTML templates let you inject data programmatically and generate hundreds of certificates in a loop.
The challenge with most PDF libraries is CSS support. Certificates use layout features like flexbox for centering, decorative borders, custom web fonts, and background images. Pure PHP libraries (FPDF, mPDF) and JavaScript libraries (jsPDF) either can't render these or require manual workarounds.
A headless Chromium API renders your HTML exactly as Chrome would, so any CSS that works in a browser works in the PDF.
Basic certificate template
Start with a landscape A4 page. Certificates are almost always wider than they are tall.
export function renderCertificate(data: {
recipientName: string
courseName: string
completionDate: string
instructorName: string
organizationName: string
}): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 297mm;
height: 210mm;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
font-family: Georgia, 'Times New Roman', serif;
}
.certificate {
width: 260mm;
height: 180mm;
border: 12px double #b8860b;
padding: 40px 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
position: relative;
}
.certificate::before {
content: '';
position: absolute;
inset: 8px;
border: 2px solid #b8860b;
pointer-events: none;
}
.org-name {
font-size: 13px;
letter-spacing: 4px;
text-transform: uppercase;
color: #b8860b;
margin-bottom: 20px;
}
.title {
font-size: 36px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: #555;
letter-spacing: 1px;
margin-bottom: 32px;
}
.presented-to {
font-size: 13px;
color: #888;
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 12px;
}
.recipient {
font-size: 42px;
font-style: italic;
color: #1a1a1a;
margin-bottom: 8px;
font-weight: 400;
}
.description {
font-size: 15px;
color: #555;
margin-bottom: 32px;
}
.course-name {
font-size: 22px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 40px;
}
.footer {
display: flex;
justify-content: space-between;
width: 100%;
gap: 40px;
}
.signature-block {
flex: 1;
text-align: center;
}
.signature-line {
border-top: 1px solid #333;
margin-bottom: 6px;
}
.signature-label {
font-size: 12px;
color: #888;
text-transform: uppercase;
letter-spacing: 1px;
}
.signature-name {
font-size: 13px;
color: #333;
}
</style>
</head>
<body>
<div class="certificate">
<div class="org-name">${data.organizationName}</div>
<div class="title">Certificate of Completion</div>
<div class="subtitle">This certificate is proudly presented to</div>
<div class="presented-to">Presented to</div>
<div class="recipient">${data.recipientName}</div>
<div class="description">for successfully completing</div>
<div class="course-name">${data.courseName}</div>
<div class="footer">
<div class="signature-block">
<div class="signature-line"></div>
<div class="signature-name">${data.instructorName}</div>
<div class="signature-label">Instructor</div>
</div>
<div class="signature-block">
<div class="signature-line"></div>
<div class="signature-name">${data.completionDate}</div>
<div class="signature-label">Date of Completion</div>
</div>
</div>
</div>
</body>
</html>`
}Generate the PDF with landscape orientation:
const response = await fetch('https://api.docapi.co/v1/pdf', {
method: 'POST',
headers: {
'x-api-key': process.env.DOCAPI_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html: renderCertificate({
recipientName: 'Jane Smith',
courseName: 'Advanced React Patterns',
completionDate: 'March 4, 2026',
instructorName: 'Alex Chen',
organizationName: 'Acme Academy',
}),
options: {
format: 'A4',
landscape: true,
printBackground: true,
margin: { top: '0', right: '0', bottom: '0', left: '0' },
},
}),
})
const pdfBuffer = await response.arrayBuffer()Set margins to '0' when the template controls its own padding. Otherwise the browser adds default margins outside your design.
Using custom web fonts
Certificates look more professional with custom typography. Load fonts from Google Fonts in the <head>:
<head>
<meta charset="utf-8">
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&display=swap"
rel="stylesheet"
>
<style>
body { font-family: 'Cormorant Garamond', Georgia, serif; }
.title { font-family: 'Cinzel', serif; }
.recipient { font-family: 'Cormorant Garamond', serif; font-style: italic; }
</style>
</head>Headless Chromium fetches external resources before rendering, so Google Fonts loads correctly.
Adding a logo or seal
Use a base64-encoded image to avoid external URL loading issues in restricted environments:
import { readFileSync } from 'fs'
const logoBase64 = readFileSync('./public/logo.png').toString('base64')
const logoSrc = `data:image/png;base64,${logoBase64}`
// In your template:
const logoHtml = `<img src="${logoSrc}" alt="Logo" style="width: 80px; height: 80px; margin-bottom: 16px;" />`Or if you're calling from a browser or serverless function where the logo is at a public URL, the Chromium renderer will fetch it:
<img src="https://yoursite.com/logo.png" alt="Logo" style="width: 80px;" />Tailwind version
For teams already using Tailwind, the CDN approach is faster to write:
export function renderCertificateTailwind(data: {
recipientName: string
courseName: string
completionDate: string
}): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;1,400&display=swap" rel="stylesheet">
</head>
<body class="bg-white w-full h-screen flex items-center justify-center m-0 p-0">
<div class="border-8 border-double border-amber-700 p-16 max-w-4xl w-full mx-8 text-center relative">
<div class="absolute inset-2 border border-amber-700 pointer-events-none"></div>
<p class="text-amber-700 text-xs uppercase tracking-widest font-semibold mb-6">Certificate of Achievement</p>
<h1 class="text-4xl font-bold text-gray-900 mb-1" style="font-family: 'Playfair Display', serif;">
Certificate of Completion
</h1>
<p class="text-gray-500 mb-8 text-sm">This is to certify that</p>
<p class="text-5xl text-gray-900 mb-2" style="font-family: 'Playfair Display', serif; font-style: italic;">
${data.recipientName}
</p>
<p class="text-gray-500 mb-4">has successfully completed</p>
<p class="text-2xl font-bold text-gray-800 mb-12">${data.courseName}</p>
<p class="text-gray-400 text-sm">${data.completionDate}</p>
</div>
</body>
</html>`
}Bulk certificate generation
When you need to generate certificates for all attendees after an event:
import { writeFileSync } from 'fs'
const attendees = [
{ name: 'Alice Johnson', email: '[email protected]' },
{ name: 'Bob Williams', email: '[email protected]' },
// ...
]
const courseName = 'Advanced TypeScript Workshop'
const completionDate = 'March 4, 2026'
async function generateAll() {
for (const attendee of attendees) {
const html = renderCertificate({
recipientName: attendee.name,
courseName,
completionDate,
instructorName: 'Sarah Chen',
organizationName: 'DevConf 2026',
})
const response = await fetch('https://api.docapi.co/v1/pdf', {
method: 'POST',
headers: {
'x-api-key': process.env.DOCAPI_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html,
options: { format: 'A4', landscape: true, printBackground: true },
}),
})
const buffer = Buffer.from(await response.arrayBuffer())
const filename = `${attendee.name.replace(/\s+/g, '-').toLowerCase()}-certificate.pdf`
writeFileSync(`./certificates/${filename}`, buffer)
// Rate limit: avoid hammering the API
await new Promise(r => setTimeout(r, 100))
}
}For large batches (hundreds of certificates), use concurrency control rather than sequential generation — but stay within your plan's rate limits.
Next.js Route Handler for on-demand generation
// app/api/certificates/[courseId]/[userId]/route.ts
import { NextRequest } from 'next/server'
import { getCertificateData } from '@/lib/db'
import { renderCertificate } from '@/lib/templates/certificate'
export async function GET(
request: NextRequest,
{ params }: { params: { courseId: string; userId: string } }
) {
const data = await getCertificateData(params.courseId, params.userId)
if (!data) {
return new Response('Not found', { status: 404 })
}
const response = await fetch('https://api.docapi.co/v1/pdf', {
method: 'POST',
headers: {
'x-api-key': process.env.DOCAPI_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html: renderCertificate(data),
options: { format: 'A4', landscape: true, printBackground: true, margin: { top: '0', right: '0', bottom: '0', left: '0' } },
}),
})
const pdf = await response.arrayBuffer()
return new Response(pdf, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="certificate-${params.userId}.pdf"`,
'Cache-Control': 'private, max-age=3600',
},
})
}Cache the response with max-age=3600 — the certificate content doesn't change after completion, so caching saves API credits.
Get started
DOCAPI_KEY=pk_your_key_hereGet a key at docapi.co/signup — 10 free PDFs, no credit card required.