DEV Community

Cover image for How to Build a Certificate Generator with HTML and CSS
Özgür S.
Özgür S.

Posted on

How to Build a Certificate Generator with HTML and CSS

Every course platform, bootcamp, and online community eventually needs the same thing: certificates. And most developers reach for one of these solutions:

  1. A design tool (Canva, Figma) — manual, doesn't scale
  2. PDF generation libraries (PDFKit, pdfmake) — painful layout control
  3. Canvas API — powerful but verbose and hard to style
  4. 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode
// utils.js
export function formatDate(date) {
  return new Intl.DateTimeFormat('en-US', {
    month: 'long',
    day: 'numeric',
    year: 'numeric'
  }).format(date)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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`
}
Enter fullscreen mode Exit fullscreen mode

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))
  )
)
Enter fullscreen mode Exit fullscreen mode

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: 2 for 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)