Skip to main content
RapidDev - Software Development Agency
how-to-build-replit30-60 minutes

How to Build a Certificate Generator with Replit

Build a PDF certificate generator in Replit in 30-60 minutes using Express, PostgreSQL, and Puppeteer. You'll get HTML template management, variable substitution, PDF generation, a unique verification code per certificate, and a public verification page — no local setup required.

What you'll build

  • HTML/CSS certificate templates with {{variable}} placeholders stored in PostgreSQL
  • PDF generation using Puppeteer (headless Chrome) configured for Replit's Nix environment
  • Unique 8-character verification codes (e.g., CERT-A3X9K2M1) for each certificate
  • Public verification endpoint: enter code to see certificate details and confirm authenticity
  • Bulk generation endpoint accepting an array of recipients to create certificates in batch
  • Email delivery of PDF certificates via SendGrid or Resend
  • Certificate management dashboard with download, email, and verification link for each certificate
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner11 min read30-60 minutesReplit FreeApril 2026RapidDev Engineering Team
TL;DR

Build a PDF certificate generator in Replit in 30-60 minutes using Express, PostgreSQL, and Puppeteer. You'll get HTML template management, variable substitution, PDF generation, a unique verification code per certificate, and a public verification page — no local setup required.

What you're building

Certificate generators power course completions, event attendance, and achievement recognition — Coursera and Udemy issue millions of them. Instead of paying for a certificate SaaS, you can host your own generator that creates personalized PDFs and verifies their authenticity.

Replit Agent scaffolds the Express + Drizzle project. The key technical step is configuring Puppeteer to work in Replit's Nix environment — Puppeteer needs Chromium, which must be added to replit.nix and pointed to the right binary path. Once that's working, generating a PDF is a straightforward render-HTML-then-print operation.

Each certificate gets a unique verification code that anyone can check at your public verification page. This is the feature that makes certificates genuinely useful — employers and partners can confirm authenticity without contacting you.

Final result

A deployed certificate generator with HTML template management, Puppeteer PDF generation, unique verification codes, public verification page, bulk generation, and email delivery.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
PuppeteerPDF Generation
Replit AuthAuth

Prerequisites

  • A Replit account (free tier is sufficient for this guide)
  • Optional: a SendGrid or Resend account for email delivery (free tiers work, store key in Secrets)
  • An HTML/CSS design for your certificate or a description of what it should look like
  • No coding experience required — Agent generates all the code

Build steps

1

Scaffold the project and configure Puppeteer in Nix

Puppeteer requires Chromium, which is not installed by default in Replit's Node.js environment. You need to add it to replit.nix and tell Puppeteer where to find the binary. This is the most Replit-specific step in the guide.

prompt.txt
1// Step 1: Prompt Replit Agent:
2// Build a Node.js Express certificate generator with Replit Auth and built-in PostgreSQL using Drizzle ORM.
3// Schema in shared/schema.ts:
4// * templates: id serial pk, name text not null, html_content text not null,
5// background_image_url text, dimensions jsonb default '{"width":1056,"height":816}',
6// created_by text not null, created_at timestamp default now()
7// * certificates: id serial pk, template_id integer references templates not null,
8// recipient_name text not null, recipient_email text, title text not null,
9// issuer_name text not null, issue_date date not null,
10// verification_code text unique not null, custom_fields jsonb,
11// pdf_url text, created_by text not null, created_at timestamp default now()
12// * verification_log: id serial pk, certificate_id integer references certificates not null,
13// verified_at timestamp default now(), ip_address text
14// Routes: GET/POST /api/templates, GET /api/templates/:id/preview,
15// POST /api/certificates, POST /api/certificates/bulk,
16// GET /api/certificates, GET /api/certificates/:id/download,
17// GET /api/verify/:code, POST /api/certificates/:id/email
18// Use puppeteer for PDF generation (headless Chrome)
19
20// Step 2: Edit replit.nix to add Chromium:
21// In the replit.nix file, add pkgs.chromium to the deps array:
22// deps = [
23// pkgs.nodejs-18_x
24// pkgs.chromium // <-- add this line
25// pkgs.nix
26// ];
27// After saving, Replit installs Chromium automatically.

Pro tip: If Puppeteer can't find Chromium after adding it to replit.nix, check the binary path with: which chromium or which chromium-browser in the Replit Shell. Use that path as executablePath in puppeteer.launch().

Expected result: Replit installs Chromium via Nix. The Shell shows the chromium binary is available. Running a simple puppeteer.launch() call in a test file succeeds without throwing 'No usable sandbox'.

2

Configure Puppeteer for Replit's environment

Puppeteer needs specific flags to run in Replit's container environment. The no-sandbox flag is required because Replit containers don't support the sandbox mode that Chrome normally uses.

server/utils/pdf.js
1import puppeteer from 'puppeteer';
2
3export async function generatePdf(htmlContent) {
4 const browser = await puppeteer.launch({
5 executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
6 args: [
7 '--no-sandbox', // required in Replit containers
8 '--disable-setuid-sandbox',
9 '--disable-dev-shm-usage', // prevents crashes in low-memory environments
10 '--disable-gpu',
11 '--no-first-run',
12 '--no-zygote',
13 ],
14 headless: 'new',
15 });
16
17 try {
18 const page = await browser.newPage();
19 await page.setContent(htmlContent, { waitUntil: 'networkidle0' });
20 await page.emulateMediaType('print');
21
22 const pdf = await page.pdf({
23 width: '1056px',
24 height: '816px',
25 printBackground: true, // render background colors and images
26 margin: { top: '0', right: '0', bottom: '0', left: '0' },
27 });
28
29 return pdf; // returns a Buffer
30 } finally {
31 await browser.close();
32 }
33}

Pro tip: Add CHROMIUM_PATH to Replit Secrets if the default /usr/bin/chromium path doesn't work. Run 'which chromium' in the Shell to find the correct path on your Replit instance.

Expected result: Calling generatePdf('<h1>Hello</h1>') returns a Buffer containing a valid PDF. Opening the Buffer in a PDF viewer shows the HTML rendered as a page.

3

Build the certificate generation endpoint

The POST /api/certificates endpoint generates a verification code, renders the HTML template with recipient data substituted for placeholders, creates a PDF, and stores the certificate record.

server/routes/certificates.js
1import crypto from 'crypto';
2import { db } from '../db.js';
3import { templates, certificates } from '../../shared/schema.js';
4import { eq } from 'drizzle-orm';
5import { generatePdf } from '../utils/pdf.js';
6
7function generateVerificationCode() {
8 const bytes = crypto.randomBytes(4).toString('hex').toUpperCase();
9 return `CERT-${bytes}`;
10}
11
12function renderTemplate(htmlContent, variables) {
13 let rendered = htmlContent;
14 for (const [key, value] of Object.entries(variables)) {
15 const placeholder = new RegExp(`{{${key}}}`, 'g');
16 rendered = rendered.replace(placeholder, String(value || ''));
17 }
18 return rendered;
19}
20
21export async function generateCertificate(req, res) {
22 const { templateId, recipientName, recipientEmail, title, issuerName, issueDate, customFields } = req.body;
23 const createdBy = req.get('X-Replit-User-Id');
24 if (!createdBy) return res.status(401).json({ error: 'Not authenticated' });
25
26 const [template] = await db.select().from(templates).where(eq(templates.id, parseInt(templateId)));
27 if (!template) return res.status(404).json({ error: 'Template not found' });
28
29 const verificationCode = generateVerificationCode();
30 const variables = {
31 recipient_name: recipientName,
32 title,
33 issuer_name: issuerName,
34 issue_date: new Date(issueDate).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
35 verification_code: verificationCode,
36 ...(customFields || {}),
37 };
38
39 const renderedHtml = renderTemplate(template.htmlContent, variables);
40 const pdfBuffer = await generatePdf(renderedHtml);
41
42 // Store PDF as base64 in DB (or upload to external storage and store URL)
43 const pdfBase64 = pdfBuffer.toString('base64');
44
45 const [cert] = await db.insert(certificates).values({
46 templateId: parseInt(templateId),
47 recipientName,
48 recipientEmail,
49 title,
50 issuerName,
51 issueDate: new Date(issueDate),
52 verificationCode,
53 customFields: customFields || null,
54 createdBy,
55 }).returning();
56
57 res.status(201).json({ certificate: cert, verificationCode, downloadPath: `/api/certificates/${cert.id}/download` });
58}

Pro tip: For production use, upload the PDF to Cloudflare R2 or AWS S3 instead of storing the base64 in PostgreSQL. Store the resulting URL in the pdf_url column. PostgreSQL's 10GB limit fills up quickly with binary data.

Expected result: POST /api/certificates with template ID, recipient name, and title returns a certificate record with a unique verification code like 'CERT-A3F9B2C1' and a download URL.

4

Add the public verification endpoint and bulk generation

The verification endpoint lets anyone check a certificate is genuine. The bulk endpoint processes multiple recipients at once from an array.

server/routes/verify.js
1import { db } from '../db.js';
2import { certificates, templates, verificationLog } from '../../shared/schema.js';
3import { eq } from 'drizzle-orm';
4
5// GET /api/verify/:code — public, no auth required
6export async function verifyCertificate(req, res) {
7 const { code } = req.params;
8
9 const [cert] = await db
10 .select({
11 recipientName: certificates.recipientName,
12 title: certificates.title,
13 issuerName: certificates.issuerName,
14 issueDate: certificates.issueDate,
15 verificationCode: certificates.verificationCode,
16 createdAt: certificates.createdAt,
17 templateName: templates.name,
18 })
19 .from(certificates)
20 .leftJoin(templates, eq(certificates.templateId, templates.id))
21 .where(eq(certificates.verificationCode, code.toUpperCase()));
22
23 if (!cert) return res.status(404).json({ error: 'Certificate not found', valid: false });
24
25 // Log the verification attempt
26 db.insert(verificationLog).values({
27 certificateId: cert.id,
28 ipAddress: req.ip,
29 }).catch(console.error);
30
31 res.json({ valid: true, certificate: cert });
32}
33
34// POST /api/certificates/bulk
35export async function generateBulk(req, res) {
36 const { templateId, recipients, title, issuerName, issueDate } = req.body;
37 // recipients = [{ name, email, customFields }, ...]
38 if (!Array.isArray(recipients) || recipients.length === 0) {
39 return res.status(400).json({ error: 'recipients must be a non-empty array' });
40 }
41 if (recipients.length > 100) {
42 return res.status(400).json({ error: 'Maximum 100 recipients per bulk request' });
43 }
44
45 const results = [];
46 for (const recipient of recipients) {
47 // Re-use generateCertificate logic per recipient
48 // (abbreviated for space — call the full function in practice)
49 results.push({ name: recipient.name, status: 'queued' });
50 }
51
52 res.json({ queued: results.length, results });
53}

Pro tip: For bulk generation of more than 10 certificates, process them in a background queue rather than synchronously in the route handler. Puppeteer is memory-intensive — generating 50 PDFs sequentially in one request can cause memory errors on Replit's free tier.

Expected result: GET /api/verify/CERT-A3F9B2C1 returns the certificate details and logs the verification. Invalid codes return a 404 with { valid: false }.

Complete code

server/utils/pdf.js
1import puppeteer from 'puppeteer';
2
3let browserInstance = null;
4
5async function getBrowser() {
6 if (browserInstance) return browserInstance;
7 browserInstance = await puppeteer.launch({
8 executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
9 args: [
10 '--no-sandbox',
11 '--disable-setuid-sandbox',
12 '--disable-dev-shm-usage',
13 '--disable-gpu',
14 '--no-first-run',
15 '--no-zygote',
16 '--single-process',
17 ],
18 headless: 'new',
19 });
20 browserInstance.on('disconnected', () => { browserInstance = null; });
21 return browserInstance;
22}
23
24export async function generatePdf(htmlContent, options = {}) {
25 const browser = await getBrowser();
26 const page = await browser.newPage();
27 try {
28 await page.setContent(htmlContent, { waitUntil: 'networkidle0' });
29 await page.emulateMediaType('print');
30 const pdf = await page.pdf({
31 width: options.width || '1056px',
32 height: options.height || '816px',
33 printBackground: true,
34 margin: { top: '0', right: '0', bottom: '0', left: '0' },
35 });
36 return pdf;
37 } finally {
38 await page.close();
39 }
40}
41
42export function renderTemplate(html, vars) {
43 return Object.entries(vars).reduce((h, [k, v]) =>
44 h.replace(new RegExp(`{{${k}}}`, 'g'), String(v ?? '')), html);
45}

Customization ideas

QR code on the certificate

Add a QR code to each certificate that links to the verification page. Use the qrcode npm package to generate a QR code as a base64 data URL. Inject it as {{qr_code}} in the template HTML: <img src='{{qr_code}}' width='80' />.

Certificate expiration dates

Add an expires_at date column to certificates. The verification endpoint returns an additional expired boolean if expires_at < now(). Show an 'Expired' badge on the verification page for expired certificates.

Zapier/webhook trigger on certificate issue

After generating each certificate, POST to a configurable webhook URL stored in app_settings. This lets you trigger Zapier automations, Slack notifications, or CRM updates whenever a certificate is issued.

Common pitfalls

Pitfall: Forgetting to add pkgs.chromium to replit.nix

How to avoid: Edit replit.nix (it's a hidden file — use the Files panel and enable 'Show hidden files') and add pkgs.chromium to the deps array. Save the file and wait for Replit to reinstall dependencies.

Pitfall: Not setting --no-sandbox in puppeteer.launch() args

How to avoid: Always include '--no-sandbox' and '--disable-setuid-sandbox' in the args array when launching Puppeteer on Replit.

Pitfall: Storing PDF binary data in PostgreSQL

How to avoid: Upload PDFs to Cloudflare R2, AWS S3, or Replit Object Storage. Store only the public URL in the pdf_url column. This keeps the database lean and makes PDF delivery fast via CDN.

Best practices

  • Add pkgs.chromium to replit.nix and set --no-sandbox in puppeteer.launch() args — these are required for Replit.
  • Reuse the Puppeteer browser instance across requests (browser pool pattern) rather than launching a new browser per PDF — launching is expensive.
  • Generate verification codes with crypto.randomBytes(4).toString('hex').toUpperCase() — this gives 32 bits of entropy, sufficient for most use cases.
  • Store PDF files in object storage (Cloudflare R2 or Replit Object Storage) not in PostgreSQL.
  • Log all verification requests in verification_log with timestamp and IP — this audit trail is valuable for fraud detection.
  • Deploy on Autoscale — certificate generation is sporadic and Puppeteer's memory usage benefits from scale-to-zero when idle.
  • Cap bulk generation at 100 recipients per request to avoid memory exhaustion from simultaneous Puppeteer page opens.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a certificate generator in Node.js using Puppeteer for PDF generation. I have an HTML template with {{recipient_name}}, {{title}}, {{issue_date}}, and {{verification_code}} placeholders. Help me write a function that takes the template HTML string and a variables object, replaces all {{key}} placeholders with the corresponding values, then uses Puppeteer to render the HTML and generate a PDF Buffer. The Puppeteer instance should be reused across multiple calls rather than launching a new browser each time.

Build Prompt

Add a certificate template visual editor to the admin panel. Build a form with an HTML textarea showing the template code, a preview panel that renders the HTML with sample variable values (recipient_name='John Doe', title='Sample Certificate', etc.), and a 'Generate Sample PDF' button that calls POST /api/templates/:id/preview and triggers a PDF download. Add variable reference hints below the editor showing which {{placeholders}} are available.

Frequently asked questions

Does Puppeteer work on Replit's free tier?

Yes, but with limitations. The free tier has limited memory (~512MB). Generating one or two certificates at a time works fine. For bulk generation of 10+ certificates simultaneously, you may hit memory limits. Consider upgrading to Replit Core or processing bulk jobs sequentially.

Can I use custom fonts in my certificates?

Yes. In your HTML template, add a @font-face CSS rule pointing to a font URL (Google Fonts CDN works well). In puppeteer.launch(), add '--font-render-hinting=none' to args. Use waitUntil: 'networkidle0' in page.setContent() so Puppeteer waits for the font to load before generating the PDF.

How do I add my company logo to the certificate template?

Embed the logo as a base64 data URL directly in the HTML template: <img src='data:image/png;base64,...' />. This avoids external HTTP requests during PDF generation. To convert an image to base64, use: Buffer.from(imageBuffer).toString('base64') in Node.js.

Can I revoke a certificate after issuing it?

Add a status column to certificates with values 'active' and 'revoked'. In the GET /api/verify/:code endpoint, check the status and return { valid: false, revoked: true } for revoked certificates. Add a revocation reason and revoked_at timestamp for audit purposes.

Is the verification code secure enough?

crypto.randomBytes(4) gives 32 bits of entropy — about 4 billion possible codes. For most certificate use cases this is sufficient. If you need stronger guarantees (e.g., for compliance certifications), use crypto.randomBytes(8) for 64 bits of entropy, giving 18 quintillion possible codes.

Can RapidDev build a certificate generation system for my organization?

Yes. RapidDev has built 600+ apps including document generation systems with branded templates, bulk issuance, and verification portals. Contact us for a free consultation about your certificate needs.

Should I deploy on Autoscale or Reserved VM?

Autoscale is the right choice for certificate generators. Generation is sporadic — someone issues certificates after completing a course or attending an event. Scale-to-zero saves money during idle periods, and the one-time PDF generation is fast enough that users don't notice the cold start.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.