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
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
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.
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 text14// 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/email18// Use puppeteer for PDF generation (headless Chrome)1920// 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_x24// pkgs.chromium // <-- add this line25// pkgs.nix26// ];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'.
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.
1import puppeteer from 'puppeteer';23export 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 containers8 '--disable-setuid-sandbox',9 '--disable-dev-shm-usage', // prevents crashes in low-memory environments10 '--disable-gpu',11 '--no-first-run',12 '--no-zygote',13 ],14 headless: 'new',15 });1617 try {18 const page = await browser.newPage();19 await page.setContent(htmlContent, { waitUntil: 'networkidle0' });20 await page.emulateMediaType('print');2122 const pdf = await page.pdf({23 width: '1056px',24 height: '816px',25 printBackground: true, // render background colors and images26 margin: { top: '0', right: '0', bottom: '0', left: '0' },27 });2829 return pdf; // returns a Buffer30 } 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.
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.
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';67function generateVerificationCode() {8 const bytes = crypto.randomBytes(4).toString('hex').toUpperCase();9 return `CERT-${bytes}`;10}1112function 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}2021export 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' });2526 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' });2829 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 };3839 const renderedHtml = renderTemplate(template.htmlContent, variables);40 const pdfBuffer = await generatePdf(renderedHtml);4142 // Store PDF as base64 in DB (or upload to external storage and store URL)43 const pdfBase64 = pdfBuffer.toString('base64');4445 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();5657 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.
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.
1import { db } from '../db.js';2import { certificates, templates, verificationLog } from '../../shared/schema.js';3import { eq } from 'drizzle-orm';45// GET /api/verify/:code — public, no auth required6export async function verifyCertificate(req, res) {7 const { code } = req.params;89 const [cert] = await db10 .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()));2223 if (!cert) return res.status(404).json({ error: 'Certificate not found', valid: false });2425 // Log the verification attempt26 db.insert(verificationLog).values({27 certificateId: cert.id,28 ipAddress: req.ip,29 }).catch(console.error);3031 res.json({ valid: true, certificate: cert });32}3334// POST /api/certificates/bulk35export 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 }4445 const results = [];46 for (const recipient of recipients) {47 // Re-use generateCertificate logic per recipient48 // (abbreviated for space — call the full function in practice)49 results.push({ name: recipient.name, status: 'queued' });50 }5152 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
1import puppeteer from 'puppeteer';23let browserInstance = null;45async 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}2324export 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}4142export 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation