Skip to main content
RapidDev - Software Development Agency

How to Build a Certificate Generator with Lovable

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.

What you'll build

  • Certificate template editor with live preview using CSS-in-JS styling
  • Field mapping system: placeholder tokens like {{recipient_name}} replaced with actual data at generation time
  • PDF generation Edge Function using Deno and a PDF library with the template rendered as HTML
  • Bulk issuance from CSV upload — parse recipients, generate a certificate for each, email via Resend
  • Issued certificates table with UUID verification tokens per certificate
  • Public verification page at /verify/UUID that shows certificate authenticity without requiring login
  • Certificate management dashboard showing all issued certs with revocation capability
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read2–2.5 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
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.

What you're building

A certificate generator has two sides: the admin side (create templates, issue certificates) and the public side (verify certificates by UUID). Templates are stored as a JSON configuration with layout fields (background color, font, border style) and a content array of blocks (text, logo, signature line). Each block has a position, size, font settings, and a value field that can be a static string or a {{token}}.

At generation time, the Edge Function receives a template ID and a recipient data object. It replaces tokens in the template content with recipient data, renders the result as HTML, and converts it to PDF using a compatible library. The PDF is saved to Supabase Storage and the file path is stored in issued_certificates. The verification token is a UUID generated at INSERT time.

The bulk issuance flow parses a CSV file on the frontend, validates required columns (recipient_name, email at minimum), shows a preview DataTable, and then calls the Edge Function in a loop with a small delay between calls to avoid rate limits. Progress is shown with a Progress bar component.

Final result

A complete certificate generation system with live template preview, PDF generation, bulk issuance via CSV, and a public verification page.

Tech stack

LovableFull-stack app generation
SupabaseDatabase, Auth, Storage, Edge Functions
Supabase StoragePDF file storage
ResendEmail delivery for bulk issuance
shadcn/uiCards, Dialog, DataTable, Badge
react-hook-form + zodTemplate and issuance forms

Prerequisites

  • 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

Build steps

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.

prompt.txt
1Build a certificate generator app. Create these Supabase tables:
2
3- 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
4- 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
5
6The template_config jsonb structure:
7{
8 layout: { background_color, border_color, border_width, font_family, width_px, height_px },
9 blocks: [{ id, type: text|image|line, x, y, width, height, content, font_size, font_weight, color, align, token_key }]
10}
11
12RLS:
13- certificate_templates: user_id = auth.uid() for all operations
14- issued_certificates: user_id = auth.uid() for writes, allow public SELECT WHERE is_revoked = false (for verification)
15
16Create 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.

prompt.txt
1Build the template editor at src/pages/TemplateEditor.tsx:
2
31. Split layout: form panel (left, 40%), preview panel (right, 60%)
42. Form panel sections:
5 - 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)
6 - Content blocks list: each block shows its type icon, token_key if set, and Edit/Delete buttons
7 - 'Add Text Block' Button: opens inline editor for that block's content, font size, position, alignment
8 - Token reference: show available tokens as Badge chips: {{recipient_name}}, {{course_name}}, {{issue_date}}, {{certificate_id}}, plus any custom tokens the user defines
93. Preview panel:
10 - Render the certificate as a div with inline styles from template_config.layout
11 - Render each block as an absolutely-positioned div within the certificate div
12 - Replace {{token}} placeholders with sample data for preview: recipient_name = 'Jane Doe', course_name = 'Your Course Name', issue_date = today
134. 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
1// supabase/functions/generate-certificate/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4
5const corsHeaders = {
6 'Access-Control-Allow-Origin': '*',
7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
8}
9
10serve(async (req: Request) => {
11 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
12
13 const supabase = createClient(
14 Deno.env.get('SUPABASE_URL') ?? '',
15 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
16 )
17
18 const { templateId, recipientData, certificateId } = await req.json()
19
20 const { data: template } = await supabase
21 .from('certificate_templates')
22 .select('template_config')
23 .eq('id', templateId)
24 .single()
25
26 if (!template) {
27 return new Response(JSON.stringify({ error: 'Template not found' }), { status: 404, headers: corsHeaders })
28 }
29
30 const config = template.template_config
31 let html = buildCertificateHtml(config, { ...recipientData, certificate_id: certificateId })
32
33 const pdfResponse = await fetch('https://api.htmlpdfapi.com/convert', {
34 method: 'POST',
35 headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${Deno.env.get('PDF_API_KEY')}` },
36 body: JSON.stringify({ html, width: config.layout.width_px, height: config.layout.height_px }),
37 })
38
39 if (!pdfResponse.ok) {
40 return new Response(JSON.stringify({ error: 'PDF generation failed' }), { status: 500, headers: corsHeaders })
41 }
42
43 const pdfBuffer = await pdfResponse.arrayBuffer()
44 const fileName = `certificates/${certificateId}.pdf`
45
46 const { error: uploadError } = await supabase.storage.from('certificates').upload(fileName, pdfBuffer, {
47 contentType: 'application/pdf',
48 upsert: true,
49 })
50
51 if (uploadError) {
52 return new Response(JSON.stringify({ error: uploadError.message }), { status: 500, headers: corsHeaders })
53 }
54
55 await supabase.from('issued_certificates').update({ pdf_url: fileName }).eq('id', certificateId)
56
57 return new Response(JSON.stringify({ pdfUrl: fileName }), { headers: corsHeaders })
58})
59
60function buildCertificateHtml(config: any, data: Record<string, string>): string {
61 const { layout, blocks } = config
62 const blockHtml = blocks.map((b: any) => {
63 const content = b.content.replace(/\{\{(\w+)\}\}/g, (_: string, key: string) => data[key] ?? `{{${key}}}`)
64 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>`
65 }).join('')
66 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>`
67}

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.

prompt.txt
1Build two features:
2
31. Bulk Issuance page at src/pages/BulkIssue.tsx:
4 - File Input for CSV upload. Expected columns: recipient_name (required), email (required), course_name (required), and any custom token columns
5 - Parse CSV on the frontend using a simple split('\n').map(row => row.split(',')) or ask Lovable to use the papaparse library
6 - Show parsed rows in a DataTable preview with a 'recipient_name', 'email', 'course_name' column validation check
7 - Template Select: choose which template to use
8 - Issue Date DatePicker (default today)
9 - 'Issue All' Button: shows a Progress bar. For each row:
10 a. Insert a row into issued_certificates (get the new id and verification_token)
11 b. Call the generate-certificate Edge Function with templateId, row data, certificateId
12 c. Send email via Resend Edge Function with a link to verify/TOKEN and a PDF download link
13 - Show a results summary: X succeeded, Y failed with error details
14
152. Public verification page at src/pages/VerifyCertificate.tsx (route: /verify/:token):
16 - Fetch issued_certificates WHERE verification_token = token AND is_revoked = false
17 - If found: show a green success Banner with the recipient name, course name, issue date, and 'This certificate is valid'
18 - If not found or revoked: show a red error state 'Certificate not found or has been revoked'
19 - Show a 'Download PDF' Button that generates a signed URL from Supabase Storage
20 - 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

src/pages/VerifyCertificate.tsx
1import { useParams } from 'react-router-dom'
2import { useQuery } from '@tanstack/react-query'
3import { supabase } from '@/integrations/supabase/client'
4import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
5import { Badge } from '@/components/ui/badge'
6import { Button } from '@/components/ui/button'
7import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
8import { CheckCircle, XCircle, Download } from 'lucide-react'
9import { format } from 'date-fns'
10
11export default function VerifyCertificate() {
12 const { token } = useParams<{ token: string }>()
13
14 const { data: cert, isLoading } = useQuery({
15 queryKey: ['verify-cert', token],
16 queryFn: async () => {
17 if (!token) throw new Error('No token')
18 const { data, error } = await supabase
19 .from('issued_certificates')
20 .select('recipient_name, course_name, issue_date, expiry_date, is_revoked, pdf_url')
21 .eq('verification_token', token)
22 .single()
23 if (error) return null
24 return data
25 },
26 enabled: !!token,
27 })
28
29 async function handleDownload() {
30 if (!cert?.pdf_url) return
31 const { data } = await supabase.storage.from('certificates').createSignedUrl(cert.pdf_url, 3600)
32 if (data?.signedUrl) window.open(data.signedUrl, '_blank')
33 }
34
35 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>
36
37 return (
38 <div className="flex min-h-screen items-center justify-center bg-muted/40 p-4">
39 <Card className="w-full max-w-lg">
40 <CardHeader>
41 <CardTitle className="text-center">Certificate Verification</CardTitle>
42 </CardHeader>
43 <CardContent className="space-y-4">
44 {!cert || cert.is_revoked ? (
45 <Alert variant="destructive">
46 <XCircle className="h-4 w-4" />
47 <AlertTitle>Invalid Certificate</AlertTitle>
48 <AlertDescription>This certificate could not be found or has been revoked.</AlertDescription>
49 </Alert>
50 ) : (
51 <>
52 <Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950">
53 <CheckCircle className="h-4 w-4 text-green-600" />
54 <AlertTitle className="text-green-700 dark:text-green-300">Valid Certificate</AlertTitle>
55 <AlertDescription className="text-green-600 dark:text-green-400">This certificate is authentic and has not been revoked.</AlertDescription>
56 </Alert>
57 <div className="space-y-2 rounded-lg border p-4">
58 <div className="flex justify-between"><span className="text-muted-foreground text-sm">Recipient</span><span className="font-medium">{cert.recipient_name}</span></div>
59 <div className="flex justify-between"><span className="text-muted-foreground text-sm">Course</span><span className="font-medium">{cert.course_name}</span></div>
60 <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>
61 {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>}
62 </div>
63 {cert.pdf_url && <Button onClick={handleDownload} className="w-full"><Download className="mr-2 h-4 w-4" />Download Certificate</Button>}
64 </>
65 )}
66 </CardContent>
67 </Card>
68 </div>
69 )
70}

Customization ideas

Certificate expiry and renewal reminders

Add expiry_date to issued certificates and a scheduled Edge Function that runs daily. 30 days before expiry, send a reminder email via Resend to the recipient. On the expiry date, automatically set is_revoked = true if not renewed. Show expiry status badges on the certificate management dashboard.

Digital signature with organization logo

Add an organization_settings table with logo_url and signature_image_url (from Supabase Storage). Include a signature block in certificate templates that renders the signatory's name, title, and signature image. This adds visual authority to generated certificates.

Certificate portfolio page for recipients

Add a /certificates/:email page where recipients can see all certificates issued to their email address. Query issued_certificates WHERE recipient_email = email AND is_revoked = false. No login required — email is the identifier. Show a gallery of certificate thumbnails with download buttons.

Multi-language certificate support

Add a language field to certificate templates and a translations JSONB column that maps token keys to translated labels. The generation function checks the recipient's locale and uses the appropriate translations for static text like 'Certificate of Completion' while keeping the recipient's name unchanged.

Common pitfalls

Pitfall: Making the certificates Storage bucket public

How to avoid: 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.

Pitfall: Using the verification token as the file name

How to avoid: 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.

Pitfall: Blocking the UI during bulk issuance

How to avoid: 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.

Pitfall: Not validating CSV column names before issuance

How to avoid: 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.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a certificate generator where certificates are stored in Supabase Storage as PDFs and have a UUID verification token. Help me design the public verification system. The /verify/:token page should work without authentication. What Supabase RLS policy allows public SELECT on issued_certificates for the verification query but prevents anonymous users from seeing all certificates? Write the exact SQL for the RLS policy.

Lovable Prompt

Add a certificate template gallery page to the app. Show all certificate_templates as Cards in a 2-column grid. Each Card shows the preview_image_url as a thumbnail, the template name, number of certificates issued (count from issued_certificates), and a 'Use Template' Button. Add a 'Duplicate' Button that creates a copy of a template with the name 'Copy of ...' and the same template_config. Add a search Input that filters templates by name.

Build Prompt

In Supabase, create a function issue_certificate(p_template_id uuid, p_recipient_name text, p_recipient_email text, p_course_name text, p_metadata jsonb) that: 1) inserts a row into issued_certificates with a generated verification_token, 2) returns the new certificate id, verification_token, and the public verification URL '/verify/' || verification_token. Make this a SECURITY DEFINER function so it can be called from Edge Functions with the anon key but still bypasses RLS for the insert.

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.

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.