DEV Community

Cover image for 🚀Creating a resume builder with NextJS, Trigger.dev and GPT4🔥✨
Eric Allam for Trigger.dev

Posted on • Originally published at trigger.dev

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

 

GiveUsStars

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
Enter fullscreen mode Exit fullscreen mode

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

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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({ })
}
Enter fullscreen mode Exit fullscreen mode

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.

npx @trigger.dev/cli@latest init
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) => {
  }
});
Enter fullscreen mode Exit fullscreen mode

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.

npm install @trigger.dev/openai
Enter fullscreen mode Exit fullscreen mode

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

OPENAI_API_KEY=<your_api_key>
Enter fullscreen mode Exit fullscreen mode

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)?`,
};
Enter fullscreen mode Exit fullscreen mode

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))
      ]);
    });
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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:

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');
  },
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Key

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

npm install @trigger.dev/resend
Enter fullscreen mode Exit fullscreen mode

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');
  },
});
Enter fullscreen mode Exit fullscreen mode

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

We have the

  integrations: {
      resend
  },
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (11)

Collapse
 
srbhr profile image
Saurabh Rai

Really awesome tutorial. I'll surely try to use this service with Resume-Matcher.

Collapse
 
valvonvorn profile image
val von vorn • Edited

Let AI write our resume and let AI do our job, hoping that nobody finds out we are making ourselves obsolete.

Collapse
 
nevodavid profile image
Nevo David

Old, not obsolete

Collapse
 
ranjancse profile image
Ranjan Dailata

The LLMs can do these things easily. In-fact, it's pretty straightforward for the LLMs to revise or generate an ATS compatible resumes.

Collapse
 
nathan_tarbert profile image
Nathan Tarbert

I love this. I’m going to follow along and build it. Thanks for creating a great tutorial!

Collapse
 
wakywayne profile image
wakywayne

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?

Collapse
 
elihood profile image
EliHood

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.

Collapse
 
hasanelsherbiny profile image
Hasan Elsherbiny

awesome effort 👏👏

Collapse
 
shricodev profile image
Shrijal Acharya

Great read! 🔥 One excellent hack for generating a resume. 😻 I will definitely give Trigger.dev a try for running background jobs.

Collapse
 
bernardkibathi profile image
Bernard K

This is cool.

Collapse
 
mainak0907 profile image
Mainak Chattopadhyay

Having an issue with the project .. Can anyone help me out?