Building a Job Description Generator with Next.js, OpenAI GPT-4, and Sheet.best

Writing great job descriptions is critical to any hiring process. While writing well-thought, detailed job descriptions is extremely time consuming, if written well, it might help you avoid attracting the wrong talent pool.

In this article, we will create a Job Description Generator powered by GPT4 using Next.js. We’ll use Tailwind CSS for styling and Sheet.best APIs to use a Google sheet as a database for this project.

At the end of this tutorial, you should be able to use the underlying concepts to potentially build any sort of gpt based tool very quickly.

What We’ll Create

If you’d like to have a look at the end result, this https://ai-jd-generator.vercel.app/ is what we’d be building.

Before we dive into the writing some code, let’s take a moment to think from the the first principles and understand what are the core elements of a great job description:

  • Role: What role are you hiring for. While usually you keep it straightforward, some people get creative and fancier titles. The idea is to standout and make more people apply.
  • Requirements & expectations: How much relevant experience are you looking for in the candidate? What are the expectations associated. This is usually more detailed.
  • Compensation
  • Location

Naturally, these would be the inputs that we’d require from a user to create a detailed job description. Additionally, we’ll also ask for an optional sample job description, in case, the user is looking to keep the tone of the JD similar to something he has in mind.

You can also include a field that talks about the company, work environment, culture, etc.

Building the Job Description Generator

Time to take the gloves off and start writing some code, let’s dive into building our Job Description Generator step by step:

Step 1: Set up the Next.js Project

Basically follow this guide https://tailwindcss.com/docs/guides/nextjs

Create a new Next.js project using the following command:

npx create-next-app@latest ai-job-description-generator --eslint

Install Tailwind CSS:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Configure Tailwind CSS:

Create a file in the root directory tailwind.config.js and add the following

module.exports = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx}",
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
 
    // Or if using `src` directory:
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Add the Tailwind directives to your globals.css file

@tailwind base;
@tailwind components;
@tailwind utilities;

Step 2: Create the Job Description Generator UI and the API routes

Clear up all the unnecessary boiler code present inside index.js including the CSS.

First off, let’s get the form in place.

import { useState } from "react";

export default function Home() {
  const [role, setRole] = useState("");
  const [bulletPoints, setBulletPoints] = useState([]);
  const [compensation, setCompensation] = useState("");
  const [location, setLocation] = useState("");
  const [exampleDescription, setExampleDescription] = useState("");

  const generateJobDescription = (e) => {
    e.preventDefault();
  };
  return (
    <div className="mx-auto w-11/12 sm:w-2/5">
      <h1 className="text-4xl text-center font-bold my-6">
        Job Description Generator
      </h1>
      <form onSubmit={generateJobDescription} className="my-4">
        <div className="mb-4">
          <label htmlFor="role" className="block text-base mb-1">
            Role
          </label>
          <input
            type="text"
            id="role"
            value={role}
            onChange={(e) => setRole(e.target.value)}
            className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>
        <div className="mb-4">
          <label htmlFor="bullet-points" className="block text-base mb-1">
            Requirements/Expectations (3-5 bullet points)
          </label>
          <textarea
            id="bullet-points"
            value={bulletPoints.join("\\n")}
            onChange={(e) => setBulletPoints(e.target.value.split("\\n"))}
            className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>
        <div className="mb-4">
          <label htmlFor="compensation" className="block text-base mb-1">
            Compensation
          </label>
          <input
            type="text"
            id="compensation"
            value={compensation}
            onChange={(e) => setCompensation(e.target.value)}
            className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>
        <div className="mb-4">
          <label htmlFor="location" className="block text-base mb-1">
            Location
          </label>
          <input
            type="text"
            id="location"
            value={location}
            onChange={(e) => setLocation(e.target.value)}
            className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>
        <div className="mb-4">
          <label htmlFor="example-description" className="block text-base mb-1">
            Example Job Description (optional)
          </label>
          <textarea
            id="example-description"
            value={exampleDescription}
            onChange={(e) => setExampleDescription(e.target.value)}
            className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>
        <button
          type="submit"
          className="bg-blue-500 w-full text-white px-6 py-2 rounded-md hover:bg-blue-700"
        >
          Generate
        </button>
      </form>
    </div>
  );
}

If you followed along well, this should look like this at the moment.

Next, we need to generate the job description from the provided user inputs and save it to our database.

We’ll make use of GPT4 for this. GPT 4 is known to adhere well to detailed contexts. You can always compare results across models and see what performs the best for your use case.

This project https://github.com/Nutlope/twitterbio by https://github.com/Nutlope does an excellent job at showcasing how to stream open ai chat api responses. Definitely check it out. We’ll make use of a similar implementation for our use case.

Creating the generate endpoint:

const { OpenAIStream } = require("../../utils/OpenAIStream");

if (!process.env.OPENAI_API_KEY) {
  throw new Error("Missing env var from OpenAI");
}

export const config = {
  runtime: "edge",
};

const handler = async (req) => {
  const { role, bulletPoints, compensation, location, exampleDescription } =
    await req.json();

  const prompt = `Generate a job description for the following role:
    Role: ${role}
    Requirements/Expectations:
    ${bulletPoints}
    Compensation: ${compensation}
    Location: ${location}
    Example job description (optional): ${exampleDescription}
    `;

  const payload = {
    model: "gpt-4",
    messages: [{ role: "user", content: prompt }],
    temperature: 0.7,
    top_p: 1,
    frequency_penalty: 0,
    presence_penalty: 0,
    max_tokens: 256,
    stream: true,
    n: 1,
  };

  const stream = await OpenAIStream(payload);
  return new Response(stream);
};

export default handler;

Calling the api route from the UI:

const generateJobDescription = async (e) => {
    e.preventDefault();
    setGeneratedJD("");
    setGenerating(true);

    const response = await fetch("/api/generate", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        role,
        bulletPoints,
        compensation,
        location,
        exampleDescription,
      }),
    });

    if (!response.ok) {
      throw new Error(response.statusText);
    }

    const data = response.body;
    if (!data) {
      return;
    }

    const reader = data.getReader();
    const decoder = new TextDecoder();
    let done = false;
    let generatedStr = "";
    while (!done) {
      const { value, done: doneReading } = await reader.read();
      done = doneReading;
      const chunkValue = decoder.decode(value);
      generatedStr += chunkValue;
      setGeneratedJD(generatedStr);
    }
    setGenerating(false);

    // Save the generated job description once the stream ends
    fetch("/api/submit", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        generatedJD: generatedStr,
      }),
    });
  };

Saving the job descriptions

If you haven’t used sheet.best before, I’d recommend you to check their documentation out at https://sheetbestdocs.netlify.app/#using-your-rest-api

export const config = {
  runtime: "edge",
};

const handler = async (req) => {
  try {
    const { generatedJD } = await req.json();
    const res = await fetch(
      "<https://sheet.best/api/sheets/><YOUR_SHEETBEST_CONNECTION_ID>",
      {
        method: "POST",
        mode: "cors",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ generatedJD }),
      }
    );
    const data = await res.json();
    return new Response(data);
  } catch (error) {
    return new Response(error);
  }
};

export default handler;

Fetch these on page load:

useEffect(() => {
    (async () => {
      const res = await fetch("/api/fetch", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
      });
      const JDs = await res.json();
      if (JDs?.length > 0) {
        setPreviouslyGeneratedJDs(JDs);
      }
    })();
  }, []);

Step 3: Deploying the Application

Quickly deploy your Job Description Generator using a platform like AWS Amplify or Vercel.

Here’s our deployed version:

https://ai-jd-generator.vercel.app/

You can further use sheet.best to store the count of descriptions generated against each ip address and limit the number of job descriptions generated (unless you’d like to go ahead and put a an auth wall).

How cool was this? Use the underlying concepts to create any sort of ai generator as your next hackathon project or a growth tool for your mainstream saas.

We did something similar for our portfolio company inlytics.io. We created a Linkedin headline generator. Do check it out at linkedinheadline.com

Sheet.best is ❤️

Using sheet.best is usually the ideal choice for spinning something up real quick.

  1. Super Easy to Set Up: Your google sheet url and 30 seconds is all it takes to turn a spreadsheet into a REST API
  2. No Back-end Required: Storing and retrieving data typically requires a back-end with a database. However, Sheet.best eliminates the need for a separate back-end, as it allows you to use Google Sheets as your database. This massively improves the time to market.
  3. Real-time 2-way Sync: Sheet.best automatically syncs the data between your Google Sheet and the API. This means that any changes you make in the Google Sheet will be instantly reflected in the API, and vice versa.
  4. Flexible Data Structure: With Sheet.best, you can store data in a flexible and organized manner. You can create columns in your Google Sheet for different data fields, such as role and job description, and Sheet.best will automatically map them to the API. Use formulas or pivot tables, we’ll be ready with json whenever you ask for it.
  5. Cost-effective: Spend hours bootstrapping rest endpoints or use sheet.best’s pay as you grow pricing tiers.

Do let us know if you have any questions or get stuck at any point during building this in the comments below.

Leave a Reply

Your email address will not be published. Required fields are marked *