You run an online course. A student finishes all the modules. They want a certificate. Sounds simple.

Then you start building it. You need the student name, the course title, the completion date, maybe an instructor signature. The layout has to look professional. It needs to work for names of any length. And you need to generate hundreds of these automatically.

This is not a quick weekend project when you build it from scratch. But with the right approach, it takes about an hour.

The Problem: Certificate Generation Is Surprisingly Tricky

Certificates seem easy because they are visually simple. One page, some text, a border, maybe a logo. But the implementation has sharp edges:

  • Dynamic text layout: "John" fits nicely. "Alexandra Konstantinopoulou" overflows. Your layout needs to handle both.
  • Consistent styling: The certificate should look the same whether you generate it at 2 AM on a server or preview it in development. Browser-based rendering introduces inconsistencies.
  • Batch generation: When a cohort of 200 students completes a course, you need to generate 200 unique certificates without manual intervention.
  • PDF output: Students expect a downloadable PDF they can print, share on LinkedIn, or attach to a job application. An image or HTML page is not enough.

Most teams start with one of these approaches:

  • Canvas/image manipulation: Generate a certificate image with Python Pillow or Node Canvas. Works, but the output is an image, not a PDF. Text is not selectable. Print quality varies.
  • Puppeteer/Playwright: Render an HTML page and print to PDF. Heavy dependency, requires a headless browser, hard to deploy in serverless environments.
  • LaTeX: Great output quality, terrible developer experience. Good luck debugging template errors.

Why It Gets Painful

The real problem is not generating one certificate. It is maintaining a certificate system.

Your designer creates a beautiful template. You hardcode the layout in your generation code. Then:

  • The marketing team wants to change the brand colors. You modify code, test, deploy.
  • A new course needs a slightly different layout. You copy-paste the template code and start diverging.
  • The CEO wants to add a QR code for verification. You refactor the generation logic.
  • A student complains their name is cut off. You add special-case handling for long names.

Every change requires a developer. The template is buried in code, not in a place where designers or content teams can touch it.

The Practical Approach: HTML Templates + API

The cleanest architecture is to separate the template from the generation logic:

  1. Design the certificate as an HTML template with Tailwind CSS for styling
  2. Define variables for dynamic content (name, course, date, etc.)
  3. Call an API to generate the PDF, passing the variables

This is exactly what Transactional.dev is built for. You create your template in a visual editor using HTML and Tailwind, then generate PDFs through a single API call.

Step-by-Step: Building the Certificate

1. Design the Template

Here is a clean certificate template using HTML and Tailwind:

<div class="w-[800px] h-[600px] mx-auto border-8 border-double border-amber-600 p-12 bg-white relative">
  
  <!-- Header -->
  <div class="text-center mb-8">
    <img src="{{logo_url}}" class="h-12 mx-auto mb-4" />
    <h1 class="text-3xl font-serif text-gray-800 tracking-wide">
      Certificate of Completion
    </h1>
  </div>

  <!-- Recipient -->
  <div class="text-center my-8">
    <p class="text-gray-500 text-sm uppercase tracking-widest mb-2">
      This certifies that
    </p>
    <p class="text-2xl font-bold text-gray-900 border-b-2 border-gray-300 pb-2 inline-block px-8">
      {{student_name}}
    </p>
  </div>

  <!-- Course -->
  <div class="text-center my-6">
    <p class="text-gray-500 text-sm">
      has successfully completed
    </p>
    <p class="text-xl font-semibold text-gray-800 mt-1">
      {{course_title}}
    </p>
    <p class="text-gray-400 text-sm mt-1">
      {{course_duration}} hours of instruction
    </p>
  </div>

  <!-- Footer -->
  <div class="absolute bottom-12 left-12 right-12 flex justify-between items-end">
    <div class="text-center">
      <div class="border-t border-gray-400 pt-1 px-8">
        <p class="text-sm font-medium">{{instructor_name}}</p>
        <p class="text-xs text-gray-400">Instructor</p>
      </div>
    </div>
    <div class="text-center">
      <p class="text-sm text-gray-500">{{completion_date}}</p>
      <p class="text-xs text-gray-400">Date of Completion</p>
    </div>
    <div class="text-center">
      <p class="text-xs text-gray-400">Certificate ID: {{certificate_id}}</p>
    </div>
  </div>
</div>

This template handles variable-length names naturally because it uses inline-block sizing. Tailwind makes it easy to adjust spacing, colors, and typography without touching generation code.

2. Generate a Certificate via API

async function generateCertificate(student, course) {
  const response = await fetch(
    "https://api.transactional.dev/v1/generate",
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-api-token": process.env.TRANSACTIONAL_API_KEY,
      },
      body: JSON.stringify({
        documentId: "your-certificate-template-uuid",
        variables: {
          student_name: student.name,
          course_title: course.title,
          course_duration: course.hours,
          instructor_name: course.instructor,
          completion_date: new Date().toLocaleDateString("en-US", {
            year: "numeric",
            month: "long",
            day: "numeric",
          }),
          certificate_id: generateCertId(),
          logo_url: "https://yourapp.com/logo.png",
        },
      }),
    }
  );

  const { url } = await response.json();
  return url;
}

3. Trigger on Course Completion

app.post("/api/complete-course", async (req, res) => {
  const { studentId, courseId } = req.body;

  // Mark course as complete in your database
  await db.completeCourse(studentId, courseId);

  // Get student and course details
  const student = await db.getStudent(studentId);
  const course = await db.getCourse(courseId);

  // Generate the certificate
  const pdfUrl = await generateCertificate(student, course);

  // Store the certificate URL
  await db.saveCertificate(studentId, courseId, pdfUrl);

  // Optionally email it
  await sendEmail({
    to: student.email,
    subject: `Your certificate for ${course.title}`,
    body: `Congratulations! Download your certificate: ${pdfUrl}`,
  });

  res.json({ certificateUrl: pdfUrl });
});

4. Batch Generation for a Cohort

async function generateCohortCertificates(courseId) {
  const course = await db.getCourse(courseId);
  const students = await db.getCompletedStudents(courseId);

  const results = [];

  for (const student of students) {
    const url = await generateCertificate(student, course);
    results.push({ studentId: student.id, url });

    // Small delay to be respectful of rate limits
    await new Promise((r) => setTimeout(r, 200));
  }

  return results;
}

Implementation Notes

Landscape orientation: Certificates typically use landscape layout. Set your template page dimensions accordingly in Transactional.dev.

Unique certificate IDs: Generate a short, readable ID for each certificate (e.g., CERT-2025-A7X3). This lets recipients and employers verify the certificate.

Font choices: Serif fonts (like Georgia or Playfair Display) give certificates a formal feel. Use Tailwind font-serif class or import a Google Font in your template.

Logo handling: Use a hosted URL for your logo rather than embedding base64. It keeps the template clean and makes logo updates trivial.

Common Mistakes

Not testing with edge cases: Always test with very long names, very short names, special characters (accents, CJK characters), and long course titles.

Hardcoding dates: Use a variable for the date format so you can localize it later. "June 8, 2025" vs "8 juin 2025" should be a template variable, not a code change.

Skipping the certificate ID: Without a unique identifier, there is no way to verify a certificate is genuine. Even a simple UUID works.

Making certificates too complex: A clean, simple certificate with good typography looks more professional than one packed with borders, seals, and watermarks. Less is more.

Conclusion

Certificate generation does not need to be a custom rendering project. Design an HTML template once with the layout and branding you want, then generate certificates by passing student data to an API.

Transactional.dev handles the rendering and PDF output. You focus on the learning experience.

Get started with Transactional.dev and ship your certificate feature today.