# How to Build a Certificate Generator with Replit

- Tool: How to Build with Replit
- Difficulty: Beginner
- Compatibility: Replit Free
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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.

```
// Step 1: Prompt Replit Agent:
// Build a Node.js Express certificate generator with Replit Auth and built-in PostgreSQL using Drizzle ORM.
// Schema in shared/schema.ts:
// * templates: id serial pk, name text not null, html_content text not null,
//   background_image_url text, dimensions jsonb default '{"width":1056,"height":816}',
//   created_by text not null, created_at timestamp default now()
// * certificates: id serial pk, template_id integer references templates not null,
//   recipient_name text not null, recipient_email text, title text not null,
//   issuer_name text not null, issue_date date not null,
//   verification_code text unique not null, custom_fields jsonb,
//   pdf_url text, created_by text not null, created_at timestamp default now()
// * verification_log: id serial pk, certificate_id integer references certificates not null,
//   verified_at timestamp default now(), ip_address text
// Routes: GET/POST /api/templates, GET /api/templates/:id/preview,
// POST /api/certificates, POST /api/certificates/bulk,
// GET /api/certificates, GET /api/certificates/:id/download,
// GET /api/verify/:code, POST /api/certificates/:id/email
// Use puppeteer for PDF generation (headless Chrome)

// Step 2: Edit replit.nix to add Chromium:
// In the replit.nix file, add pkgs.chromium to the deps array:
// deps = [
//   pkgs.nodejs-18_x
//   pkgs.chromium        // <-- add this line
//   pkgs.nix
// ];
// 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.

```
import puppeteer from 'puppeteer';

export async function generatePdf(htmlContent) {
  const browser = await puppeteer.launch({
    executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
    args: [
      '--no-sandbox',          // required in Replit containers
      '--disable-setuid-sandbox',
      '--disable-dev-shm-usage', // prevents crashes in low-memory environments
      '--disable-gpu',
      '--no-first-run',
      '--no-zygote',
    ],
    headless: 'new',
  });

  try {
    const page = await browser.newPage();
    await page.setContent(htmlContent, { waitUntil: 'networkidle0' });
    await page.emulateMediaType('print');

    const pdf = await page.pdf({
      width: '1056px',
      height: '816px',
      printBackground: true, // render background colors and images
      margin: { top: '0', right: '0', bottom: '0', left: '0' },
    });

    return pdf; // returns a Buffer
  } finally {
    await browser.close();
  }
}
```

> 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.

```
import crypto from 'crypto';
import { db } from '../db.js';
import { templates, certificates } from '../../shared/schema.js';
import { eq } from 'drizzle-orm';
import { generatePdf } from '../utils/pdf.js';

function generateVerificationCode() {
  const bytes = crypto.randomBytes(4).toString('hex').toUpperCase();
  return `CERT-${bytes}`;
}

function renderTemplate(htmlContent, variables) {
  let rendered = htmlContent;
  for (const [key, value] of Object.entries(variables)) {
    const placeholder = new RegExp(`{{${key}}}`, 'g');
    rendered = rendered.replace(placeholder, String(value || ''));
  }
  return rendered;
}

export async function generateCertificate(req, res) {
  const { templateId, recipientName, recipientEmail, title, issuerName, issueDate, customFields } = req.body;
  const createdBy = req.get('X-Replit-User-Id');
  if (!createdBy) return res.status(401).json({ error: 'Not authenticated' });

  const [template] = await db.select().from(templates).where(eq(templates.id, parseInt(templateId)));
  if (!template) return res.status(404).json({ error: 'Template not found' });

  const verificationCode = generateVerificationCode();
  const variables = {
    recipient_name: recipientName,
    title,
    issuer_name: issuerName,
    issue_date: new Date(issueDate).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
    verification_code: verificationCode,
    ...(customFields || {}),
  };

  const renderedHtml = renderTemplate(template.htmlContent, variables);
  const pdfBuffer = await generatePdf(renderedHtml);

  // Store PDF as base64 in DB (or upload to external storage and store URL)
  const pdfBase64 = pdfBuffer.toString('base64');

  const [cert] = await db.insert(certificates).values({
    templateId: parseInt(templateId),
    recipientName,
    recipientEmail,
    title,
    issuerName,
    issueDate: new Date(issueDate),
    verificationCode,
    customFields: customFields || null,
    createdBy,
  }).returning();

  res.status(201).json({ certificate: cert, verificationCode, downloadPath: `/api/certificates/${cert.id}/download` });
}
```

> 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.

```
import { db } from '../db.js';
import { certificates, templates, verificationLog } from '../../shared/schema.js';
import { eq } from 'drizzle-orm';

// GET /api/verify/:code — public, no auth required
export async function verifyCertificate(req, res) {
  const { code } = req.params;

  const [cert] = await db
    .select({
      recipientName: certificates.recipientName,
      title: certificates.title,
      issuerName: certificates.issuerName,
      issueDate: certificates.issueDate,
      verificationCode: certificates.verificationCode,
      createdAt: certificates.createdAt,
      templateName: templates.name,
    })
    .from(certificates)
    .leftJoin(templates, eq(certificates.templateId, templates.id))
    .where(eq(certificates.verificationCode, code.toUpperCase()));

  if (!cert) return res.status(404).json({ error: 'Certificate not found', valid: false });

  // Log the verification attempt
  db.insert(verificationLog).values({
    certificateId: cert.id,
    ipAddress: req.ip,
  }).catch(console.error);

  res.json({ valid: true, certificate: cert });
}

// POST /api/certificates/bulk
export async function generateBulk(req, res) {
  const { templateId, recipients, title, issuerName, issueDate } = req.body;
  // recipients = [{ name, email, customFields }, ...]
  if (!Array.isArray(recipients) || recipients.length === 0) {
    return res.status(400).json({ error: 'recipients must be a non-empty array' });
  }
  if (recipients.length > 100) {
    return res.status(400).json({ error: 'Maximum 100 recipients per bulk request' });
  }

  const results = [];
  for (const recipient of recipients) {
    // Re-use generateCertificate logic per recipient
    // (abbreviated for space — call the full function in practice)
    results.push({ name: recipient.name, status: 'queued' });
  }

  res.json({ queued: results.length, results });
}
```

> 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 example

File: `server/utils/pdf.js`

```javascript
import puppeteer from 'puppeteer';

let browserInstance = null;

async function getBrowser() {
  if (browserInstance) return browserInstance;
  browserInstance = await puppeteer.launch({
    executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
    args: [
      '--no-sandbox',
      '--disable-setuid-sandbox',
      '--disable-dev-shm-usage',
      '--disable-gpu',
      '--no-first-run',
      '--no-zygote',
      '--single-process',
    ],
    headless: 'new',
  });
  browserInstance.on('disconnected', () => { browserInstance = null; });
  return browserInstance;
}

export async function generatePdf(htmlContent, options = {}) {
  const browser = await getBrowser();
  const page = await browser.newPage();
  try {
    await page.setContent(htmlContent, { waitUntil: 'networkidle0' });
    await page.emulateMediaType('print');
    const pdf = await page.pdf({
      width: options.width || '1056px',
      height: options.height || '816px',
      printBackground: true,
      margin: { top: '0', right: '0', bottom: '0', left: '0' },
    });
    return pdf;
  } finally {
    await page.close();
  }
}

export function renderTemplate(html, vars) {
  return Object.entries(vars).reduce((h, [k, v]) =>
    h.replace(new RegExp(`{{${k}}}`, 'g'), String(v ?? '')), html);
}
```

## Common mistakes

- **Forgetting to add pkgs.chromium to replit.nix** — Puppeteer requires Chromium to be installed. Without it in replit.nix, puppeteer.launch() throws 'Could not find expected browser' or similar errors. Fix: 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.
- **Not setting --no-sandbox in puppeteer.launch() args** — Replit containers don't support Chrome's sandbox mode. Without this flag, Puppeteer throws 'No usable sandbox' and crashes. Fix: Always include '--no-sandbox' and '--disable-setuid-sandbox' in the args array when launching Puppeteer on Replit.
- **Storing PDF binary data in PostgreSQL** — Storing PDFs as base64 in the database quickly consumes Replit's 10GB PostgreSQL limit and slows down all queries because the certificates table becomes very large. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/certificate-generator
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/certificate-generator
