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
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
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.
1Build a certificate generator app. Create these Supabase tables:23- 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_at4- 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_at56The 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}1112RLS:13- certificate_templates: user_id = auth.uid() for all operations14- issued_certificates: user_id = auth.uid() for writes, allow public SELECT WHERE is_revoked = false (for verification)1516Create 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.
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.
1Build the template editor at src/pages/TemplateEditor.tsx:231. 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 buttons7 - 'Add Text Block' Button: opens inline editor for that block's content, font size, position, alignment8 - Token reference: show available tokens as Badge chips: {{recipient_name}}, {{course_name}}, {{issue_date}}, {{certificate_id}}, plus any custom tokens the user defines93. Preview panel:10 - Render the certificate as a div with inline styles from template_config.layout11 - Render each block as an absolutely-positioned div within the certificate div12 - Replace {{token}} placeholders with sample data for preview: recipient_name = 'Jane Doe', course_name = 'Your Course Name', issue_date = today134. Save Button: upserts template_config to certificate_templates, updates preview_image_url by calling html2canvas on the preview divExpected 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.
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.
1// supabase/functions/generate-certificate/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const corsHeaders = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',8}910serve(async (req: Request) => {11 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1213 const supabase = createClient(14 Deno.env.get('SUPABASE_URL') ?? '',15 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''16 )1718 const { templateId, recipientData, certificateId } = await req.json()1920 const { data: template } = await supabase21 .from('certificate_templates')22 .select('template_config')23 .eq('id', templateId)24 .single()2526 if (!template) {27 return new Response(JSON.stringify({ error: 'Template not found' }), { status: 404, headers: corsHeaders })28 }2930 const config = template.template_config31 let html = buildCertificateHtml(config, { ...recipientData, certificate_id: certificateId })3233 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 })3839 if (!pdfResponse.ok) {40 return new Response(JSON.stringify({ error: 'PDF generation failed' }), { status: 500, headers: corsHeaders })41 }4243 const pdfBuffer = await pdfResponse.arrayBuffer()44 const fileName = `certificates/${certificateId}.pdf`4546 const { error: uploadError } = await supabase.storage.from('certificates').upload(fileName, pdfBuffer, {47 contentType: 'application/pdf',48 upsert: true,49 })5051 if (uploadError) {52 return new Response(JSON.stringify({ error: uploadError.message }), { status: 500, headers: corsHeaders })53 }5455 await supabase.from('issued_certificates').update({ pdf_url: fileName }).eq('id', certificateId)5657 return new Response(JSON.stringify({ pdfUrl: fileName }), { headers: corsHeaders })58})5960function buildCertificateHtml(config: any, data: Record<string, string>): string {61 const { layout, blocks } = config62 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.
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.
1Build two features:231. 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 columns5 - Parse CSV on the frontend using a simple split('\n').map(row => row.split(',')) or ask Lovable to use the papaparse library6 - Show parsed rows in a DataTable preview with a 'recipient_name', 'email', 'course_name' column validation check7 - Template Select: choose which template to use8 - 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, certificateId12 c. Send email via Resend Edge Function with a link to verify/TOKEN and a PDF download link13 - Show a results summary: X succeeded, Y failed with error details14152. Public verification page at src/pages/VerifyCertificate.tsx (route: /verify/:token):16 - Fetch issued_certificates WHERE verification_token = token AND is_revoked = false17 - 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 Storage20 - No authentication required for this pagePro 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
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'1011export default function VerifyCertificate() {12 const { token } = useParams<{ token: string }>()1314 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 supabase19 .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 null24 return data25 },26 enabled: !!token,27 })2829 async function handleDownload() {30 if (!cert?.pdf_url) return31 const { data } = await supabase.storage.from('certificates').createSignedUrl(cert.pdf_url, 3600)32 if (data?.signedUrl) window.open(data.signedUrl, '_blank')33 }3435 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>3637 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation