# How to Build a Resume Builder Backend with Lovable

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

## TL;DR

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.

## Before you start

- 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

## Step-by-step guide

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

```
Build a resume builder. Create these Supabase tables:

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

- resume_summary: id, resume_id (FK resumes UNIQUE), content (text, 2-4 sentence professional summary), created_at

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

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

- resume_skills: id, resume_id (FK resumes), category (text e.g. 'Programming Languages'), skills (text array), sort_order (int), created_at

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

RLS: 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()).

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

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

```
Build the resume editor at src/pages/ResumeEditor.tsx:

1. Layout: editing panel (left, 45%), preview panel (right, 55%)
2. Editing panel with Tabs for each section: Header, Summary, Experience, Education, Skills, Projects
3. Header tab: form for full_name, email, phone, location, website_url, linkedin_url, github_url (all auto-saving on blur)
4. Experience tab:
   - List of experience cards showing company + title + date range
   - '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)
   - Drag handle on each card to reorder (updates sort_order)
   - Edit and delete buttons per card
5. Similar pattern for Education, Skills, and Projects tabs
6. Preview panel: renders the selected template component with the current resume data. Updates on every change using react-query with a 500ms debounce
7. Template selector: small thumbnail Cards above the preview showing all available templates. Clicking one updates resumes.selected_template and re-renders the preview
```

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

### 3. Build the template components

Create multiple React template components that each accept the same resume data props and render different visual layouts.

```
// src/components/resume-templates/MinimalTemplate.tsx
import { Separator } from '@/components/ui/separator'
import { Badge } from '@/components/ui/badge'
import type { ResumeData } from '@/types/resume'
import { format } from 'date-fns'

interface Props { data: ResumeData; isPdfMode?: boolean }

export function MinimalTemplate({ data, isPdfMode = false }: Props) {
  const { resume, experiences, education, skills, projects, summary } = data
  const dateRange = (start: string, end: string | null, isCurrent: boolean) =>
    `${format(new Date(start), 'MMM yyyy')} — ${isCurrent || !end ? 'Present' : format(new Date(end), 'MMM yyyy')}`

  return (
    <div className={`bg-white text-gray-900 font-sans ${isPdfMode ? 'p-8' : 'p-6'} max-w-[794px]`}>
      <div className="text-center mb-6">
        <h1 className="text-3xl font-bold tracking-tight">{resume.full_name}</h1>
        <div className="flex justify-center gap-4 text-sm text-gray-500 mt-1 flex-wrap">
          {resume.email && <span>{resume.email}</span>}
          {resume.phone && <span>{resume.phone}</span>}
          {resume.location && <span>{resume.location}</span>}
          {resume.linkedin_url && <a href={resume.linkedin_url} className="text-blue-600">{resume.linkedin_url.replace('https://linkedin.com/in/', 'linkedin.com/in/')}</a>}
        </div>
      </div>
      {summary && (
        <>
          <h2 className="text-lg font-semibold uppercase tracking-wide mb-2">Summary</h2>
          <Separator className="mb-2" />
          <p className="text-sm leading-relaxed mb-4">{summary.content}</p>
        </>
      )}
      {experiences.length > 0 && (
        <>
          <h2 className="text-lg font-semibold uppercase tracking-wide mb-2">Experience</h2>
          <Separator className="mb-3" />
          <div className="space-y-4 mb-4">
            {experiences.map((exp) => (
              <div key={exp.id}>
                <div className="flex justify-between items-start">
                  <div>
                    <span className="font-semibold">{exp.title}</span> <span className="text-gray-600">at {exp.company}</span>
                    {exp.location && <span className="text-gray-500 text-sm ml-2">· {exp.location}</span>}
                  </div>
                  <span className="text-sm text-gray-500 whitespace-nowrap">{dateRange(exp.start_date, exp.end_date, exp.is_current)}</span>
                </div>
                <ul className="mt-1 space-y-0.5">
                  {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>)}
                </ul>
              </div>
            ))}
          </div>
        </>
      )}
      {skills.length > 0 && (
        <>
          <h2 className="text-lg font-semibold uppercase tracking-wide mb-2">Skills</h2>
          <Separator className="mb-3" />
          <div className="space-y-1 mb-4">
            {skills.map((s) => (
              <div key={s.id} className="flex gap-2 text-sm">
                <span className="font-medium w-36 shrink-0">{s.category}:</span>
                <span className="text-gray-600">{s.skills.join(', ')}</span>
              </div>
            ))}
          </div>
        </>
      )}
    </div>
  )
}
```

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

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

```
Build two features:

1. PDF generation Edge Function at supabase/functions/generate-resume-pdf/index.ts:
   - Authenticate the user via Authorization header
   - Accept { resumeId, templateName } in request body
   - Fetch all resume data using the resume_full_data view (service_role client)
   - Build the HTML for the selected template using string interpolation (not React — Deno can't render JSX)
   - Call the HTML-to-PDF API with the HTML and A4 dimensions (794x1123px)
   - Upload the PDF to Supabase Storage at resumes/{userId}/{resumeId}/{templateName}.pdf
   - Return a signed URL valid for 1 hour

2. Public sharing feature:
   - Add a 'Share Resume' toggle in the editor header
   - 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_slug
   - Create a public route /resume/:slug that fetches resume data WHERE public_slug = slug AND is_public = true (no auth)
   - Render using the resume's selected_template
   - 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)
   - Show a 'Copy Link' Button that copies the public URL to clipboard
```

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

File: `src/types/resume.ts`

```typescript
export interface Resume {
  id: string
  user_id: string
  title: string
  full_name: string
  email: string
  phone: string | null
  location: string | null
  website_url: string | null
  linkedin_url: string | null
  github_url: string | null
  is_public: boolean
  public_slug: string | null
  selected_template: 'minimal' | 'modern' | 'sidebar' | 'compact'
  created_at: string
  updated_at: string
}

export interface ResumeExperience {
  id: string
  resume_id: string
  company: string
  title: string
  location: string | null
  start_date: string
  end_date: string | null
  is_current: boolean
  bullets: string[]
  sort_order: number
}

export interface ResumeEducation {
  id: string
  resume_id: string
  institution: string
  degree: string
  field_of_study: string
  gpa: string | null
  start_date: string
  end_date: string
  highlights: string[]
  sort_order: number
}

export interface ResumeSkill {
  id: string
  resume_id: string
  category: string
  skills: string[]
  sort_order: number
}

export interface ResumeProject {
  id: string
  resume_id: string
  name: string
  description: string
  tech_stack: string[]
  url: string | null
  start_date: string | null
  end_date: string | null
  bullets: string[]
  sort_order: number
}

export interface ResumeSummary {
  id: string
  resume_id: string
  content: string
}

export interface ResumeData {
  resume: Resume
  summary: ResumeSummary | null
  experiences: ResumeExperience[]
  education: ResumeEducation[]
  skills: ResumeSkill[]
  projects: ResumeProject[]
}
```

## Common mistakes

- **Trying to render React components inside the Edge Function for PDF generation** — Deno-based Edge Functions cannot execute React's JSX renderer. Attempting to import and render a React component in a Deno function results in module resolution errors. Fix: 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.
- **Storing the full resume as a single large JSONB blob** — A JSONB blob is hard to query, index, and update. If you want to count how many people have Python as a skill, or search resumes by company name, a denormalized blob makes these queries nearly impossible. Fix: 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.
- **Not setting sort_order correctly after adding a new item** — If all new items get sort_order = 0, dragging to reorder becomes unpredictable and ties between items cause inconsistent rendering. Fix: 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.
- **Using CSS classes with Tailwind in the PDF-generated HTML** — Tailwind CSS classes are meaningless in a standalone HTML string unless you include the full Tailwind stylesheet. An HTML string with className='text-gray-500' renders without any styling. Fix: 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.

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

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/resume-builder-backend
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/resume-builder-backend
