# How to Build a Certificate Generator with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a certificate generator in Lovable with a live template editor, PDF generation via a Supabase Edge Function, bulk issuance from CSV upload, and a verification page where anyone can validate a certificate by UUID. Covers the full certificate lifecycle from template design to recipient delivery.

## Before you start

- Lovable Pro account for Edge Function generation
- Supabase project with Storage bucket enabled
- Resend account with an API key for email delivery (free tier: 100 emails/day)
- Supabase URL, anon key, service role key, and Resend API key saved to Cloud tab → Secrets

## Step-by-step guide

### 1. Set up the certificate schema with templates and issued certificates

Create the database tables that store templates, issued certificates, and the verification tokens. The issued_certificates table is the source of truth for all verification.

```
Build a certificate generator app. Create these Supabase tables:

- certificate_templates: id, user_id, name, description, template_config (jsonb, stores layout + content blocks), preview_image_url, is_active (bool default true), created_at, updated_at
- issued_certificates: id, user_id, template_id (FK certificate_templates), verification_token (uuid, default gen_random_uuid(), UNIQUE), recipient_name, recipient_email, course_name (text), issue_date (date, default today), expiry_date (date nullable), pdf_url (text, Supabase Storage path), is_revoked (bool default false), revoked_at, revocation_reason, metadata (jsonb, stores all custom fields), created_at

The template_config jsonb structure:
{
  layout: { background_color, border_color, border_width, font_family, width_px, height_px },
  blocks: [{ id, type: text|image|line, x, y, width, height, content, font_size, font_weight, color, align, token_key }]
}

RLS:
- certificate_templates: user_id = auth.uid() for all operations
- issued_certificates: user_id = auth.uid() for writes, allow public SELECT WHERE is_revoked = false (for verification)

Create a Storage bucket 'certificates' with public = false. Signed URLs will be used for PDF access.
```

> Pro tip: Ask Lovable to seed 3 starter templates (Course Completion, Workshop Attendance, Achievement Award) with pre-filled template_config JSON so users have working examples to customize immediately.

**Expected result:** Both tables are created with appropriate RLS. The Storage bucket 'certificates' is created. The app loads with a template gallery showing the starter templates.

### 2. Build the template editor with live preview

Create a split-view template editor where changes to the form fields on the left immediately update a certificate preview on the right.

```
Build the template editor at src/pages/TemplateEditor.tsx:

1. Split layout: form panel (left, 40%), preview panel (right, 60%)
2. Form panel sections:
   - Layout settings: background color picker (Input type='color'), border color + width, font family Select (Serif, Sans-serif, Monospace, Georgia), dimensions (A4 preset = 794x1123px, or custom)
   - Content blocks list: each block shows its type icon, token_key if set, and Edit/Delete buttons
   - 'Add Text Block' Button: opens inline editor for that block's content, font size, position, alignment
   - Token reference: show available tokens as Badge chips: {{recipient_name}}, {{course_name}}, {{issue_date}}, {{certificate_id}}, plus any custom tokens the user defines
3. Preview panel:
   - Render the certificate as a div with inline styles from template_config.layout
   - Render each block as an absolutely-positioned div within the certificate div
   - Replace {{token}} placeholders with sample data for preview: recipient_name = 'Jane Doe', course_name = 'Your Course Name', issue_date = today
4. Save Button: upserts template_config to certificate_templates, updates preview_image_url by calling html2canvas on the preview div
```

**Expected result:** The template editor shows a split view. Changing the background color immediately updates the preview. Adding a text block with {{recipient_name}} shows 'Jane Doe' in the preview.

### 3. Create the PDF generation Edge Function

Build the Edge Function that takes a template ID and recipient data, renders the certificate as HTML, converts to PDF, saves to Storage, and returns the PDF URL.

```
// supabase/functions/generate-certificate/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const { templateId, recipientData, certificateId } = await req.json()

  const { data: template } = await supabase
    .from('certificate_templates')
    .select('template_config')
    .eq('id', templateId)
    .single()

  if (!template) {
    return new Response(JSON.stringify({ error: 'Template not found' }), { status: 404, headers: corsHeaders })
  }

  const config = template.template_config
  let html = buildCertificateHtml(config, { ...recipientData, certificate_id: certificateId })

  const pdfResponse = await fetch('https://api.htmlpdfapi.com/convert', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${Deno.env.get('PDF_API_KEY')}` },
    body: JSON.stringify({ html, width: config.layout.width_px, height: config.layout.height_px }),
  })

  if (!pdfResponse.ok) {
    return new Response(JSON.stringify({ error: 'PDF generation failed' }), { status: 500, headers: corsHeaders })
  }

  const pdfBuffer = await pdfResponse.arrayBuffer()
  const fileName = `certificates/${certificateId}.pdf`

  const { error: uploadError } = await supabase.storage.from('certificates').upload(fileName, pdfBuffer, {
    contentType: 'application/pdf',
    upsert: true,
  })

  if (uploadError) {
    return new Response(JSON.stringify({ error: uploadError.message }), { status: 500, headers: corsHeaders })
  }

  await supabase.from('issued_certificates').update({ pdf_url: fileName }).eq('id', certificateId)

  return new Response(JSON.stringify({ pdfUrl: fileName }), { headers: corsHeaders })
})

function buildCertificateHtml(config: any, data: Record<string, string>): string {
  const { layout, blocks } = config
  const blockHtml = blocks.map((b: any) => {
    const content = b.content.replace(/\{\{(\w+)\}\}/g, (_: string, key: string) => data[key] ?? `{{${key}}}`)
    return `<div style="position:absolute;left:${b.x}px;top:${b.y}px;width:${b.width}px;font-size:${b.font_size}px;font-weight:${b.font_weight};color:${b.color};text-align:${b.align}">${content}</div>`
  }).join('')
  return `<html><body><div style="position:relative;width:${layout.width_px}px;height:${layout.height_px}px;background:${layout.background_color};border:${layout.border_width}px solid ${layout.border_color};font-family:${layout.font_family}">${blockHtml}</div></body></html>`
}
```

**Expected result:** The Edge Function deploys. Calling it with a template ID and recipient data returns a pdfUrl. The PDF is visible in Supabase Storage under the certificates bucket.

### 4. Add bulk issuance from CSV and the verification page

Build the bulk issuance flow for issuing certificates to a list of recipients from a CSV file, plus the public verification page that anyone can use to validate a certificate.

```
Build two features:

1. Bulk Issuance page at src/pages/BulkIssue.tsx:
   - File Input for CSV upload. Expected columns: recipient_name (required), email (required), course_name (required), and any custom token columns
   - Parse CSV on the frontend using a simple split('\n').map(row => row.split(',')) or ask Lovable to use the papaparse library
   - Show parsed rows in a DataTable preview with a 'recipient_name', 'email', 'course_name' column validation check
   - Template Select: choose which template to use
   - Issue Date DatePicker (default today)
   - 'Issue All' Button: shows a Progress bar. For each row:
     a. Insert a row into issued_certificates (get the new id and verification_token)
     b. Call the generate-certificate Edge Function with templateId, row data, certificateId
     c. Send email via Resend Edge Function with a link to verify/TOKEN and a PDF download link
   - Show a results summary: X succeeded, Y failed with error details

2. Public verification page at src/pages/VerifyCertificate.tsx (route: /verify/:token):
   - Fetch issued_certificates WHERE verification_token = token AND is_revoked = false
   - If found: show a green success Banner with the recipient name, course name, issue date, and 'This certificate is valid'
   - If not found or revoked: show a red error state 'Certificate not found or has been revoked'
   - Show a 'Download PDF' Button that generates a signed URL from Supabase Storage
   - No authentication required for this page
```

> Pro tip: Add a QR code to each generated certificate that links to /verify/VERIFICATION_TOKEN. Use the qrcode library importable via esm.sh in the Edge Function. This lets anyone scan the certificate with a phone to instantly verify it.

**Expected result:** Uploading a CSV and clicking 'Issue All' generates PDFs for each recipient and sends emails. The /verify/TOKEN page loads without logging in and shows certificate details.

## Complete code example

File: `src/pages/VerifyCertificate.tsx`

```typescript
import { useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { supabase } from '@/integrations/supabase/client'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle, XCircle, Download } from 'lucide-react'
import { format } from 'date-fns'

export default function VerifyCertificate() {
  const { token } = useParams<{ token: string }>()

  const { data: cert, isLoading } = useQuery({
    queryKey: ['verify-cert', token],
    queryFn: async () => {
      if (!token) throw new Error('No token')
      const { data, error } = await supabase
        .from('issued_certificates')
        .select('recipient_name, course_name, issue_date, expiry_date, is_revoked, pdf_url')
        .eq('verification_token', token)
        .single()
      if (error) return null
      return data
    },
    enabled: !!token,
  })

  async function handleDownload() {
    if (!cert?.pdf_url) return
    const { data } = await supabase.storage.from('certificates').createSignedUrl(cert.pdf_url, 3600)
    if (data?.signedUrl) window.open(data.signedUrl, '_blank')
  }

  if (isLoading) return <div className="flex min-h-screen items-center justify-center"><div className="animate-spin h-8 w-8 rounded-full border-4 border-primary border-t-transparent" /></div>

  return (
    <div className="flex min-h-screen items-center justify-center bg-muted/40 p-4">
      <Card className="w-full max-w-lg">
        <CardHeader>
          <CardTitle className="text-center">Certificate Verification</CardTitle>
        </CardHeader>
        <CardContent className="space-y-4">
          {!cert || cert.is_revoked ? (
            <Alert variant="destructive">
              <XCircle className="h-4 w-4" />
              <AlertTitle>Invalid Certificate</AlertTitle>
              <AlertDescription>This certificate could not be found or has been revoked.</AlertDescription>
            </Alert>
          ) : (
            <>
              <Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950">
                <CheckCircle className="h-4 w-4 text-green-600" />
                <AlertTitle className="text-green-700 dark:text-green-300">Valid Certificate</AlertTitle>
                <AlertDescription className="text-green-600 dark:text-green-400">This certificate is authentic and has not been revoked.</AlertDescription>
              </Alert>
              <div className="space-y-2 rounded-lg border p-4">
                <div className="flex justify-between"><span className="text-muted-foreground text-sm">Recipient</span><span className="font-medium">{cert.recipient_name}</span></div>
                <div className="flex justify-between"><span className="text-muted-foreground text-sm">Course</span><span className="font-medium">{cert.course_name}</span></div>
                <div className="flex justify-between"><span className="text-muted-foreground text-sm">Issued</span><span>{format(new Date(cert.issue_date), 'MMMM d, yyyy')}</span></div>
                {cert.expiry_date && <div className="flex justify-between"><span className="text-muted-foreground text-sm">Expires</span><Badge variant={new Date(cert.expiry_date) < new Date() ? 'destructive' : 'secondary'}>{format(new Date(cert.expiry_date), 'MMMM d, yyyy')}</Badge></div>}
              </div>
              {cert.pdf_url && <Button onClick={handleDownload} className="w-full"><Download className="mr-2 h-4 w-4" />Download Certificate</Button>}
            </>
          )}
        </CardContent>
      </Card>
    </div>
  )
}
```

## Common mistakes

- **Making the certificates Storage bucket public** — Public buckets expose all stored PDFs to anyone who knows or guesses the URL. Certificate PDFs often contain personal information and should be protected. Fix: Keep the certificates bucket private. Generate signed URLs on demand using supabase.storage.from('certificates').createSignedUrl(path, 3600) when the user clicks Download. The signed URL expires after 1 hour, preventing permanent public access.
- **Using the verification token as the file name** — If someone obtains a signed URL and can guess adjacent certificate IDs, they may be able to reconstruct other certificate paths. The verification token and storage path should be independent. Fix: Use a separate UUID as the certificate ID (the primary key) for the storage path. The verification_token is a different UUID used only for the /verify/ URL. Store both in issued_certificates as separate columns.
- **Blocking the UI during bulk issuance** — Generating 50 PDFs sequentially takes several minutes. If the UI freezes waiting for each Edge Function call to complete, users think the app has crashed. Fix: Show a Progress bar that updates after each certificate is generated. Use async/await with a for loop and update a progress state counter after each iteration. Consider using Promise.allSettled with batches of 5 for faster bulk generation.
- **Not validating CSV column names before issuance** — If the CSV has misspelled column headers (Recipient Name instead of recipient_name), the token replacement produces empty values and certificates show blank fields. Fix: Before showing the issue button, validate that all required columns exist in the CSV headers. Show a clear error message listing which required columns are missing. Map common variations (Recipient Name → recipient_name) in a normalization step.

## Best practices

- Store the complete recipient data as a metadata JSONB column in issued_certificates at issuance time. If you delete the template later, you still have all the data needed to regenerate or audit the certificate.
- Generate and store the verification token as a UUID v4 (gen_random_uuid() in PostgreSQL) at INSERT time using a default column value, not in application code. This ensures uniqueness is enforced at the database level.
- Use signed URLs with short expiry times (1-24 hours) for PDF downloads rather than public bucket access. This prevents certificate PDFs from being permanently accessible via guessed URLs.
- Add an is_revoked flag and a revoked_at timestamp rather than deleting certificates. Revocation is an audit event — you need to know when and why a certificate was invalidated, not just that it no longer exists.
- Rate-limit bulk issuance to avoid overwhelming the Edge Function and email service. A 100ms delay between PDF generation calls and 5 concurrent email sends is safe for most services.
- Include the certificate's unique ID and issue date in the PDF content itself (not just in the database) so the printed certificate can be verified even without internet access.

## Frequently asked questions

### What PDF generation library works in Supabase Edge Functions?

Supabase Edge Functions run on Deno, which has limited native module support. The most reliable approach is to use an external HTML-to-PDF API service like htmlpdfapi.com, CloudConvert, or similar, calling it from the Edge Function via fetch. Store the API key in Cloud tab → Secrets. Alternatively, use a lightweight Deno-compatible PDF library like pdf-lib (importable via esm.sh) for simpler, non-browser-rendered PDFs.

### How many certificates can I generate at once via CSV bulk upload?

Practically, 100-500 certificates per batch is safe. Each PDF generation involves one Edge Function invocation and one Storage upload. Supabase Edge Functions have no strict concurrency limit on the Pro plan, but generating PDFs sequentially takes about 2-5 seconds per certificate depending on the PDF service used. For 100 certificates, expect 3-8 minutes of processing time. Use the Progress bar in the bulk issuance UI so users know the process is still running.

### Can recipients download their certificate without creating an account?

Yes. The /verify/:token page is public and does not require authentication. The Download button generates a signed Supabase Storage URL on the spot. The recipient only needs the verification link (from their email) to access and download their certificate. No account is needed.

### How do I add a custom logo to certificates?

Add an image block to the template with an image URL. For the organization logo, store it in Supabase Storage and reference its public URL in the template_config block. In the Edge Function's buildCertificateHtml function, render image blocks as img tags with the URL. Ask Lovable to add an image block type to the template editor with an Upload button that stores the image in Supabase Storage.

### What happens if I need to revoke a certificate after issuing it?

Set is_revoked = true and record revoked_at and revocation_reason in the issued_certificates table. The /verify/:token page checks is_revoked and shows an 'Invalid Certificate' error for revoked entries. The public RLS policy filters out revoked certificates from the verification query. Add a Revoke button in the admin dashboard with a confirmation Dialog that requires entering a reason.

### Can I customize the verification page with my organization's branding?

Yes. Ask Lovable to update the VerifyCertificate page to fetch your organization name, logo, and brand colors from an organization_settings table. The page can show your logo at the top and use your brand colors for the verification result alert. This requires no authentication changes since the page is already public.

### How do I handle certificate templates in multiple languages?

Add a language field to certificate_templates (en, es, fr, etc.) and create separate templates for each language. When issuing in bulk, add a language column to the CSV and select the appropriate template based on that column. The simpler alternative is to create one template per language and let the issuer choose the correct template from the dropdown.

### Can I see help from a professional for custom certificate designs?

RapidDev builds production Lovable apps including certificate systems with custom PDF templates, advanced verification workflows, and integration with LMS platforms. Reach out if you need a fully branded certificate generator beyond this guide.

---

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