Build a resume builder backend in Lovable with structured section tables (experience, education, skills, projects), multiple visual templates that consume the same data, PDF generation via a Supabase Edge Function, and template preview Cards so users can switch templates without re-entering information.
What you're building
A resume builder separates data from presentation. The data (your experience, education, skills) is stored in structured tables. Multiple templates are React components that accept the same data as props and render it differently — one template might be a clean single-column layout, another a two-column design with a colored sidebar. Users switch between templates without losing any data.
The data model uses a resumes table as the root object, with separate tables for each section: resume_experiences, resume_education, resume_skills, resume_projects, and resume_summary. Each table has a resume_id FK and a sort_order for drag-to-reorder. This normalized structure makes it easy to add, edit, or remove individual items without touching the rest of the resume.
PDF generation is handled by a Supabase Edge Function. It receives the resume ID and selected template name, fetches all resume data, renders the template as an HTML string using the same logic as the React component but as plain string interpolation (since Deno cannot run React), and converts to PDF via an HTML-to-PDF API. The PDF is stored in Supabase Storage and a signed URL is returned for download.
Final result
A resume builder where data is entered once and presented through multiple downloadable templates, with live preview and PDF export.
Tech stack
Prerequisites
- Lovable Pro account for Edge Function generation
- Supabase project with Storage enabled
- An HTML-to-PDF API key (htmlpdfapi.com free tier works)
- Supabase URL, service role key, and PDF API key saved to Cloud tab → Secrets
Build steps
Create the normalized resume data schema
Set up the resume tables. The normalized structure is the key architectural decision — it allows templates to read the same structured data without any per-template database changes.
1Build a resume builder. Create these Supabase tables:23- resumes: id, user_id, title (e.g. 'Software Engineer Resume'), full_name, email, phone, location, website_url, linkedin_url, github_url, is_public (bool default false), public_slug (text, UNIQUE, for shareable link), selected_template (text default 'minimal'), created_at, updated_at45- resume_summary: id, resume_id (FK resumes UNIQUE), content (text, 2-4 sentence professional summary), created_at67- resume_experiences: id, resume_id (FK resumes), company, title, location, start_date (date), end_date (date nullable, null = present), is_current (bool default false), bullets (text array, list of achievement bullets), sort_order (int), created_at89- resume_education: id, resume_id (FK resumes), institution, degree, field_of_study, gpa (text nullable), start_date (date), end_date (date), highlights (text array), sort_order (int), created_at1011- resume_skills: id, resume_id (FK resumes), category (text e.g. 'Programming Languages'), skills (text array), sort_order (int), created_at1213- resume_projects: id, resume_id (FK resumes), name, description, tech_stack (text array), url (text nullable), start_date (date nullable), end_date (date nullable), bullets (text array), sort_order (int), created_at1415RLS: all tables require user_id = auth.uid() on resumes table; access to section tables via resume_id IN (SELECT id FROM resumes WHERE user_id = auth.uid()).1617Create a view resume_full_data that joins all section tables for a given resume_id — used by the PDF generator.Pro tip: Ask Lovable to create an import_from_linkedin function stub in the UI — a text area where users can paste their LinkedIn 'About' section and work history as plain text, and a prompt that asks Lovable's AI to parse and pre-fill the resume sections. This dramatically reduces time-to-first-draft.
Expected result: All six tables are created with the cascade-style RLS. The resume_full_data view is created. The app loads with a resume list page and a 'Create New Resume' button.
Build the section editors with live preview
Create the resume editing interface. Each section has its own form component. A live preview panel on the right shows the current template rendering as the user types.
1Build the resume editor at src/pages/ResumeEditor.tsx:231. Layout: editing panel (left, 45%), preview panel (right, 55%)42. Editing panel with Tabs for each section: Header, Summary, Experience, Education, Skills, Projects53. Header tab: form for full_name, email, phone, location, website_url, linkedin_url, github_url (all auto-saving on blur)64. Experience tab:7 - List of experience cards showing company + title + date range8 - 'Add Experience' Button opens an inline form with: company, title, location, date range (start_date/end_date DatePickers, 'Currently working here' Checkbox), bullets (dynamic list — type a bullet and press Enter to add another, click x to remove)9 - Drag handle on each card to reorder (updates sort_order)10 - Edit and delete buttons per card115. Similar pattern for Education, Skills, and Projects tabs126. Preview panel: renders the selected template component with the current resume data. Updates on every change using react-query with a 500ms debounce137. Template selector: small thumbnail Cards above the preview showing all available templates. Clicking one updates resumes.selected_template and re-renders the previewPro tip: Ask Lovable to add an AI-powered bullet point improver: a magic wand icon next to each experience bullet that sends the raw text to an Edge Function calling Claude or OpenAI to rewrite it using action verbs and quantified achievements.
Expected result: The editor shows the two-panel layout. Adding an experience entry shows it in the live preview panel immediately. Switching templates changes the preview layout while keeping all data.
Build the template components
Create multiple React template components that each accept the same resume data props and render different visual layouts.
1// src/components/resume-templates/MinimalTemplate.tsx2import { Separator } from '@/components/ui/separator'3import { Badge } from '@/components/ui/badge'4import type { ResumeData } from '@/types/resume'5import { format } from 'date-fns'67interface Props { data: ResumeData; isPdfMode?: boolean }89export function MinimalTemplate({ data, isPdfMode = false }: Props) {10 const { resume, experiences, education, skills, projects, summary } = data11 const dateRange = (start: string, end: string | null, isCurrent: boolean) =>12 `${format(new Date(start), 'MMM yyyy')} — ${isCurrent || !end ? 'Present' : format(new Date(end), 'MMM yyyy')}`1314 return (15 <div className={`bg-white text-gray-900 font-sans ${isPdfMode ? 'p-8' : 'p-6'} max-w-[794px]`}>16 <div className="text-center mb-6">17 <h1 className="text-3xl font-bold tracking-tight">{resume.full_name}</h1>18 <div className="flex justify-center gap-4 text-sm text-gray-500 mt-1 flex-wrap">19 {resume.email && <span>{resume.email}</span>}20 {resume.phone && <span>{resume.phone}</span>}21 {resume.location && <span>{resume.location}</span>}22 {resume.linkedin_url && <a href={resume.linkedin_url} className="text-blue-600">{resume.linkedin_url.replace('https://linkedin.com/in/', 'linkedin.com/in/')}</a>}23 </div>24 </div>25 {summary && (26 <>27 <h2 className="text-lg font-semibold uppercase tracking-wide mb-2">Summary</h2>28 <Separator className="mb-2" />29 <p className="text-sm leading-relaxed mb-4">{summary.content}</p>30 </>31 )}32 {experiences.length > 0 && (33 <>34 <h2 className="text-lg font-semibold uppercase tracking-wide mb-2">Experience</h2>35 <Separator className="mb-3" />36 <div className="space-y-4 mb-4">37 {experiences.map((exp) => (38 <div key={exp.id}>39 <div className="flex justify-between items-start">40 <div>41 <span className="font-semibold">{exp.title}</span> <span className="text-gray-600">at {exp.company}</span>42 {exp.location && <span className="text-gray-500 text-sm ml-2">· {exp.location}</span>}43 </div>44 <span className="text-sm text-gray-500 whitespace-nowrap">{dateRange(exp.start_date, exp.end_date, exp.is_current)}</span>45 </div>46 <ul className="mt-1 space-y-0.5">47 {exp.bullets.map((b, i) => <li key={i} className="text-sm text-gray-700 before:content-['•'] before:mr-2 before:text-gray-400">{b}</li>)}48 </ul>49 </div>50 ))}51 </div>52 </>53 )}54 {skills.length > 0 && (55 <>56 <h2 className="text-lg font-semibold uppercase tracking-wide mb-2">Skills</h2>57 <Separator className="mb-3" />58 <div className="space-y-1 mb-4">59 {skills.map((s) => (60 <div key={s.id} className="flex gap-2 text-sm">61 <span className="font-medium w-36 shrink-0">{s.category}:</span>62 <span className="text-gray-600">{s.skills.join(', ')}</span>63 </div>64 ))}65 </div>66 </>67 )}68 </div>69 )70}Expected result: The MinimalTemplate renders the resume data as a clean, print-ready layout. Switching to this template in the editor preview shows the minimal design.
Add PDF generation and shareable links
Build the PDF generation Edge Function and the public sharing feature. When a user makes their resume public, it gets a slug-based URL anyone can view.
1Build two features:231. PDF generation Edge Function at supabase/functions/generate-resume-pdf/index.ts:4 - Authenticate the user via Authorization header5 - Accept { resumeId, templateName } in request body6 - Fetch all resume data using the resume_full_data view (service_role client)7 - Build the HTML for the selected template using string interpolation (not React — Deno can't render JSX)8 - Call the HTML-to-PDF API with the HTML and A4 dimensions (794x1123px)9 - Upload the PDF to Supabase Storage at resumes/{userId}/{resumeId}/{templateName}.pdf10 - Return a signed URL valid for 1 hour11122. Public sharing feature:13 - Add a 'Share Resume' toggle in the editor header14 - When enabled: set resumes.is_public = true, generate a URL-safe slug from full_name + random 6-char suffix (e.g. 'jane-doe-a3f9k2'), store in public_slug15 - Create a public route /resume/:slug that fetches resume data WHERE public_slug = slug AND is_public = true (no auth)16 - Render using the resume's selected_template17 - Add a 'Download PDF' Button on the public page that calls the generate-resume-pdf Edge Function with the resume owner's service_role (via a special public download endpoint)18 - Show a 'Copy Link' Button that copies the public URL to clipboardExpected result: Clicking 'Download PDF' generates a PDF and triggers a browser download. Enabling sharing shows a shareable URL. Opening that URL without being logged in shows the resume in its selected template.
Complete code
1export interface Resume {2 id: string3 user_id: string4 title: string5 full_name: string6 email: string7 phone: string | null8 location: string | null9 website_url: string | null10 linkedin_url: string | null11 github_url: string | null12 is_public: boolean13 public_slug: string | null14 selected_template: 'minimal' | 'modern' | 'sidebar' | 'compact'15 created_at: string16 updated_at: string17}1819export interface ResumeExperience {20 id: string21 resume_id: string22 company: string23 title: string24 location: string | null25 start_date: string26 end_date: string | null27 is_current: boolean28 bullets: string[]29 sort_order: number30}3132export interface ResumeEducation {33 id: string34 resume_id: string35 institution: string36 degree: string37 field_of_study: string38 gpa: string | null39 start_date: string40 end_date: string41 highlights: string[]42 sort_order: number43}4445export interface ResumeSkill {46 id: string47 resume_id: string48 category: string49 skills: string[]50 sort_order: number51}5253export interface ResumeProject {54 id: string55 resume_id: string56 name: string57 description: string58 tech_stack: string[]59 url: string | null60 start_date: string | null61 end_date: string | null62 bullets: string[]63 sort_order: number64}6566export interface ResumeSummary {67 id: string68 resume_id: string69 content: string70}7172export interface ResumeData {73 resume: Resume74 summary: ResumeSummary | null75 experiences: ResumeExperience[]76 education: ResumeEducation[]77 skills: ResumeSkill[]78 projects: ResumeProject[]79}Customization ideas
ATS score checker
Add an ATS (Applicant Tracking System) analysis feature. An Edge Function calls an AI API (Claude or OpenAI) with the resume text and a job description the user pastes. The AI returns a compatibility score and specific suggestions: missing keywords, weak action verbs, and formatting issues that ATS systems struggle with.
Version history
Add a resume_versions table that stores a JSONB snapshot of the full resume on each save. Allow users to browse version history and restore a previous version. Show a timeline of versions with timestamps and a diff view highlighting what changed between versions.
Job application tracker
Add a job_applications table linked to resumes. Track which resume version was used, application date, company, role, status (applied/interview/offer/rejected), and notes. Show a Kanban board of applications. Link applications to specific resume versions so users know exactly which resume they sent.
Cover letter generator
Add a cover letters table linked to resumes. An Edge Function accepts the resume ID and job description, then calls Claude or OpenAI to generate a personalized cover letter that references specific experiences from the resume. Users can edit the generated draft and save it alongside their resume.
Common pitfalls
Pitfall: Trying to render React components inside the Edge Function for PDF generation
How to avoid: Build two parallel implementations: the React component for the live preview in the browser, and a plain HTML string builder function for the Edge Function. Both use the same data and produce visually identical output, but the Edge Function version uses string interpolation instead of JSX.
Pitfall: Storing the full resume as a single large JSONB blob
How to avoid: Use the normalized schema from Step 1 with separate tables for each section type. This enables powerful queries (find all users with React experience at companies of 100+ employees) and makes partial updates (change just one experience entry) efficient.
Pitfall: Not setting sort_order correctly after adding a new item
How to avoid: When inserting a new section item, set sort_order to MAX(sort_order) + 1 for that resume_id. On drag-end, update all affected items' sort_order values in a single batch upsert. Ask Lovable to implement the reorder logic using a fractional indexing approach for fewer database writes.
Pitfall: Using CSS classes with Tailwind in the PDF-generated HTML
How to avoid: Use inline styles (style='color: #6b7280') in the PDF HTML string builder function. Alternatively, include a minimal CSS stylesheet in the HTML string's head section. The live preview uses Tailwind classes normally since Tailwind is loaded in the browser.
Best practices
- Separate data from presentation from the start. The normalized table schema means users can switch templates without any data migration, and you can add new templates without changing the database.
- Auto-save section forms on blur rather than requiring explicit save button clicks. Resume editing is frequent and accidental data loss is very frustrating. Use react-hook-form's watch() and debounced Supabase updates.
- Store PDFs in a user-scoped path in Supabase Storage: resumes/{user_id}/{resume_id}/{template}.pdf. This makes it easy to list and delete all PDFs for a user during account deletion.
- Add a char limit on the bullets array items (e.g. 200 chars per bullet). Resume bullets should be concise. Enforce this in the Zod schema for the experience form with z.string().max(200).
- Generate the public_slug from the user's name using a slugify function: lowercase, replace spaces with hyphens, remove special characters, append a random 6-character suffix. Ensure uniqueness by checking if the slug exists before saving. Use a Supabase RPC function for the generation to handle retries on collision.
- Test all templates at A4 dimensions (794px x 1123px) in print preview mode. Template layouts that look good on screen often break at print dimensions. Ask Lovable to add a 'Print Preview' mode that shows the template at A4 size with a shadow border.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a resume builder where I have normalized tables for experiences, education, skills, and projects. I need to convert the live React template (using Tailwind CSS classes) to a PDF-safe HTML string builder function for a Deno Edge Function. Help me write a TypeScript function buildResumeHtml(data: ResumeData, template: 'minimal' | 'modern'): string that produces valid HTML with inline styles for the minimal template. Include the full_name, email, experiences with date ranges, and skills sections.
Add a 'Suggestions' panel to the resume editor that appears on the right side when the user is editing their experience section. The panel analyzes the current bullets and suggests improvements: checks for action verbs at the start, suggests adding quantified results ('increased sales by X%' instead of 'increased sales'), and flags bullets that are too long (>150 chars). Use pattern matching in TypeScript — no AI API needed for basic suggestions. Highlight flagged bullets in yellow in the editing list.
In Supabase, create a function get_resume_full_data(p_resume_id uuid) that returns all resume data as a single JSONB object for use in the PDF generation Edge Function. The object should have this shape: { resume: {...}, summary: {...} | null, experiences: [...], education: [...], skills: [...], projects: [...] }. Use row_to_json and json_agg to build the nested structure. The function should be SECURITY DEFINER so the Edge Function can call it with the service_role key.
Frequently asked questions
How do I add a new template design?
Create a new React component at src/components/resume-templates/YourTemplate.tsx that accepts ResumeData as props and renders it differently from the existing templates. Add the template name to the selected_template union type in resume.ts. Add a thumbnail preview Card in the template selector UI. Create a corresponding HTML string builder in the PDF Edge Function for the same template. The data model never changes — only the presentation layer.
Can users have multiple resumes for different job types?
Yes. Each user can have multiple rows in the resumes table. The resumes list page shows all of them. A user might have 'Software Engineer Resume', 'Product Manager Resume', and 'Freelance Consultant Resume' each with different content emphasis and selected templates. The Create New Resume button creates a blank new resume.
What PDF quality can I expect from HTML-to-PDF conversion?
HTML-to-PDF services using headless Chromium (like htmlpdfapi.com or WeasyPrint) produce high-quality output that matches what you see in the browser preview. Fonts render correctly, borders are crisp, and the layout is pixel-accurate. The main limitation is web fonts — use system fonts (Georgia, Arial, Times New Roman) in the PDF HTML for guaranteed rendering. Google Fonts can be referenced via @import in the style tag but may not load in all PDF services.
How do I make the resume ATS-friendly?
ATS (Applicant Tracking System) software has trouble parsing PDFs with complex layouts, columns, tables, and graphics. The Minimal template in this guide uses a single-column layout which is the safest for ATS. Add an 'ATS Mode' toggle that strips all decorative elements and renders plain text only. The customization idea in this guide describes adding an AI-powered ATS score checker for more detailed analysis.
Is the public shareable resume link indexed by Google?
By default, Lovable apps allow search engine crawling. If you want public resume pages to be indexed (for personal branding), no action is needed. If you want them private from search engines, add a meta robots noindex tag to the public resume page: add a noIndex prop to the page's head element. You can also add canonical URLs per resume page for better SEO if you want them indexed.
Can I import data from an existing resume PDF?
Not directly in the base build. Adding PDF import requires an Edge Function that calls an AI API (Claude or GPT-4) with the extracted PDF text to parse it into structured JSON matching your resume schema. This is the 'Resume Parser' related build in this guide. Ask Lovable to add an 'Import from PDF' button after the base resume builder is working.
How do I handle special characters and Unicode in PDF output?
Include a UTF-8 meta charset in the HTML string: a UTF-8 meta charset tag. Use a font that supports the required character ranges — the system fonts Georgia and Arial support most Latin, Cyrillic, and Greek characters. For East Asian characters or Arabic, you'll need to specify a Unicode-capable font and include it in the PDF service request. Test your template with sample data containing the special characters your users will need.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation