Creating a resume builder with NextJS, Trigger.dev and GPT4

Eric AllamEric Allam

Creating a resume builder with NextJS, Trigger.dev and GPT4

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

Monkey Table

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!

Let's set it up ๐Ÿ”ฅ

Set up a new project with NextJS


_10
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!

Inputs

We are going to work with NextJS's new app router. Open layout.tsx and add the following code


_28
import { GeistSans } from "geist/font";
_28
import "./globals.css";
_28
_28
const defaultUrl = process.env.VERCEL_URL
_28
? `https://${process.env.VERCEL_URL}`
_28
: "http://localhost:3000";
_28
_28
export const metadata = {
_28
metadataBase: new URL(defaultUrl),
_28
title: "Resume Builder with GPT4",
_28
description: "The fastest way to build a resume with GPT4",
_28
};
_28
_28
export default function RootLayout({
_28
children,
_28
}: {
_28
children: React.ReactNode,
_28
}) {
_28
return (
_28
<html lang="en" className={GeistSans.className}>
_28
<body className="bg-background text-foreground">
_28
<main className="flex min-h-screen flex-col items-center">
_28
{children}
_28
</main>
_28
</body>
_28
</html>
_28
);
_28
}

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:


_11
<div className="flex w-full flex-1 flex-col items-center">
_11
<nav className="flex h-16 w-full justify-center border-b border-b-foreground/10">
_11
<div className="flex w-full max-w-6xl items-center justify-between p-3 text-sm">
_11
<span className="select-none font-bold">resumeGPT.</span>
_11
</div>
_11
</nav>
_11
_11
<div className="flex max-w-6xl flex-1 flex-col px-3 opacity-0 animate-in">
_11
<Home />
_11
</div>
_11
</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!


_10
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:


_111
"use client";
_111
_111
import React, { useState } from "react";
_111
import {FormProvider, useForm} from "react-hook-form";
_111
import Companies from "@/components/Companies";
_111
import axios from "axios";
_111
import {serialize} from "object-to-formdata";
_111
_111
export type TUserDetails = {
_111
firstName: string;
_111
lastName: string;
_111
photo: string;
_111
email: string;
_111
companies: TCompany[];
_111
};
_111
_111
export type TCompany = {
_111
companyName: string;
_111
position: string;
_111
workedYears: string;
_111
technologies: string;
_111
};
_111
_111
const Home = () => {
_111
const [finished, setFinished] = useState<boolean>(false);
_111
const methods = useForm<TUserDetails>()
_111
_111
const {
_111
register,
_111
handleSubmit,
_111
formState: { errors },
_111
} = methods;
_111
_111
const handleFormSubmit = async (values: TUserDetails) => {
_111
axios.post('/api/create', serialize(values));
_111
setFinished(true);
_111
};
_111
_111
if (finished) {
_111
return (
_111
<div className="mt-10">Sent to the queue! Check your email</div>
_111
)
_111
}
_111
_111
return (
_111
<div className="flex flex-col items-center justify-center p-7">
_111
<div className="w-full py-3 bg-slate-500 items-center justify-center flex flex-col rounded-t-lg text-white">
_111
<h1 className="font-bold text-white text-3xl">Resume Builder</h1>
_111
<p className="text-gray-300">
_111
Generate a resume with GPT in seconds ๐Ÿš€
_111
</p>
_111
</div>
_111
<FormProvider {...methods}>
_111
<form
_111
onSubmit={handleSubmit(handleFormSubmit)}
_111
className="p-4 w-full flex flex-col"
_111
>
_111
<div className="flex flex-col lg:flex-row gap-4">
_111
<div className="flex flex-col w-full">
_111
<label htmlFor="firstName">First name</label>
_111
<input
_111
type="text"
_111
required
_111
id="firstName"
_111
placeholder="e.g. John"
_111
className="p-3 rounded-md outline-none border border-gray-500 text-white bg-transparent"
_111
{...register('firstName')}
_111
/>
_111
</div>
_111
<div className="flex flex-col w-full">
_111
<label htmlFor="lastName">Last name</label>
_111
<input
_111
type="text"
_111
required
_111
id="lastName"
_111
placeholder="e.g. Doe"
_111
className="p-3 rounded-md outline-none border border-gray-500 text-white bg-transparent"
_111
{...register('lastName')}
_111
/>
_111
</div>
_111
</div>
_111
<hr className="w-full h-1 mt-3" />
_111
<label htmlFor="email">Email Address</label>
_111
<input
_111
type="email"
_111
required
_111
id="email"
_111
placeholder="e.g. [email protected]"
_111
className="p-3 rounded-md outline-none border border-gray-500 text-white bg-transparent"
_111
{...register('email', {required: true, pattern: /^\S+@\S+$/i})}
_111
/>
_111
<hr className="w-full h-1 mt-3" />
_111
<label htmlFor="photo">Upload your image ๐Ÿ˜Ž</label>
_111
<input
_111
type="file"
_111
id="photo"
_111
accept="image/x-png"
_111
className="p-3 rounded-md outline-none border border-gray-500 mb-3"
_111
{...register('photo', {required: true})}
_111
/>
_111
<Companies />
_111
<button className="p-4 pointer outline-none bg-blue-500 border-none text-white text-base font-semibold rounded-lg">
_111
CREATE RESUME
_111
</button>
_111
</form>
_111
</FormProvider>
_111
</div>
_111
);
_111
};
_111
_111
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:


_104
import React, { useCallback, useEffect } from "react";
_104
_104
import { TCompany } from "./Home";
_104
import { useFieldArray, useFormContext } from "react-hook-form";
_104
_104
const Companies = () => {
_104
const { control, register } = We();
_104
const { fields: companies, append } = useFieldArray({
_104
control,
_104
name: "companies",
_104
});
_104
_104
const addCompany = useCallback(() => {
_104
append({
_104
companyName: "",
_104
position: "",
_104
workedYears: "",
_104
technologies: "",
_104
});
_104
}, [companies]);
_104
_104
useEffect(() => {
_104
addCompany();
_104
}, []);
_104
_104
return (
_104
<div className="mb-4">
_104
{companies.length > 1 ? (
_104
<h3 className="my-3 text-3xl font-bold text-white">
_104
Your list of Companies:
_104
</h3>
_104
) : null}
_104
{companies.length > 1 &&
_104
companies.slice(1).map((company, index) => (
_104
<div
_104
key={index}
_104
className="mb-4 rounded-lg border bg-gray-800 p-4 shadow-md"
_104
>
_104
<div className="mb-2">
_104
<label htmlFor={`companyName-${index}`} className="text-white">
_104
Company Name
_104
</label>
_104
<input
_104
type="text"
_104
id={`companyName-${index}`}
_104
className="w-full rounded-md border border-gray-300 bg-transparent p-2"
_104
{...register(`companies.${index}.companyName`, {
_104
required: true,
_104
})}
_104
/>
_104
</div>
_104
_104
<div className="mb-2">
_104
<label htmlFor={`position-${index}`} className="text-white">
_104
Position
_104
</label>
_104
<input
_104
type="text"
_104
id={`position-${index}`}
_104
className="w-full rounded-md border border-gray-300 bg-transparent p-2"
_104
{...register(`companies.${index}.position`, { required: true })}
_104
/>
_104
</div>
_104
_104
<div className="mb-2">
_104
<label htmlFor={`workedYears-${index}`} className="text-white">
_104
Worked Years
_104
</label>
_104
<input
_104
type="number"
_104
id={`workedYears-${index}`}
_104
className="w-full rounded-md border border-gray-300 bg-transparent p-2"
_104
{...register(`companies.${index}.workedYears`, {
_104
required: true,
_104
})}
_104
/>
_104
</div>
_104
<div className="mb-2">
_104
<label htmlFor={`workedYears-${index}`} className="text-white">
_104
Technologies
_104
</label>
_104
<input
_104
type="text"
_104
id={`technologies-${index}`}
_104
className="w-full rounded-md border border-gray-300 bg-transparent p-2"
_104
{...register(`companies.${index}.technologies`, {
_104
required: true,
_104
})}
_104
/>
_104
</div>
_104
</div>
_104
))}
_104
<button
_104
type="button"
_104
onClick={addCompany}
_104
className="pointer mb-4 w-full rounded-lg border-none bg-blue-900 p-2 text-base font-semibold text-white outline-none"
_104
>
_104
Add Company
_104
</button>
_104
</div>
_104
);
_104
};
_104
_104
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:


_32
import {NextRequest, NextResponse} from "next/server";
_32
import {client} from "@/trigger";
_32
_32
export async function POST(req: NextRequest) {
_32
const data = await req.formData();
_32
const allArr = {
_32
name: data.getAll('companies[][companyName]'),
_32
position: data.getAll('companies[][position]'),
_32
workedYears: data.getAll('companies[][workedYears]'),
_32
technologies: data.getAll('companies[][technologies]'),
_32
};
_32
_32
const payload = {
_32
firstName: data.get('firstName'),
_32
lastName: data.get('lastName'),
_32
photo: Buffer.from((await (data.get('photo[0]') as File).arrayBuffer())).toString('base64'),
_32
email: data.get('email'),
_32
companies: allArr.name.map((name, index) => ({
_32
companyName: allArr.name[index],
_32
position: allArr.position[index],
_32
workedYears: allArr.workedYears[index],
_32
technologies: allArr.technologies[index],
_32
})).filter((company) => company.companyName && company.position && company.workedYears && company.technologies)
_32
}
_32
_32
await client.sendEvent({
_32
name: 'create.resume',
_32
payload
_32
});
_32
_32
return NextResponse.json({ })
_32
}

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.

  1. When deploying to Vercel, there is a limit of 10 seconds on serverless functions. We will never make it on time.
  2. 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.

CreateOrg

Select Next.js as your framework and follow the process for adding Trigger.dev to an existing Next.js project.

Next

Otherwise, clickย Environments & API Keysย on the sidebar menu of your project dashboard.

Copy

Copy your DEV server API key and run the code snippet below to install Trigger.dev. Follow the instructions carefully.


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


_10
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:


_23
client.defineJob({
_23
id: "create-resume",
_23
name: "Create Resume",
_23
version: "0.0.1",
_23
trigger: eventTrigger({
_23
name: "create.resume",
_23
schema: z.object({
_23
firstName: z.string(),
_23
lastName: z.string(),
_23
photo: z.string(),
_23
email: z.string().email(),
_23
companies: z.array(
_23
z.object({
_23
companyName: z.string(),
_23
position: z.string(),
_23
workedYears: z.string(),
_23
technologies: z.string(),
_23
})
_23
),
_23
}),
_23
}),
_23
run: async (payload, io, ctx) => {},
_23
});

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.

ChatGPT

Click View API key from the dropdown to create an API Key.

ApiKey

Next, install the OpenAI package by running the code snippet below.


_10
npm install @trigger.dev/openai

Add your OpenAI API key to the .env.local file.


_10
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:


_25
import { OpenAI } from "openai";
_25
_25
const openai = new OpenAI({
_25
apiKey: process.env.OPENAI_API_KEY!,
_25
});
_25
_25
export async function generateResumeText(prompt: string) {
_25
const response = await openai.completions.create({
_25
model: "text-davinci-003",
_25
prompt,
_25
max_tokens: 250,
_25
temperature: 0.7,
_25
top_p: 1,
_25
frequency_penalty: 1,
_25
presence_penalty: 1,
_25
});
_25
_25
return response.choices[0].text.trim();
_25
}
_25
_25
export const prompts = {
_25
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)?`,
_25
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?`,
_25
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)?`,
_25
};

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:


_69
import { client } from "@/trigger";
_69
import { eventTrigger } from "@trigger.dev/sdk";
_69
import { z } from "zod";
_69
import { prompts } from "@/utils/openai";
_69
import { TCompany, TUserDetails } from "@/components/Home";
_69
_69
const companyDetails = (companies: TCompany[]) => {
_69
let stringText = "";
_69
for (let i = 1; i < companies.length; i++) {
_69
stringText += ` ${companies[i].companyName} as a ${companies[i].position} on technologies ${companies[i].technologies} for ${companies[i].workedYears} years.`;
_69
}
_69
return stringText;
_69
};
_69
_69
client.defineJob({
_69
id: "create-resume",
_69
name: "Create Resume",
_69
version: "0.0.1",
_69
integrations: {
_69
resend,
_69
},
_69
trigger: eventTrigger({
_69
name: "create.resume",
_69
schema: z.object({
_69
firstName: z.string(),
_69
lastName: z.string(),
_69
photo: z.string(),
_69
email: z.string().email(),
_69
companies: z.array(
_69
z.object({
_69
companyName: z.string(),
_69
position: z.string(),
_69
workedYears: z.string(),
_69
technologies: z.string(),
_69
})
_69
),
_69
}),
_69
}),
_69
run: async (payload, io, ctx) => {
_69
const texts = await io.runTask("openai-task", async () => {
_69
return Promise.all([
_69
await generateResumeText(
_69
prompts.profileSummary(
_69
payload.firstName,
_69
payload.companies[0].position,
_69
payload.companies[0].workedYears,
_69
payload.companies[0].technologies
_69
)
_69
),
_69
await generateResumeText(
_69
prompts.jobResponsibilities(
_69
payload.firstName,
_69
payload.companies[0].position,
_69
payload.companies[0].workedYears,
_69
payload.companies[0].technologies
_69
)
_69
),
_69
await generateResumeText(
_69
prompts.workHistory(
_69
payload.firstName,
_69
payload.companies[0].position,
_69
payload.companies[0].workedYears,
_69
payload.companies
_69
)
_69
),
_69
]);
_69
});
_69
},
_69
});

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


_10
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:


_72
import { TUserDetails } from "@/components/Home";
_72
import { jsPDF } from "jspdf";
_72
_72
type ResumeProps = {
_72
userDetails: TUserDetails,
_72
picture: string,
_72
profileSummary: string,
_72
workHistory: string,
_72
jobResponsibilities: string,
_72
};
_72
_72
export function createResume({
_72
userDetails,
_72
picture,
_72
workHistory,
_72
jobResponsibilities,
_72
profileSummary,
_72
}: ResumeProps) {
_72
const doc = new jsPDF();
_72
_72
// Title block
_72
doc.setFontSize(24);
_72
doc.setFont("helvetica", "bold");
_72
_72
doc.text(userDetails.firstName + " " + userDetails.lastName, 45, 27);
_72
doc.setLineWidth(0.5);
_72
doc.rect(20, 15, 170, 20); // x, y, width, height
_72
doc.addImage({
_72
imageData: picture,
_72
x: 25,
_72
y: 17,
_72
width: 15,
_72
height: 15,
_72
});
_72
_72
// Reset font for the rest
_72
doc.setFontSize(12);
_72
doc.setFont("helvetica", "normal");
_72
_72
// Personal Information block
_72
doc.setFontSize(14);
_72
doc.setFont("helvetica", "bold");
_72
doc.text("Summary", 20, 50);
_72
doc.setFontSize(10);
_72
doc.setFont("helvetica", "normal");
_72
const splitText = doc.splitTextToSize(profileSummary, 170);
_72
doc.text(splitText, 20, 60);
_72
_72
const newY = splitText.length * 5;
_72
_72
// Work history block
_72
doc.setFontSize(14);
_72
doc.setFont("helvetica", "bold");
_72
doc.text("Work History", 20, newY + 65);
_72
doc.setFontSize(10);
_72
doc.setFont("helvetica", "normal");
_72
const splitWork = doc.splitTextToSize(workHistory, 170);
_72
doc.text(splitWork, 20, newY + 75);
_72
_72
const newNewY = splitWork.length * 5;
_72
_72
// Job Responsibilities block
_72
doc.setFontSize(14);
_72
doc.setFont("helvetica", "bold");
_72
doc.text("Job Responsibilities", 20, newY + newNewY + 75);
_72
doc.setFontSize(10);
_72
doc.setFont("helvetica", "normal");
_72
const splitJob = doc.splitTextToSize(jobResponsibilities, 170);
_72
doc.text(splitJob, 20, newY + newNewY + 85);
_72
_72
return doc.output("datauristring");
_72
}

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.

Resume

Now, let's create the next task: open resume.ts again and add the following code:


_86
import { client } from "@/trigger";
_86
import { eventTrigger } from "@trigger.dev/sdk";
_86
import { z } from "zod";
_86
import { prompts } from "@/utils/openai";
_86
import { TCompany, TUserDetails } from "@/components/Home";
_86
import { createResume } from "@/utils/resume";
_86
_86
const companyDetails = (companies: TCompany[]) => {
_86
let stringText = "";
_86
for (let i = 1; i < companies.length; i++) {
_86
stringText += ` ${companies[i].companyName} as a ${companies[i].position} on technologies ${companies[i].technologies} for ${companies[i].workedYears} years.`;
_86
}
_86
return stringText;
_86
};
_86
_86
client.defineJob({
_86
id: "create-resume",
_86
name: "Create Resume",
_86
version: "0.0.1",
_86
integrations: {
_86
resend,
_86
},
_86
trigger: eventTrigger({
_86
name: "create.resume",
_86
schema: z.object({
_86
firstName: z.string(),
_86
lastName: z.string(),
_86
photo: z.string(),
_86
email: z.string().email(),
_86
companies: z.array(
_86
z.object({
_86
companyName: z.string(),
_86
position: z.string(),
_86
workedYears: z.string(),
_86
technologies: z.string(),
_86
})
_86
),
_86
}),
_86
}),
_86
run: async (payload, io, ctx) => {
_86
const texts = await io.runTask("openai-task", async () => {
_86
return Promise.all([
_86
await generateResumeText(
_86
prompts.profileSummary(
_86
payload.firstName,
_86
payload.companies[0].position,
_86
payload.companies[0].workedYears,
_86
payload.companies[0].technologies
_86
)
_86
),
_86
await generateResumeText(
_86
prompts.jobResponsibilities(
_86
payload.firstName,
_86
payload.companies[0].position,
_86
payload.companies[0].workedYears,
_86
payload.companies[0].technologies
_86
)
_86
),
_86
await generateResumeText(
_86
prompts.workHistory(
_86
payload.firstName,
_86
payload.companies[0].position,
_86
payload.companies[0].workedYears,
_86
payload.companies
_86
)
_86
),
_86
]);
_86
});
_86
_86
console.log("passed chatgpt");
_86
_86
const pdf = await io.runTask("convert-to-html", async () => {
_86
const resume = createResume({
_86
userDetails: payload,
_86
picture: payload.photo,
_86
profileSummary: texts[0],
_86
jobResponsibilities: texts[1],
_86
workHistory: texts[2],
_86
});
_86
_86
return { final: resume.split(",")[1] };
_86
});
_86
_86
console.log("converted to pdf");
_86
},
_86
});

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.


_10
RESEND_API_KEY=<place_your_API_key>

Key

Install the Trigger.dev Resend integration package to your Next.js project.


_10
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:


_86
import { client } from "@/trigger";
_86
import { eventTrigger } from "@trigger.dev/sdk";
_86
import { z } from "zod";
_86
import { prompt } from "@/utils/openai";
_86
import { TCompany, TUserDetails } from "@/components/Home";
_86
import { createResume } from "@/utils/resume";
_86
import { Resend } from "@trigger.dev/resend";
_86
_86
const resend = new Resend({
_86
id: "resend",
_86
apiKey: process.env.RESEND_API_KEY!,
_86
});
_86
_86
const companyDetails = (companies: TCompany[]) => {
_86
let stringText = "";
_86
for (let i = 1; i < companies.length; i++) {
_86
stringText += ` ${companies[i].companyName} as a ${companies[i].position} on technologies ${companies[i].technologies} for ${companies[i].workedYears} years.`;
_86
}
_86
return stringText;
_86
};
_86
_86
client.defineJob({
_86
id: "create-resume",
_86
name: "Create Resume",
_86
version: "0.0.1",
_86
integrations: {
_86
resend
_86
},
_86
trigger: eventTrigger({
_86
name: "create.resume",
_86
schema: z.object({
_86
firstName: z.string(),
_86
lastName: z.string(),
_86
photo: z.string(),
_86
email: z.string().email(),
_86
companies: z.array(z.object({
_86
companyName: z.string(),
_86
position: z.string(),
_86
workedYears: z.string(),
_86
technologies: z.string()
_86
}))
_86
}),
_86
}),
_86
run: async (payload, io, ctx) => {
_86
_86
const texts = await io.runTask("openai-task", async () => {
_86
return Promise.all([
_86
await generateResumeText(prompts.profileSummary(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
_86
await generateResumeText(prompts.jobResponsibilities(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),
_86
await generateResumeText(prompts.workHistory(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies))
_86
]);
_86
});
_86
_86
console.log('passed chatgpt');
_86
_86
const pdf = await io.runTask('convert-to-html', async () => {
_86
const resume = createResume({
_86
userDetails: payload,
_86
picture: payload.photo,
_86
profileSummary: texts[0],
_86
jobResponsibilities: texts[1],
_86
workHistory: texts[2],
_86
});
_86
_86
return {final: resume.split(',')[1]}
_86
});
_86
_86
console.log('converted to pdf');
_86
_86
await io.resend.sendEmail('send-email', {
_86
to: payload.email,
_86
subject: 'Resume',
_86
html: 'Your resume is attached!',
_86
attachments: [
_86
{
_86
filename: 'resume.pdf',
_86
content: Buffer.from(pdf.final, 'base64'),
_86
contentType: 'application/pdf',
_86
}
_86
],
_86
from: "Nevo David <[email protected]>",
_86
});
_86
_86
console.log('Sent email');
_86
},
_86
});

We have the Resend instance at the top of our file loaded with our API key from the dashboard.

We have the


_10
integrations: {
_10
resend
_10
},

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 ๐ŸŽ‰

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!