TL;DR
In this article, you will learn how to create a resume builder using NextJS, Trigger.dev, Resend, and OpenAI. 😲
- Add basic details such as First name, last name, and last places of work.
- Generate details such as Profile Summary, Work History, and Job Responsibilities.
- Create a PDF that contains all the information.
- Send everything to your email
Your background job platform 🔌
Trigger.dev is an open-source library that enables you to create and monitor long-running jobs for your app with NextJS, Remix, Astro, and so many more!
Please help us with a star 🥹.
It would help us to create more articles like this 💖
Star the Trigger.dev repository ⭐️
Let's set it up 🔥
Set up a new project with NextJS
npx create-next-app@latest
We are going to create a simple form with basic information such as:
- First name
- Last name
- Email address
- Your profile picture
- And the experience you have until today!
We are going to work with NextJS's new app router.
Open layout.tsx
and add the following code
import { GeistSans } from "geist/font";
import "./globals.css";
const defaultUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
export const metadata = {
metadataBase: new URL(defaultUrl),
title: "Resume Builder with GPT4",
description: "The fastest way to build a resume with GPT4",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={GeistSans.className}>
<body className="bg-background text-foreground">
<main className="min-h-screen flex flex-col items-center">
{children}
</main>
</body>
</html>
);
}
We are basically setting the layout for all the pages (even though we have only one page.)
We set the basic page metadata, background, and global CSS elements.
Next, let's open our page.tsx
and add the following code:
<div className="flex-1 w-full flex flex-col items-center">
<nav className="w-full flex justify-center border-b border-b-foreground/10 h-16">
<div className="w-full max-w-6xl flex justify-between items-center p-3 text-sm">
<span className="font-bold select-none">resumeGPT.</span>
</div>
</nav>
<div className="animate-in flex-1 flex flex-col opacity-0 max-w-6xl px-3">
<Home />
</div>
</div>
This sets the headline of our resumeGPT and the main home components.
The easiest way to build forms
The easiest way to save the form information and validate our fields is to use react-hook-form.
We are going to upload a profile picture.
For that, we can't use JSON-based requests.
We will need to convert the JSON into a valid form-data.
So let's install them all!
npm install react-hook-form object-to-formdata axios --save
Create a new folder called components add a new file called Home.tsx
, and add the following code:
"use client";
import React, { useState } from "react";
import {FormProvider, useForm} from "react-hook-form";
import Companies from "@/components/Companies";
import axios from "axios";
import {serialize} from "object-to-formdata";
export type TUserDetails = {
firstName: string;
lastName: string;
photo: string;
email: string;
companies: TCompany[];
};
export type TCompany = {
companyName: string;
position: string;
workedYears: string;
technologies: string;
};
const Home = () => {
const [finished, setFinished] = useState<boolean>(false);
const methods = useForm<TUserDetails>()
const {
register,
handleSubmit,
formState: { errors },
} = methods;
const handleFormSubmit = async (values: TUserDetails) => {
axios.post('/api/create', serialize(values));
setFinished(true);
};
if (finished) {
return (
<div className="mt-10">Sent to the queue! Check your email</div>
)
}
return (
<div className="flex flex-col items-center justify-center p-7">
<div className="w-full py-3 bg-slate-500 items-center justify-center flex flex-col rounded-t-lg text-white">
<h1 className="font-bold text-white text-3xl">Resume Builder</h1>
<p className="text-gray-300">
Generate a resume with GPT in seconds 🚀
</p>
</div>
<FormProvider {...methods}>
<form
onSubmit={handleSubmit(handleFormSubmit)}
className="p-4 w-full flex flex-col"
>
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex flex-col w-full">
<label htmlFor="firstName">First name</label>
<input
type="text"
required
id="firstName"
placeholder="e.g. John"
className="p-3 rounded-md outline-none border border-gray-500 text-white bg-transparent"
{...register('firstName')}
/>
</div>
<div className="flex flex-col w-full">
<label htmlFor="lastName">Last name</label>
<input
type="text"
required
id="lastName"
placeholder="e.g. Doe"
className="p-3 rounded-md outline-none border border-gray-500 text-white bg-transparent"
{...register('lastName')}
/>
</div>
</div>
<hr className="w-full h-1 mt-3" />
<label htmlFor="email">Email Address</label>
<input
type="email"
required
id="email"
placeholder="e.g. john.doe@gmail.com"
className="p-3 rounded-md outline-none border border-gray-500 text-white bg-transparent"
{...register('email', {required: true, pattern: /^\S+@\S+$/i})}
/>
<hr className="w-full h-1 mt-3" />
<label htmlFor="photo">Upload your image 😎</label>
<input
type="file"
id="photo"
accept="image/x-png"
className="p-3 rounded-md outline-none border border-gray-500 mb-3"
{...register('photo', {required: true})}
/>
<Companies />
<button className="p-4 pointer outline-none bg-blue-500 border-none text-white text-base font-semibold rounded-lg">
CREATE RESUME
</button>
</form>
</FormProvider>
</div>
);
};
export default Home;
You can see that we start with "use client"
which basically tells our component that it should run on the client only.
Why do we want client only?
React states (input changes) are available only on the client side.
We set two interfaces, TUserDetails
and TCompany
. They represent the structure of the data we are working with.
We use useForm
with react-hook-form
. It creates a local state management to our inputs and allows us to update and validate our fields easily. you can see that in every input
, there is a simple register
function that specific the input name and validation and registers it to the managed state.
This is cool as we don't need to play with things like onChange
You can also see that we use FormProvider
, that's important as we want to have the Context of react-hook-form
in children components.
We also have a method called handleFormSubmit
. That's the method that is called once we submit the form. You can see that we use the serialize
function to convert our javascript object to FormData and send a request to the server to initiate the job with axios
.
And you can see another component called Companies
. That component will let us specify all the companies we worked for.
So let's work on it.
Create a new file called Companies.tsx
And add the following code:
import React, {useCallback, useEffect} from "react";
import { TCompany } from "./Home";
import {useFieldArray, useFormContext} from "react-hook-form";
const Companies = () => {
const {control, register} = We();
const {fields: companies, append} = useFieldArray({
control,
name: "companies",
});
const addCompany = useCallback(() => {
append({
companyName: '',
position: '',
workedYears: '',
technologies: ''
})
}, [companies]);
useEffect(() => {
addCompany();
}, []);
return (
<div className="mb-4">
{companies.length > 1 ? (
<h3 className="font-bold text-white text-3xl my-3">
Your list of Companies:
</h3>
) : null}
{companies.length > 1 &&
companies.slice(1).map((company, index) => (
<div
key={index}
className="mb-4 p-4 border bg-gray-800 rounded-lg shadow-md"
>
<div className="mb-2">
<label htmlFor={`companyName-${index}`} className="text-white">
Company Name
</label>
<input
type="text"
id={`companyName-${index}`}
className="p-2 border border-gray-300 rounded-md w-full bg-transparent"
{...register(`companies.${index}.companyName`, {required: true})}
/>
</div>
<div className="mb-2">
<label htmlFor={`position-${index}`} className="text-white">
Position
</label>
<input
type="text"
id={`position-${index}`}
className="p-2 border border-gray-300 rounded-md w-full bg-transparent"
{...register(`companies.${index}.position`, {required: true})}
/>
</div>
<div className="mb-2">
<label htmlFor={`workedYears-${index}`} className="text-white">
Worked Years
</label>
<input
type="number"
id={`workedYears-${index}`}
className="p-2 border border-gray-300 rounded-md w-full bg-transparent"
{...register(`companies.${index}.workedYears`, {required: true})}
/>
</div>
<div className="mb-2">
<label htmlFor={`workedYears-${index}`} className="text-white">
Technologies
</label>
<input
type="text"
id={`technologies-${index}`}
className="p-2 border border-gray-300 rounded-md w-full bg-transparent"
{...register(`companies.${index}.technologies`, {required: true})}
/>
</div>
</div>
))}
<button type="button" onClick={addCompany} className="mb-4 p-2 pointer outline-none bg-blue-900 w-full border-none text-white text-base font-semibold rounded-lg">
Add Company
</button>
</div>
);
};
export default Companies;
We start with useFormContext
, which allows us to get the context of the parent component.
Next, we use useFieldArray
to create a new state called companies. It is an array for all the companies that we have.
In the useEffect
, we add the first item of the array to iterate over it.
When clicking on addCompany
, it will push another element to the array.
We have finished with the client 🥳
Parse the HTTP request
Remember that we send a POST
request to /api/create
?
Let's go to our app/api folder and create a new folder called create
inside that folder, create a new file called route.tsx
and paste the following code:
import {NextRequest, NextResponse} from "next/server";
import {client} from "@/trigger";
export async function POST(req: NextRequest) {
const data = await req.formData();
const allArr = {
name: data.getAll('companies[][companyName]'),
position: data.getAll('companies[][position]'),
workedYears: data.getAll('companies[][workedYears]'),
technologies: data.getAll('companies[][technologies]'),
};
const payload = {
firstName: data.get('firstName'),
lastName: data.get('lastName'),
photo: Buffer.from((await (data.get('photo[0]') as File).arrayBuffer())).toString('base64'),
email: data.get('email'),
companies: allArr.name.map((name, index) => ({
companyName: allArr.name[index],
position: allArr.position[index],
workedYears: allArr.workedYears[index],
technologies: allArr.technologies[index],
})).filter((company) => company.companyName && company.position && company.workedYears && company.technologies)
}
await client.sendEvent({
name: 'create.resume',
payload
});
return NextResponse.json({ })
}
This code will run only with NodeJS version 20+. If you have a lower version, it will not be able to parse FormData.
That code is pretty simple.
- We parse the request as FormData using
req.formData
- We convert the FormData-based request into a JSON file.
- We extract the image and convert it to
base64
- We send everything to TriggerDev
Build the resume and send it to your email 📨
Building the resume is a long-running task we need to
- Use ChatGPT to generate the content.
- Create a PDF
- Send it to your email
We don't want to make a long-running HTTP request to make all of those for a few reasons.
- When deploying to Vercel, there is a limit of 10 seconds on serverless functions. We will never make it on time.
- We want to keep the user from hanging for a long time. It's a bad UX. If the user closes the window, the entire process will fail.
Introducing Trigger.dev!
With Trigger.dev, you can run background processes inside of your NextJS app! You don't need to create a new server.
They also know how to handle long-running jobs by breaking them into short tasks seamlessly.
Sign up for a Trigger.dev account. Once registered, create an organization and choose a project name for your job.
Select Next.js as your framework and follow the process for adding Trigger.dev to an existing Next.js project.
Otherwise, click Environments & API Keys
on the sidebar menu of your project dashboard.
Copy your DEV server API key and run the code snippet below to install Trigger.dev. Follow the instructions carefully.
npx @trigger.dev/cli@latest init
In another terminal, run the following code snippet to establish a tunnel between Trigger.dev and your Next.js project.
npx @trigger.dev/cli@latest dev
Let's create our TriggerDev job!
Head over to the newly created folder jobs and create a new file called create.resume.ts
.
Add the following code:
client.defineJob({
id: "create-resume",
name: "Create Resume",
version: "0.0.1",
trigger: eventTrigger({
name: "create.resume",
schema: z.object({
firstName: z.string(),
lastName: z.string(),
photo: z.string(),
email: z.string().email(),
companies: z.array(z.object({
companyName: z.string(),
position: z.string(),
workedYears: z.string(),
technologies: z.string()
}))
}),
}),
run: async (payload, io, ctx) => {
}
});
This will create a new job for us called create-resume
.
As you can see, there is a schema validation of the request we previously sent from our route.tsx
. That will give us validation and also auto-completion
.
We are going to run three jobs here
- ChatGPT
- Pdf creation
- Email sending
Let's start with ChatGPT.
Create an OpenAI account and generate an API Key.
Click View API key
from the dropdown to create an API Key.
Next, install the OpenAI package by running the code snippet below.
npm install @trigger.dev/openai
Add your OpenAI API key to the .env.local
file.
OPENAI_API_KEY=<your_api_key>
Create a new folder in the root directory called utils
.
inside that directory, create a new file called openai.ts
Add the following code:
import { OpenAI } from "openai";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY!,
});
export async function generateResumeText(prompt: string) {
const response = await openai.completions.create({
model: "text-davinci-003",
prompt,
max_tokens: 250,
temperature: 0.7,
top_p: 1,
frequency_penalty: 1,
presence_penalty: 1,
});
return response.choices[0].text.trim();
}
export const prompts = {
profileSummary: (fullName: string, currentPosition: string, workingExperience: string, knownTechnologies: string) => `I am writing a resume, my details are \n name: ${fullName} \n role: ${currentPosition} (${workingExperience} years). \n I write in the technologies: ${knownTechnologies}. Can you write a 100 words description for the top of the resume(first person writing)?`,
jobResponsibilities: (fullName: string, currentPosition: string, workingExperience: string, knownTechnologies: string) => `I am writing a resume, my details are \n name: ${fullName} \n role: ${currentPosition} (${workingExperience} years). \n I write in the technolegies: ${knownTechnologies}. Can you write 3 points for a resume on what I am good at?`,
workHistory: (fullName: string, currentPosition: string, workingExperience: string, details: TCompany[]) => `I am writing a resume, my details are \n name: ${fullName} \n role: ${currentPosition} (${workingExperience} years). ${companyDetails(details)} \n Can you write me 50 words for each company seperated in numbers of my succession in the company (in first person)?`,
};
This code basically created the infrastructure to use ChatGPT and also 3 functions, profileSummary
, workingExperience
, and workHistory
. We will use them to create the content for the sections.
Go back to our create.resume.ts
and add the new job:
import { client } from "@/trigger";
import { eventTrigger } from "@trigger.dev/sdk";
import { z } from "zod";
import { prompts } from "@/utils/openai";
import { TCompany, TUserDetails } from "@/components/Home";
const companyDetails = (companies: TCompany[]) => {
let stringText = "";
for (let i = 1; i < companies.length; i++) {
stringText += ` ${companies[i].companyName} as a ${companies[i].position} on technologies ${companies[i].technologies} for ${companies[i].workedYears} years.`;
}
return stringText;
};
client.defineJob({
id: "create-resume",
name: "Create Resume",
version: "0.0.1",
integrations: {
resend
},
trigger: eventTrigger({
name: "create.resume",
schema: z.object({
firstName: z.string(),
lastName: z.string(),
photo: z.string(),
email: z.string().email(),
companies: z.array(z.object({
companyName: z.string(),
position: z.string(),
workedYears: z.string(),
technologies: z.string()
}))
}),
}),
run: async (payload, io, ctx) => {
const texts = await io.runTask("openai-task", async () => {
return Promise.all([
await generateResumeText(prompts.profileSummary(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
await generateResumeText(prompts.jobResponsibilities(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
await generateResumeText(prompts.workHistory(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies))
]);
});
},
});
We created a new task called openai-task
.
Inside that task, we simultaneously run three prompts with ChatGPT, and return them.
Creating the PDF
There are many ways to create a PDF
- You can use things like HTML2CANVAS and convert HTML code into an image and then a PDF.
- You can use things like
puppeteer
to scrape a web page and convert it to a PDF. - You can use different libraries that can create PDFs on the backend side.
In our case, we are going to use a simple library called jsPdf
it's very simplistic library to create PDF over the backend. I encourage you to create some more robust PDF files with Puppeteer and more HTML.
So let's install it
npm install jspdf @typs/jspdf --save
Let's return to utils
and create a new file called resume.ts
. That file will basically create a PDF file that we can send to the user's email.
Add the following content:
import {TUserDetails} from "@/components/Home";
import {jsPDF} from "jspdf";
type ResumeProps = {
userDetails: TUserDetails;
picture: string;
profileSummary: string;
workHistory: string;
jobResponsibilities: string;
};
export function createResume({ userDetails, picture, workHistory, jobResponsibilities, profileSummary }: ResumeProps) {
const doc = new jsPDF();
// Title block
doc.setFontSize(24);
doc.setFont('helvetica', 'bold');
doc.text(userDetails.firstName + ' ' + userDetails.lastName, 45, 27);
doc.setLineWidth(0.5);
doc.rect(20, 15, 170, 20); // x, y, width, height
doc.addImage({
imageData: picture,
x: 25,
y: 17,
width: 15,
height: 15
});
// Reset font for the rest
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
// Personal Information block
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('Summary', 20, 50);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
const splitText = doc.splitTextToSize(profileSummary, 170);
doc.text(splitText, 20, 60);
const newY = splitText.length * 5;
// Work history block
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('Work History', 20, newY + 65);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
const splitWork = doc.splitTextToSize(workHistory, 170);
doc.text(splitWork, 20, newY + 75);
const newNewY = splitWork.length * 5;
// Job Responsibilities block
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('Job Responsibilities', 20, newY + newNewY + 75);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
const splitJob = doc.splitTextToSize(jobResponsibilities, 170);
doc.text(splitJob, 20, newY + newNewY + 85);
return doc.output("datauristring");
}
This file contains three sections: Personal Information
, Work history
, and Job Responsibilities
block.
We calculate where each block will be and what it will be.
Everything is set up in an absolute
way.
A notable thing is the splitTextToSize
to break the text into multiple lines, so it will not go off the screen.
Now, let's create the next task: open resume.ts
again and add the following code:
import { client } from "@/trigger";
import { eventTrigger } from "@trigger.dev/sdk";
import { z } from "zod";
import { prompts } from "@/utils/openai";
import { TCompany, TUserDetails } from "@/components/Home";
import { createResume } from "@/utils/resume";
const companyDetails = (companies: TCompany[]) => {
let stringText = "";
for (let i = 1; i < companies.length; i++) {
stringText += ` ${companies[i].companyName} as a ${companies[i].position} on technologies ${companies[i].technologies} for ${companies[i].workedYears} years.`;
}
return stringText;
};
client.defineJob({
id: "create-resume",
name: "Create Resume",
version: "0.0.1",
integrations: {
resend
},
trigger: eventTrigger({
name: "create.resume",
schema: z.object({
firstName: z.string(),
lastName: z.string(),
photo: z.string(),
email: z.string().email(),
companies: z.array(z.object({
companyName: z.string(),
position: z.string(),
workedYears: z.string(),
technologies: z.string()
}))
}),
}),
run: async (payload, io, ctx) => {
const texts = await io.runTask("openai-task", async () => {
return Promise.all([
await generateResumeText(prompts.profileSummary(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
await generateResumeText(prompts.jobResponsibilities(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
await generateResumeText(prompts.workHistory(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies))
]);
});
console.log('passed chatgpt');
const pdf = await io.runTask('convert-to-html', async () => {
const resume = createResume({
userDetails: payload,
picture: payload.photo,
profileSummary: texts[0],
jobResponsibilities: texts[1],
workHistory: texts[2],
});
return {final: resume.split(',')[1]}
});
console.log('converted to pdf');
},
});
You can see we have added a new task called convert-to-html
. This will create the PDF for us, convert it to base64 and return it.
Let them know 🎤
We are reaching the end!
The only thing left is to share it with the user.
You can use any email service you want.
We will use Resend.com
Visit the Signup page, create an account and an API Key, and save it into the .env.local
file.
RESEND_API_KEY=<place_your_API_key>
Install the Trigger.dev Resend integration package to your Next.js project.
npm install @trigger.dev/resend
All that is left to do is to add our last job!
Fortunately, Trigger directly integrates with Resend, so we don't need to create a new "normal" task.
Here is the final code:
import { client } from "@/trigger";
import { eventTrigger } from "@trigger.dev/sdk";
import { z } from "zod";
import { prompt } from "@/utils/openai";
import { TCompany, TUserDetails } from "@/components/Home";
import { createResume } from "@/utils/resume";
import { Resend } from "@trigger.dev/resend";
const resend = new Resend({
id: "resend",
apiKey: process.env.RESEND_API_KEY!,
});
const companyDetails = (companies: TCompany[]) => {
let stringText = "";
for (let i = 1; i < companies.length; i++) {
stringText += ` ${companies[i].companyName} as a ${companies[i].position} on technologies ${companies[i].technologies} for ${companies[i].workedYears} years.`;
}
return stringText;
};
client.defineJob({
id: "create-resume",
name: "Create Resume",
version: "0.0.1",
integrations: {
resend
},
trigger: eventTrigger({
name: "create.resume",
schema: z.object({
firstName: z.string(),
lastName: z.string(),
photo: z.string(),
email: z.string().email(),
companies: z.array(z.object({
companyName: z.string(),
position: z.string(),
workedYears: z.string(),
technologies: z.string()
}))
}),
}),
run: async (payload, io, ctx) => {
const texts = await io.runTask("openai-task", async () => {
return Promise.all([
await generateResumeText(prompts.profileSummary(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
await generateResumeText(prompts.jobResponsibilities(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
await generateResumeText(prompts.workHistory(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies))
]);
});
console.log('passed chatgpt');
const pdf = await io.runTask('convert-to-html', async () => {
const resume = createResume({
userDetails: payload,
picture: payload.photo,
profileSummary: texts[0],
jobResponsibilities: texts[1],
workHistory: texts[2],
});
return {final: resume.split(',')[1]}
});
console.log('converted to pdf');
await io.resend.sendEmail('send-email', {
to: payload.email,
subject: 'Resume',
html: 'Your resume is attached!',
attachments: [
{
filename: 'resume.pdf',
content: Buffer.from(pdf.final, 'base64'),
contentType: 'application/pdf',
}
],
from: "Nevo David <nevo@gitup.dev>",
});
console.log('Sent email');
},
});
We have the Resend
instance at the top of our file loaded with our API key from the dashboard.
We have the
integrations: {
resend
},
We added it to our job to use later inside of io
.
And finally, we have our job to send the PDF io.resend.sendEmail
A notable thing is the attachment inside it with the PDF file we generated in the previous step.
And we are done 🎉
You can check and run the full source code here:
https://github.com/triggerdotdev/blog
Let's connect! 🔌
As an open-source developer, you're invited to join our community to contribute and engage with maintainers. Don't hesitate to visit our GitHub repository to contribute and create issues related to Trigger.dev.
The source for this tutorial is available here:
https://github.com/triggerdotdev/blog/tree/main/blog-resume-builder
Thank you for reading!
Top comments (11)
Really awesome tutorial. I'll surely try to use this service with Resume-Matcher.
Let AI write our resume and let AI do our job, hoping that nobody finds out we are making ourselves obsolete.
Old, not obsolete
The LLMs can do these things easily. In-fact, it's pretty straightforward for the LLMs to revise or generate an ATS compatible resumes.
I love this. I’m going to follow along and build it. Thanks for creating a great tutorial!
What is the obsession with making everything a serverless function? How do I decide when I should use one and when I should make my own route?
It could be due to easy integration for serverless computing services like aws, azure, etc. The benefit of serverless computing is, that you pay per certain... code usage like an event execution.
cbtnuggets.com/blog/certifications...
Some folks say EC2 is cheaper, some people say serverless functions are cheaper, but obviously, tt depends on the application & requirements.
awesome effort 👏👏
Great read! 🔥 One excellent hack for generating a resume. 😻 I will definitely give Trigger.dev a try for running background jobs.
This is cool.
Having an issue with the project .. Can anyone help me out?