# How to Build a Resume Builder Backend with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Free
- Last updated: April 2026

## TL;DR

Build a resume builder backend in Replit using Express and PostgreSQL in 1-2 hours. You'll store structured resume data (experience, education, skills, projects) and generate PDF exports using pdfkit. Replit Agent scaffolds the API and React editor with live preview — a Canva Resumes competitor backend.

## Before you start

- A Replit account (Free tier is sufficient)
- No external API keys needed for the core build
- pdfkit npm package (Agent will install it)
- Basic understanding of resume structure (sections, bullet points)

## Step-by-step guide

### 1. Scaffold the project with Replit Agent

Create a new Replit App and paste this prompt. Agent builds the full resume backend with all seven tables and the React editor skeleton.

```
// Build a resume builder backend with Express and PostgreSQL using Drizzle ORM.
// Use Replit Auth for user authentication.
//
// Tables:
// 1. resumes: id serial primary key, user_id text not null, title text default 'Untitled Resume',
//    template_id text default 'classic', is_public boolean default false,
//    created_at timestamp default now(), updated_at timestamp default now()
// 2. personal_info: id serial, resume_id integer references resumes not null unique,
//    full_name text not null, email text, phone text, location text,
//    website text, linkedin text, summary text
// 3. experiences: id serial, resume_id integer references resumes not null,
//    company text not null, title text not null, location text,
//    start_date date not null, end_date date, is_current boolean default false,
//    description text, highlights text[] (bullet points array), position integer default 0
// 4. education: id serial, resume_id integer references resumes not null,
//    institution text not null, degree text not null, field text,
//    start_date date, end_date date, gpa text, highlights text[], position integer default 0
// 5. skills: id serial, resume_id integer references resumes not null,
//    category text not null (e.g. 'Programming Languages'),
//    items text[] not null, position integer default 0
// 6. projects: id serial, resume_id integer references resumes not null,
//    name text not null, description text, url text,
//    technologies text[], position integer default 0
// 7. certifications: id serial, resume_id integer references resumes not null,
//    name text not null, issuer text, date date, url text, position integer default 0
//
// Routes for each section: GET/POST/PUT/DELETE + PATCH reorder
// Plus: POST /api/resumes/:id/export/pdf, GET /api/resumes/:id/preview (HTML),
//       POST /api/resumes/:id/duplicate, GET /api/resumes (list)
//
// Install pdfkit. Bind to 0.0.0.0:3000.
```

> Pro tip: After scaffolding, immediately test the GET /api/resumes/:id/preview route — if the HTML renders in a browser tab, your template logic is working before you wire up the React frontend.

**Expected result:** Running Express app with all seven tables. POST /api/resumes creates a new resume. GET /api/resumes returns the user's resume list.

### 2. Build the full resume retrieval with joined sections

The main data-fetch route returns a complete resume with all sections joined — used both for the editor and for export.

```
const express = require('express');
const { db } = require('../db');
const {
  resumes, personalInfo, experiences, education,
  skills, projects, certifications,
} = require('../schema');
const { eq, and, asc } = require('drizzle-orm');
const { withDbRetry } = require('../lib/retryDb');

const router = express.Router();

router.get('/api/resumes/:id', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const resumeId = parseInt(req.params.id);

  const [resume] = await db.select().from(resumes)
    .where(and(eq(resumes.id, resumeId), eq(resumes.userId, req.user.id)))
    .limit(1);
  if (!resume) return res.status(404).json({ error: 'Resume not found' });

  // Load all sections in parallel
  const [info, exp, edu, sk, proj, certs] = await Promise.all([
    db.select().from(personalInfo).where(eq(personalInfo.resumeId, resumeId)).limit(1),
    db.select().from(experiences).where(eq(experiences.resumeId, resumeId)).orderBy(asc(experiences.position)),
    db.select().from(education).where(eq(education.resumeId, resumeId)).orderBy(asc(education.position)),
    db.select().from(skills).where(eq(skills.resumeId, resumeId)).orderBy(asc(skills.position)),
    db.select().from(projects).where(eq(projects.resumeId, resumeId)).orderBy(asc(projects.position)),
    db.select().from(certifications).where(eq(certifications.resumeId, resumeId)).orderBy(asc(certifications.position)),
  ]);

  return res.json({
    ...resume,
    personalInfo: info[0] || null,
    experiences: exp,
    education: edu,
    skills: sk,
    projects: proj,
    certifications: certs,
  });
});

// Duplicate a resume with all sections
router.post('/api/resumes/:id/duplicate', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const resumeId = parseInt(req.params.id);

  // Get original full resume
  const fullRes = await fetch(`http://localhost:${process.env.PORT || 3000}/api/resumes/${resumeId}`, {
    headers: { cookie: req.headers.cookie },
  }).then(r => r.json());

  if (!fullRes.id) return res.status(404).json({ error: 'Resume not found' });

  // Create new resume
  const [newResume] = await db.insert(resumes).values({
    userId: req.user.id,
    title: fullRes.title + ' (Copy)',
    templateId: fullRes.templateId,
  }).returning();

  const newId = newResume.id;

  // Copy all sections
  if (fullRes.personalInfo) {
    const { id: _id, resumeId: _rid, ...info } = fullRes.personalInfo;
    await db.insert(personalInfo).values({ ...info, resumeId: newId });
  }
  if (fullRes.experiences.length) {
    await db.insert(experiences).values(
      fullRes.experiences.map(({ id: _id, resumeId: _rid, ...e }) => ({ ...e, resumeId: newId }))
    );
  }

  return res.status(201).json({ id: newId, title: newResume.title });
});

module.exports = router;
```

> Pro tip: Use Promise.all() to load all six section tables in parallel — this cuts the total database time from 6 sequential queries to the time of the slowest single query, typically 3-5x faster for a full resume load.

**Expected result:** GET /api/resumes/1 returns a complete resume object with personalInfo, experiences array, education array, skills array, projects array, and certifications array.

### 3. Add the section CRUD routes with position-based reordering

Each section type needs create, update, delete, and reorder endpoints. The reorder route accepts a new position array and updates all affected rows.

```
const express = require('express');
const { db } = require('../db');
const { experiences } = require('../schema');
const { eq, and } = require('drizzle-orm');

const router = express.Router();

router.post('/api/resumes/:resumeId/experiences', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { company, title, location, startDate, endDate, isCurrent, description, highlights } = req.body;
  if (!company || !title || !startDate) {
    return res.status(400).json({ error: 'company, title, and startDate are required' });
  }
  // Get current max position
  const maxPos = await db.execute({
    sql: 'SELECT COALESCE(MAX(position), -1) AS max FROM experiences WHERE resume_id = $1',
    params: [parseInt(req.params.resumeId)],
  });
  const nextPos = (maxPos.rows[0]?.max || -1) + 1;

  const row = await db.insert(experiences).values({
    resumeId: parseInt(req.params.resumeId),
    company, title, location: location || null,
    startDate: new Date(startDate),
    endDate: endDate ? new Date(endDate) : null,
    isCurrent: Boolean(isCurrent),
    description: description || null,
    highlights: highlights || [],
    position: nextPos,
  }).returning();
  return res.status(201).json(row[0]);
});

router.put('/api/resumes/:resumeId/experiences/:id', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { company, title, location, startDate, endDate, isCurrent, description, highlights } = req.body;
  const updated = await db.update(experiences)
    .set({
      ...(company && { company }), ...(title && { title }),
      location: location || null,
      ...(startDate && { startDate: new Date(startDate) }),
      endDate: endDate ? new Date(endDate) : null,
      isCurrent: isCurrent !== undefined ? Boolean(isCurrent) : undefined,
      ...(description !== undefined && { description }),
      ...(highlights && { highlights }),
    })
    .where(eq(experiences.id, parseInt(req.params.id)))
    .returning();
  if (!updated[0]) return res.status(404).json({ error: 'Not found' });
  return res.json(updated[0]);
});

// Reorder: accepts array of {id, position} and batch-updates positions
router.patch('/api/resumes/:resumeId/experiences/reorder', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { order } = req.body; // [{id: 1, position: 0}, {id: 2, position: 1}]
  if (!Array.isArray(order)) return res.status(400).json({ error: 'order array required' });

  await Promise.all(
    order.map(({ id, position }) =>
      db.update(experiences)
        .set({ position: parseInt(position) })
        .where(eq(experiences.id, parseInt(id)))
    )
  );
  return res.json({ reordered: order.length });
});

module.exports = router;
```

> Pro tip: The reorder route uses Promise.all() to update all positions in parallel. For very long resumes, consider using a single SQL UPDATE with a CASE WHEN expression instead: UPDATE experiences SET position = CASE WHEN id=1 THEN 0 WHEN id=2 THEN 1 END WHERE id IN (1, 2). This is more efficient for 10+ entries.

**Expected result:** POST /api/resumes/1/experiences creates an experience entry. PATCH /api/resumes/1/experiences/reorder with [{id:2, position:0}, {id:1, position:1}] swaps the display order.

### 4. Build the PDF export and HTML preview routes

The PDF export uses pdfkit to render a formatted resume server-side. The HTML preview route returns the same data as a styled HTML string for the client-side live preview.

```
const express = require('express');
const PDFDocument = require('pdfkit');
const { loadFullResume } = require('../lib/resumeLoader'); // helper that calls the GET logic

const router = express.Router();

// HTML preview — fast, used for client-side live preview
router.get('/api/resumes/:id/preview', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const resume = await loadFullResume(parseInt(req.params.id), req.user.id);
  if (!resume) return res.status(404).json({ error: 'Not found' });

  const html = renderResumeHtml(resume);
  res.setHeader('Content-Type', 'text/html');
  return res.send(html);
});

// PDF export — server-side rendering with pdfkit
router.post('/api/resumes/:id/export/pdf', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const resume = await loadFullResume(parseInt(req.params.id), req.user.id);
  if (!resume) return res.status(404).json({ error: 'Not found' });

  const doc = new PDFDocument({ margin: 50, size: 'LETTER' });
  const filename = `${(resume.personalInfo?.fullName || 'resume').replace(/\s+/g, '_')}.pdf`;

  res.setHeader('Content-Type', 'application/pdf');
  res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
  doc.pipe(res);

  // Header: Name and contact info
  if (resume.personalInfo) {
    const p = resume.personalInfo;
    doc.fontSize(20).font('Helvetica-Bold').text(p.fullName || '', { align: 'center' });
    const contact = [p.email, p.phone, p.location, p.website].filter(Boolean).join('  |  ');
    doc.fontSize(10).font('Helvetica').text(contact, { align: 'center' });
    if (p.summary) {
      doc.moveDown(0.5).fontSize(10).text(p.summary);
    }
    doc.moveDown(1);
  }

  // Experience section
  if (resume.experiences?.length) {
    doc.fontSize(13).font('Helvetica-Bold').text('EXPERIENCE');
    doc.moveTo(50, doc.y).lineTo(560, doc.y).stroke();
    for (const exp of resume.experiences) {
      doc.moveDown(0.4).fontSize(11).font('Helvetica-Bold').text(exp.title);
      doc.fontSize(10).font('Helvetica').text(`${exp.company}  |  ${exp.location || ''}`, { continued: true });
      const dateRange = exp.isCurrent ? `${exp.startDate?.slice(0,7)} – Present` : `${exp.startDate?.slice(0,7)} – ${exp.endDate?.slice(0,7) || ''}`;
      doc.text(dateRange, { align: 'right' });
      if (exp.highlights?.length) {
        for (const bullet of exp.highlights) {
          doc.fontSize(9).text(`• ${bullet}`, { indent: 10 });
        }
      }
    }
    doc.moveDown(1);
  }

  doc.end();
});

function renderResumeHtml(resume) {
  const p = resume.personalInfo || {};
  return `<!DOCTYPE html><html><head><style>
    body{font-family:Georgia,serif;max-width:800px;margin:40px auto;padding:0 20px;color:#222;}
    h1{text-align:center;font-size:24px;margin-bottom:4px;}
    .contact{text-align:center;font-size:13px;color:#555;margin-bottom:16px;}
    h2{font-size:14px;border-bottom:1px solid #333;padding-bottom:2px;text-transform:uppercase;}
    .item{margin-bottom:12px;} .item-header{display:flex;justify-content:space-between;}
    .item-title{font-weight:bold;font-size:13px;} .item-sub{font-size:12px;color:#555;}
    ul{margin:4px 0 0 16px;padding:0;font-size:12px;}
  </style></head><body>
    <h1>${p.fullName || ''}</h1>
    <div class="contact">${[p.email,p.phone,p.location].filter(Boolean).join(' | ')}</div>
    ${p.summary ? `<p style="font-size:13px">${p.summary}</p>` : ''}
    ${resume.experiences?.length ? `<h2>Experience</h2>${resume.experiences.map(e =>
      `<div class="item"><div class="item-header"><span class="item-title">${e.title} — ${e.company}</span><span class="item-sub">${e.startDate?.slice(0,7) || ''} – ${e.isCurrent ? 'Present' : (e.endDate?.slice(0,7) || '')}</span></div>
      ${e.highlights?.length ? `<ul>${e.highlights.map(b => `<li>${b}</li>`).join('')}</ul>` : ''}</div>`).join('')}` : ''}
  </body></html>`;
}

module.exports = router;
```

> Pro tip: The HTML preview renders in an iframe in the React editor. When the user updates any section field, refetch GET /api/resumes/:id/preview with a 500ms debounce and update the iframe's srcdoc attribute — giving a live preview without a full page reload.

**Expected result:** GET /api/resumes/1/preview returns a full HTML resume. POST /api/resumes/1/export/pdf triggers a PDF file download with the candidate's name as the filename.

### 5. Build the React resume editor with live preview

Ask Agent to create the React frontend with the two-panel editor layout and section management forms.

```
// Ask Agent to build the React frontend with this prompt:
// Build a React resume editor with a two-panel layout:
//
// Left panel (editing, 50% width):
// - Resume title input at top (auto-saves via PUT /api/resumes/:id)
// - Collapsible section cards: Personal Info, Experience, Education, Skills, Projects, Certifications
// - Personal Info: form fields for name, email, phone, location, website, linkedin, summary textarea
// - Experience: list of entries, each with Edit and Delete buttons
//   'Add Experience' button opens a form: company, title, location, start/end dates, 'current job' checkbox
//   Bullet highlights: dynamic list with Add/Remove buttons (each bullet is a text input)
// - Education: same pattern as Experience
// - Skills: category name input + comma-separated items textarea per skill group
// - Projects: name, description, URL, technologies (comma-separated)
// - Each section has drag handles for reordering — on drop, call PATCH .../reorder
//
// Right panel (live preview, 50% width):
// - iframe showing GET /api/resumes/:id/preview
// - Refetch preview with 500ms debounce on any form change via iframe.srcdoc update
//
// Top toolbar:
// - Template selector dropdown (classic/modern/minimal) — changes resume.template_id
// - 'Export PDF' button calls POST /api/resumes/:id/export/pdf and triggers download
// - 'Duplicate' button calls POST /api/resumes/:id/duplicate
//
// Resume list page: card grid with title, last updated, and Edit/Duplicate/Delete buttons
```

**Expected result:** The editor shows two panels. Editing the name field in Personal Info updates the live preview in the right panel after 500ms. Clicking Export PDF downloads a formatted PDF.

## Complete code example

File: `server/routes/resumes.js`

```javascript
const express = require('express');
const { db } = require('../db');
const {
  resumes, personalInfo, experiences, education, skills, projects, certifications,
} = require('../schema');
const { eq, and, asc } = require('drizzle-orm');
const { withDbRetry } = require('../lib/retryDb');

const router = express.Router();

router.get('/api/resumes', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const rows = await db.select().from(resumes)
    .where(eq(resumes.userId, req.user.id))
    .orderBy(resumes.updatedAt);
  return res.json({ resumes: rows });
});

router.post('/api/resumes', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { title, templateId } = req.body;
  const row = await withDbRetry(() =>
    db.insert(resumes).values({
      userId: req.user.id,
      title: title || 'Untitled Resume',
      templateId: templateId || 'classic',
    }).returning()
  );
  return res.status(201).json(row[0]);
});

router.get('/api/resumes/:id', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const resumeId = parseInt(req.params.id);
  const [resume] = await db.select().from(resumes)
    .where(and(eq(resumes.id, resumeId), eq(resumes.userId, req.user.id))).limit(1);
  if (!resume) return res.status(404).json({ error: 'Resume not found' });

  const [info, exp, edu, sk, proj, certs] = await Promise.all([
    db.select().from(personalInfo).where(eq(personalInfo.resumeId, resumeId)).limit(1),
    db.select().from(experiences).where(eq(experiences.resumeId, resumeId)).orderBy(asc(experiences.position)),
    db.select().from(education).where(eq(education.resumeId, resumeId)).orderBy(asc(education.position)),
    db.select().from(skills).where(eq(skills.resumeId, resumeId)).orderBy(asc(skills.position)),
    db.select().from(projects).where(eq(projects.resumeId, resumeId)).orderBy(asc(projects.position)),
    db.select().from(certifications).where(eq(certifications.resumeId, resumeId)).orderBy(asc(certifications.position)),
  ]);

  return res.json({ ...resume, personalInfo: info[0] || null, experiences: exp, education: edu, skills: sk, projects: proj, certifications: certs });
});

router.put('/api/resumes/:id', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const { title, templateId } = req.body;
  const updated = await db.update(resumes)
    .set({ ...(title && { title }), ...(templateId && { templateId }), updatedAt: new Date() })
    .where(and(eq(resumes.id, parseInt(req.params.id)), eq(resumes.userId, req.user.id)))
    .returning();
  if (!updated[0]) return res.status(404).json({ error: 'Not found' });
  return res.json(updated[0]);
});

module.exports = router;
```

## Common mistakes

- **Generating the PDF on every keystroke in the live preview** — PDF generation with pdfkit takes 200-500ms per resume. Running it on every keystroke creates a request queue that falls behind and makes the editor feel broken. Fix: Use the HTML preview endpoint for real-time display — it renders in under 30ms. Only call the PDF export endpoint when the user explicitly clicks 'Download PDF'.
- **Storing the resume as a single text or JSON blob** — A single-blob approach makes it impossible to update one experience entry without replacing the entire resume, or to reorder sections without client-side reindexing. Fix: Use the normalized schema with separate tables per section type and position columns for ordering. The GET /api/resumes/:id endpoint joins them all with Promise.all() for fast loading.
- **Forgetting to scope resume queries by user_id** — Without user_id scoping, any logged-in user can view, edit, or delete any other user's resume — a serious privacy violation. Fix: Every SELECT, UPDATE, and DELETE on the resumes table must include WHERE user_id = req.user.id. Section tables (experiences, education, etc.) are protected by the resume_id foreign key as long as you verify resume ownership first.
- **Not handling the PostgreSQL sleep issue on export** — If no one has used the resume builder for 5+ minutes, the first PDF export request may fail due to a stale database connection. Fix: Wrap the loadFullResume database calls in withDbRetry() so the first query after a sleep period automatically retries with a 250ms delay.

## Best practices

- Use Promise.all() to load all section tables in parallel when fetching a full resume — reduces load time from 6 sequential queries to a single round-trip time.
- Provide an HTML preview endpoint (fast, ~30ms) for live editing and a separate PDF export endpoint (slower, ~300ms) for download — never generate PDF on every keystroke.
- Use position integers for section ordering and a PATCH reorder endpoint — this avoids rewriting all rows on every drag-and-drop, only updating the moved items' positions.
- Store highlights (bullet points) as a PostgreSQL text[] array column rather than a separate bullets table — arrays are perfect for ordered lists that don't need their own IDs or metadata.
- Scope all resume queries by user_id even for section tables — verify ownership via the parent resume before operating on its children.
- Use Drizzle Studio (Database tab in Replit sidebar) to quickly inspect and edit resume data during development without writing SQL queries.
- Deploy on Autoscale — resume builders have low concurrent traffic with short bursts when users edit and export. Cold starts are acceptable.

## Frequently asked questions

### How does the live preview work without regenerating the PDF constantly?

The live preview uses the GET /api/resumes/:id/preview route which returns styled HTML — not a PDF. HTML generation is instant (under 30ms). The React editor loads this HTML into an iframe with a 500ms debounce on changes. PDF generation only happens when the user clicks Export PDF.

### Can I support multiple resume templates?

Yes. The template_id column on the resumes table selects which template to use. Create separate renderClassicHtml(), renderModernHtml(), and renderMinimalHtml() functions in server/lib/resumeTemplates.js. The preview and PDF export routes call the correct function based on resume.templateId.

### What Replit plan do I need?

Free tier is sufficient. Replit Auth is built-in, PostgreSQL is free with 10GB storage, and Autoscale deployment is included. pdfkit is a standard npm package — no external API keys needed for the core build.

### How do I handle dates in the experience entries?

Store start_date and end_date as PostgreSQL date columns (YYYY-MM-DD format). In the form, use an HTML date input. Send dates as ISO strings to the API. The pdfkit renderer formats them using JavaScript's Date object — slice to 'YYYY-MM' for the typical resume format.

### Can users share their resume via a public URL?

Add a is_public boolean and a share_token (UUID) to the resumes table. A GET /r/:token endpoint loads the resume without auth checking. The owner toggles sharing from the editor toolbar. The public endpoint returns the same HTML preview response as the editor.

### Should I deploy on Autoscale or Reserved VM?

Autoscale is ideal for a resume builder. Users edit in short bursts, often with long gaps between sessions. The cold start of 10-30 seconds is acceptable — the editor loading animation provides a natural buffer. Reserved VM would be overkill and costly for personal use.

### Can RapidDev help build a custom resume builder platform?

Yes. RapidDev builds complete resume builder platforms with AI enhancement, LinkedIn import, team collaboration features, and custom branded PDF templates for HR tech startups. 600+ apps built, free consultation available.

### How do I allow users to reorder bullet points within an experience entry?

The highlights column is a PostgreSQL text[] array — reorder is done client-side (drag in React, update the array order), then a single PUT /api/resumes/:id/experiences/:eid updates the entire highlights array. No separate reorder endpoint is needed for bullet points since the array is stored as a single column.

---

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