Loading
Create a structured resume builder in React with multiple templates, live preview, PDF export, and ATS-friendly formatting.
A well-formatted resume is the first thing standing between a developer and their next job. Most resume builders are either paywalled or produce formats that applicant tracking systems (ATS) can't parse. In this tutorial, you'll build a resume builder in React that takes structured input, renders a live preview, supports multiple templates, and exports to a clean PDF that ATS software can actually read.
What you'll learn:
The final app lets you fill in your experience, education, skills, and projects, then switch between templates and export instantly.
Define the resume data structure in src/types.ts:
The preview renders semantic HTML that ATS systems can parse. No tables for layout — only headings, lists, and paragraphs.
The cleanest way to export to PDF is using the browser's built-in print functionality with a dedicated print stylesheet:
Run npm start and you'll see a split-screen editor. Fill in your details on the left — the resume updates live on the right. Hit Export PDF to open the print dialog and save as PDF. The semantic HTML structure means ATS systems can parse every section: name, experience, education, and skills are all in proper heading and list elements. Add the "modern" and "minimal" templates by creating new components with different styling — the data shape stays identical across all templates.
npx create-react-app resume-builder --template typescript
cd resume-builderexport interface ContactInfo {
name: string;
email: string;
phone: string;
location: string;
linkedin: string;
github: string;
website: string;
}
export interface Experience {
id: string;
company: string;
title: string;
startDate: string;
endDate: string;
current: boolean;
bullets: string[];
}
export interface Education {
id: string;
school: string;
degree: string;
field: string;
graduationDate: string;
gpa: string;
}
export interface Project {
id: string;
name: string;
description: string;
technologies: string[];
url: string;
}
export interface ResumeData {
contact: ContactInfo;
summary: string;
experience: Experience[];
education: Education[];
skills: string[];
projects: Project[];
}
export type TemplateName = "classic" | "modern" | "minimal";// src/useResumeState.ts
import { useState, useEffect, useCallback } from "react";
import { ResumeData, Experience, Education, Project } from "./types";
const STORAGE_KEY = "resume-builder-data";
const emptyResume: ResumeData = {
contact: { name: "", email: "", phone: "", location: "", linkedin: "", github: "", website: "" },
summary: "",
experience: [],
education: [],
skills: [],
projects: [],
};
export function useResumeState() {
const [data, setData] = useState<ResumeData>(() => {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : emptyResume;
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}, [data]);
const updateContact = useCallback((field: string, value: string) => {
setData((prev) => ({
...prev,
contact: { ...prev.contact, [field]: value },
}));
}, []);
const addExperience = useCallback(() => {
const entry: Experience = {
id: crypto.randomUUID(),
company: "",
title: "",
startDate: "",
endDate: "",
current: false,
bullets: [""],
};
setData((prev) => ({ ...prev, experience: [...prev.experience, entry] }));
}, []);
const updateExperience = useCallback((id: string, updates: Partial<Experience>) => {
setData((prev) => ({
...prev,
experience: prev.experience.map((e) => (e.id === id ? { ...e, ...updates } : e)),
}));
}, []);
const removeExperience = useCallback((id: string) => {
setData((prev) => ({
...prev,
experience: prev.experience.filter((e) => e.id !== id),
}));
}, []);
const addEducation = useCallback(() => {
const entry: Education = {
id: crypto.randomUUID(),
school: "",
degree: "",
field: "",
graduationDate: "",
gpa: "",
};
setData((prev) => ({ ...prev, education: [...prev.education, entry] }));
}, []);
const addProject = useCallback(() => {
const entry: Project = {
id: crypto.randomUUID(),
name: "",
description: "",
technologies: [],
url: "",
};
setData((prev) => ({ ...prev, projects: [...prev.projects, entry] }));
}, []);
return {
data,
setData,
updateContact,
addExperience,
updateExperience,
removeExperience,
addEducation,
addProject,
};
}// src/components/ContactForm.tsx
import { ContactInfo } from "../types";
interface ContactFormProps {
contact: ContactInfo;
summary: string;
onContactChange: (field: string, value: string) => void;
onSummaryChange: (value: string) => void;
}
export function ContactForm({
contact,
summary,
onContactChange,
onSummaryChange,
}: ContactFormProps) {
const fields: { key: keyof ContactInfo; label: string; type?: string }[] = [
{ key: "name", label: "Full Name" },
{ key: "email", label: "Email", type: "email" },
{ key: "phone", label: "Phone", type: "tel" },
{ key: "location", label: "Location" },
{ key: "linkedin", label: "LinkedIn URL", type: "url" },
{ key: "github", label: "GitHub URL", type: "url" },
{ key: "website", label: "Website", type: "url" },
];
return (
<section>
<h2>Contact Information</h2>
{fields.map(({ key, label, type }) => (
<div key={key} style={{ marginBottom: "0.5rem" }}>
<label htmlFor={key}>{label}</label>
<input
id={key}
type={type ?? "text"}
value={contact[key]}
onChange={(e) => onContactChange(key, e.target.value)}
style={{ display: "block", width: "100%", padding: "0.5rem" }}
/>
</div>
))}
<div>
<label htmlFor="summary">Professional Summary</label>
<textarea
id="summary"
rows={4}
value={summary}
onChange={(e) => onSummaryChange(e.target.value)}
placeholder="2-3 sentences summarizing your experience and goals..."
style={{ display: "block", width: "100%", padding: "0.5rem" }}
/>
</div>
</section>
);
}// src/components/ExperienceForm.tsx
import { Experience } from "../types";
interface ExperienceFormProps {
experiences: Experience[];
onUpdate: (id: string, updates: Partial<Experience>) => void;
onRemove: (id: string) => void;
onAdd: () => void;
}
export function ExperienceForm({ experiences, onUpdate, onRemove, onAdd }: ExperienceFormProps) {
function updateBullet(expId: string, index: number, value: string): void {
const exp = experiences.find((e) => e.id === expId);
if (!exp) return;
const bullets = [...exp.bullets];
bullets[index] = value;
onUpdate(expId, { bullets });
}
function addBullet(expId: string): void {
const exp = experiences.find((e) => e.id === expId);
if (!exp) return;
onUpdate(expId, { bullets: [...exp.bullets, ""] });
}
function removeBullet(expId: string, index: number): void {
const exp = experiences.find((e) => e.id === expId);
if (!exp) return;
onUpdate(expId, { bullets: exp.bullets.filter((_, i) => i !== index) });
}
return (
<section>
<h2>Experience</h2>
{experiences.map((exp) => (
<div
key={exp.id}
style={{
border: "1px solid #333",
padding: "1rem",
marginBottom: "1rem",
borderRadius: "8px",
}}
>
<input
placeholder="Company"
value={exp.company}
onChange={(e) => onUpdate(exp.id, { company: e.target.value })}
/>
<input
placeholder="Job Title"
value={exp.title}
onChange={(e) => onUpdate(exp.id, { title: e.target.value })}
/>
<div style={{ display: "flex", gap: "0.5rem" }}>
<input
type="month"
value={exp.startDate}
onChange={(e) => onUpdate(exp.id, { startDate: e.target.value })}
/>
<input
type="month"
value={exp.endDate}
disabled={exp.current}
onChange={(e) => onUpdate(exp.id, { endDate: e.target.value })}
/>
<label>
<input
type="checkbox"
checked={exp.current}
onChange={(e) => onUpdate(exp.id, { current: e.target.checked })}
/>
Current
</label>
</div>
<div>
<strong>Bullet Points:</strong>
{exp.bullets.map((bullet, i) => (
<div key={i} style={{ display: "flex", gap: "0.5rem", marginTop: "0.25rem" }}>
<input
value={bullet}
onChange={(e) => updateBullet(exp.id, i, e.target.value)}
placeholder="Accomplished X by doing Y, resulting in Z"
style={{ flex: 1 }}
/>
<button onClick={() => removeBullet(exp.id, i)} type="button">
-
</button>
</div>
))}
<button onClick={() => addBullet(exp.id)} type="button">
+ Add Bullet
</button>
</div>
<button
onClick={() => onRemove(exp.id)}
type="button"
style={{ color: "#ef4444", marginTop: "0.5rem" }}
>
Remove Position
</button>
</div>
))}
<button onClick={onAdd} type="button">
+ Add Experience
</button>
</section>
);
}// src/components/SkillsForm.tsx
import { useState } from "react";
interface SkillsFormProps {
skills: string[];
onChange: (skills: string[]) => void;
}
export function SkillsForm({ skills, onChange }: SkillsFormProps) {
const [input, setInput] = useState("");
function addSkill(): void {
const trimmed = input.trim();
if (trimmed && !skills.includes(trimmed)) {
onChange([...skills, trimmed]);
setInput("");
}
}
return (
<section>
<h2>Skills</h2>
<div style={{ display: "flex", gap: "0.5rem" }}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addSkill())}
placeholder="Type a skill and press Enter"
/>
<button onClick={addSkill} type="button">
Add
</button>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginTop: "0.5rem" }}>
{skills.map((skill) => (
<span
key={skill}
style={{
background: "#1a1a2e",
padding: "0.25rem 0.75rem",
borderRadius: "999px",
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
>
{skill}
<button
onClick={() => onChange(skills.filter((s) => s !== skill))}
style={{ background: "none", border: "none", color: "#ef4444", cursor: "pointer" }}
>
x
</button>
</span>
))}
</div>
</section>
);
}// src/templates/ClassicTemplate.tsx
import { ResumeData } from "../types";
interface TemplateProps {
data: ResumeData;
}
export function ClassicTemplate({ data }: TemplateProps) {
const { contact, summary, experience, education, skills, projects } = data;
return (
<article
className="resume-preview classic"
style={{
fontFamily: "Georgia, serif",
color: "#111",
padding: "2rem",
maxWidth: "8.5in",
lineHeight: 1.5,
fontSize: "11pt",
}}
>
<header
style={{
textAlign: "center",
marginBottom: "1rem",
borderBottom: "2px solid #111",
paddingBottom: "0.5rem",
}}
>
<h1 style={{ margin: 0, fontSize: "1.5rem" }}>{contact.name}</h1>
<p style={{ margin: "0.25rem 0" }}>
{[contact.email, contact.phone, contact.location].filter(Boolean).join(" | ")}
</p>
<p style={{ margin: 0, fontSize: "0.85rem" }}>
{[contact.linkedin, contact.github, contact.website].filter(Boolean).join(" | ")}
</p>
</header>
{summary && (
<section>
<h2
style={{
fontSize: "1rem",
borderBottom: "1px solid #ccc",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Summary
</h2>
<p>{summary}</p>
</section>
)}
{experience.length > 0 && (
<section>
<h2
style={{
fontSize: "1rem",
borderBottom: "1px solid #ccc",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Experience
</h2>
{experience.map((exp) => (
<div key={exp.id} style={{ marginBottom: "0.75rem" }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<strong>{exp.title}</strong>
<span>
{exp.startDate} — {exp.current ? "Present" : exp.endDate}
</span>
</div>
<div style={{ fontStyle: "italic" }}>{exp.company}</div>
<ul style={{ margin: "0.25rem 0", paddingLeft: "1.25rem" }}>
{exp.bullets.filter(Boolean).map((b, i) => (
<li key={i}>{b}</li>
))}
</ul>
</div>
))}
</section>
)}
{education.length > 0 && (
<section>
<h2
style={{
fontSize: "1rem",
borderBottom: "1px solid #ccc",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Education
</h2>
{education.map((edu) => (
<div key={edu.id}>
<strong>
{edu.degree} in {edu.field}
</strong>{" "}
— {edu.school}
<span style={{ float: "right" }}>{edu.graduationDate}</span>
{edu.gpa && <span> (GPA: {edu.gpa})</span>}
</div>
))}
</section>
)}
{skills.length > 0 && (
<section>
<h2
style={{
fontSize: "1rem",
borderBottom: "1px solid #ccc",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Skills
</h2>
<p>{skills.join(", ")}</p>
</section>
)}
</article>
);
}// src/components/ExportButton.tsx
interface ExportButtonProps {
targetId: string;
}
export function ExportButton({ targetId }: ExportButtonProps) {
function handleExport(): void {
const printWindow = window.open("", "_blank");
if (!printWindow) return;
const content = document.getElementById(targetId);
if (!content) return;
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Resume</title>
<style>
@page { margin: 0.5in; size: letter; }
body { margin: 0; }
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
</style>
</head>
<body>${content.innerHTML}</body>
</html>
`);
printWindow.document.close();
printWindow.print();
}
return (
<button
onClick={handleExport}
style={{
background: "#10b981",
color: "#fff",
border: "none",
padding: "0.75rem 1.5rem",
borderRadius: "8px",
cursor: "pointer",
fontSize: "1rem",
}}
>
Export PDF
</button>
);
}// src/App.tsx
import { useState } from "react";
import { useResumeState } from "./useResumeState";
import { ContactForm } from "./components/ContactForm";
import { ExperienceForm } from "./components/ExperienceForm";
import { SkillsForm } from "./components/SkillsForm";
import { ExportButton } from "./components/ExportButton";
import { ClassicTemplate } from "./templates/ClassicTemplate";
import { TemplateName } from "./types";
export default function App() {
const { data, setData, updateContact, addExperience, updateExperience, removeExperience } =
useResumeState();
const [template, setTemplate] = useState<TemplateName>("classic");
return (
<div style={{ display: "flex", minHeight: "100vh", background: "#08080d", color: "#f0f0f0" }}>
{/* Editor Panel */}
<div style={{ flex: 1, padding: "2rem", overflowY: "auto", maxHeight: "100vh" }}>
<h1>Resume Builder</h1>
<div style={{ marginBottom: "1rem" }}>
<label>Template: </label>
<select value={template} onChange={(e) => setTemplate(e.target.value as TemplateName)}>
<option value="classic">Classic</option>
<option value="modern">Modern</option>
<option value="minimal">Minimal</option>
</select>
<ExportButton targetId="resume-preview" />
</div>
<ContactForm
contact={data.contact}
summary={data.summary}
onContactChange={updateContact}
onSummaryChange={(v) => setData((prev) => ({ ...prev, summary: v }))}
/>
<ExperienceForm
experiences={data.experience}
onUpdate={updateExperience}
onRemove={removeExperience}
onAdd={addExperience}
/>
<SkillsForm
skills={data.skills}
onChange={(skills) => setData((prev) => ({ ...prev, skills }))}
/>
</div>
{/* Live Preview */}
<div style={{ flex: 1, background: "#fff", overflowY: "auto", maxHeight: "100vh" }}>
<div id="resume-preview">
<ClassicTemplate data={data} />
</div>
</div>
</div>
);
}