Building a Dynamic Job Board with Issues Github, Next.js, Tailwind CSS and MobX-State-Tree

tuantvktuantvk
5 min read

In this tutorial, we will cover the development of the core components of a job board step-by-step, using Next.js, Tailwind CSS and MobX-State-Tree for the frontend and Issues Github as job data. Below is a list of what this tutorial covers.

  • How It Works?

  • Prerequisites

  • Creating a Next.js Project

  • Data Fetching

  • Parse Front Matter

  • Render UI

  • Conclusion

Visit website at https://wwwhat-dev.vercel.app/ or my github repo https://github.com/tuantvk/wwwhat.dev

How It Works?

When a Github user creates a new issue on Github, the website will call Github's api to get the issues in open state and exclude issues that have the label bug.

https://api.github.com/search/issues?q=is:issue repo:tuantvk/wwwhat.dev state:open -label:bug

Once all issues are retrieved, they will be displayed on the website. The displayed content will be based on the markup information as shown in the file below.

---
company: GitHub
logoCompany: https://user-images.githubusercontent.com/logo.png
shortDescription: GitHub is where over 100 million developers...
location: San Francisco, CA, United States
salary: $100K – $110K/yr
technologies: Java, JavaScript, Kotlin, Kubernetes, MongoDB, Node.js, PostgreSQL, Python
isRemoteJob: true
---

Lorem Ipsum is simply dummy text of the printing and typesetting industry.
Lorem Ipsum has been the industry's standard dummy text ever since the 1500s.

Prerequisites

To get the most out of this article, you need to have the following:

  • Familiarity with TypeScript, React and Next.js

  • Basic knowledge of Tailwind CSS and MobX-State-Tree

Creating a Next.js Project

To create our Next.js app, we navigate to our preferred directory and run the terminal command below:

npx create-next-app@latest <app-name>

On installation, choose the following:

Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes 
Would you like to use `src/` directory? No
Would you like to use App Router? (recommended) Yes
Would you like to customize the default import alias?  Yes
What import alias would you like configured? @/*

After running the script, move to the created directory and start the Next.js server:

yarn dev
# or
pnpm dev
# or
bun dev

You should have your app running at http://localhost:3000

Installing the required dependencies

# UI
yarn add axios dayjs react-infinite-scroller react-modal

# Markdown
yarn add front-matter react-markdown

# State management
yarn add mobx mobx-react-lite mobx-state-tree

Data Fetching

When we call endpoint https://api.github.com/search/issues, data response is so many key, but I limited like model below:

app/models/Issues.ts

// Minified
// Issues Model
import { Instance, types } from "mobx-state-tree"

export const ItemModel = types.model("ItemModel").props({
  node_id: types.identifier,
  id: types.number,
  title: types.string,
  html_url: types.string,
  body: types.string,
  created_at: types.string,
  labels: types.optional(types.array(LabelModel), []),
})

export const IssuesModel = types.model("IssuesModel").props({
  total_count: types.number,
  incomplete_results: types.boolean,
  items: types.array(ItemModel),
})

View more REST API endpoints for issues.

In file IssuesStore, we call endpoint and set data response to state.

// Minified
// Issues Store
import axios from "axios"
import { types, flow } from "mobx-state-tree"
import { API_GITHUB_SEARCH_ISSUES } from "@/constants/github"
import { IssuesModel } from "./Issues"

export const IssuesStoreModel = types
  .model("IssuesStore")
  .props({
    issues: types.maybeNull(IssuesModel),
  })
  .actions((self) => ({
    fetchIssues: flow(function* fetchIssues(params = "") {
      try {
        const response = yield axios.get(
          `${API_GITHUB_SEARCH_ISSUES} ${params}`,
        )
        self.issues = response.data
      } catch { }
    }),
    afterCreate() {
      this.fetchIssues()
    },
  }))

Parse Front Matter

Because body items are markdown content, we need to parse key/value from header content from markup information above.

import parseFrontMatter from "front-matter"

const {
  company,
  logoCompany,
  shortDescription,
  location,
  salary,
  technologies,
  isRemoteJob,
} = parseFrontMatter(item.body)?.attributes || {}

Render UI

Skip some components in my github repo, we get data from mobx and render CardIssue with map function.

// Minified
// page.tsx
const Home = () => {
  const { issues } = useIssuesStore()

  return (
    <InfiniteScroll>
      {issues.items.map((item) => (
        <CardIssue key={item.node_id} item={item} />
      ))}
    </InfiniteScroll>
  )
}
// CardIssue.tsx
"use client"
import Image from "next/image"
import Link from "next/link"
import { observer } from "mobx-react-lite"
import Markdown from "react-markdown"
import parseFrontMatter from "front-matter"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import { IItem } from "@/models/Issues"
import { useIssuesStore } from "@/models/IssuesStore"
import { IFrontMatter } from "@/definitions"
import { EASY_APPLY_LABEL } from "@/constants/labels"
import {
  IconBookmark,
  IconLocation,
  IconClock,
  IconZap,
  IconDollar,
  IconRemote,
} from "@/icons"

dayjs.extend(relativeTime)

interface Props {
  item: IItem
  onSeach: (tech: string) => void
}

export const CardIssue = observer(({ item, onSeach }: Props) => {
  const { bookmarks, toggleBookmark } = useIssuesStore()

  const isBookmark = bookmarks?.has(item.node_id)
  const isEasyApply = !!item?.labels?.find(
    (label) => label.name === EASY_APPLY_LABEL,
  )
  const createdAt = dayjs(item.created_at).fromNow(true)
  const {
    company,
    logoCompany,
    shortDescription,
    location,
    salary,
    technologies,
    isRemoteJob,
  } = parseFrontMatter<IFrontMatter>(item.body)?.attributes || {}

  return (
    <article
      key={item.id}
      title={item.title}
      className="relative cursor-pointer pb-3 px-3 pt-[22px] rounded-xl bg-white border-2 border-[#D9D9D9] hover:border-black shadow-[2px_2px_0px_#D9D9D9] hover:shadow-[4px_4px_0px_#FFCC00] ease-in-out duration-300"
    >
      <Link href={item.html_url} target="_blank">
        <Image
          src={logoCompany || "/apple-touch-icon.png"}
          alt="avatar"
          width={44}
          height={44}
          className="w-11 h-11 object-contain rounded absolute -top-[23px] left-0 right-0 mx-auto border-2 border-black shadow-[2px_2px_0px_#FFCC00]"
        />
        <h1 className="mt-1 font-heading text-base text-xl font-bold text-slate-700 hn-break-words truncate">
          {item.title}
        </h1>
        <div className="flex flex-row items-center">
          <span className="text-base font-medium text-slate-500 hn-break-words line-clamp-1">
            {company}
          </span>
          {isEasyApply && (
            <div className="flex flex-row items-center ml-5">
              <IconZap width={12} height={12} />
              <span className="text-xs ml-1 text-emerald-400">
                {EASY_APPLY_LABEL}
              </span>
            </div>
          )}
          {Boolean(isRemoteJob) && (
            <div className="flex flex-row items-center ml-5">
              <IconRemote width={14} height={14} />
              <span className="text-xs ml-1 text-violet-600">Remote job</span>
            </div>
          )}
        </div>
        <div className="mt-2">
          {shortDescription?.trim()?.length ? (
            <div className="text-sm text-slate-500 line-clamp-5">
              {shortDescription}
            </div>
          ) : (
            <Markdown
              skipHtml
              allowedElements={["p"]}
              className="text-sm text-slate-500 line-clamp-4"
            >
              {item.body}
            </Markdown>
          )}
        </div>
        <div className="flex flex-row mt-3 items-center justify-between">
          <div className="flex flex-row items-center">
            <IconDollar width={18} height={18} />
            <p className="ml-1 text-sm text-slate-700">{salary || "?"}</p>
          </div>
          <div className="flex flex-row items-center">
            <IconLocation width={18} height={18} fill="#94a3b8" />
            <p className="ml-1 text-sm text-slate-700">{location || "?"}</p>
          </div>
          <div className="flex flex-row items-center">
            <IconClock width={18} height={18} />
            <p className="ml-1 text-sm text-slate-700">{createdAt}</p>
          </div>
        </div>
      </Link>
      <div className="grid grid-cols-6 gap-2 mt-3">
        <div className="flex flex-row flex-wrap col-span-5 gap-2">
          {technologies
            ?.split(",")
            ?.slice(0, 6)
            ?.map((tech) => (
              <div
                key={tech}
                className="rounded bg-slate-100 px-2 py-1 text-xs text-slate-700 hover:bg-slate-200 ease-in-out duration-300"
                onClick={() => onSeach(tech?.trim())}
              >
                {tech?.trim()}
              </div>
            ))}
        </div>
        <div className="flex items-end justify-end">
          <IconBookmark
            width={22}
            height={22}
            fill={isBookmark ? "#ffcc00" : "#94a3b8"}
            className="hover:scale-125 ease-in-out duration-300"
            onClick={() => toggleBookmark(item)}
          />
        </div>
      </div>
    </article>
  )
})

Conclusion

With this, we created a job board using Next.js, Tailwind CSS and MobX-State-Tree and Issues Github as job data. I hope you’ve enjoyed this tutorial and are looking forward to building additional projects with Next.js.

This project in the tutorial is absolutely open source and if you want to add a feature or edit something, feel free clone it and make it your own or to fork and make your pull requests.

Any comments and suggestions are always welcome. Please make Issues or Pull requests for me.

0
Subscribe to my newsletter

Read articles from tuantvk directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

tuantvk
tuantvk

Visit site: https://sadagas.com