Building a Dynamic Job Board with Issues Github, Next.js, Tailwind CSS and MobX-State-Tree
Tags: nextjs, tailwindcss, react, typescript
Published: 2024-03-28 11:22:30

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/](https://wwwhat-dev.vercel.app/) or my github repo [https://github.com/tuantvk/wwwhat.dev](https://github.com/tuantvk/wwwhat.dev/)

## How It Works?

<p align="center">
<img src="https://raw.githubusercontent.com/tuantvk/wwwhat.dev/main/assets/how-it-works.png" title="how-it-works.png">
</p>

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

```txt
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.

```md
---
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.
```

<p align="center">
<img src="https://raw.githubusercontent.com/tuantvk/wwwhat.dev/main/assets/jobs.png" title="jobs.png">
</p>

## Prerequisites

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

- Familiarity with [TypeScript](https://www.typescriptlang.org/), [React](https://react.dev/) and [Next.js](https://nextjs.org/)
- Basic knowledge of [Tailwind CSS](https://tailwindcss.com/) and [MobX-State-Tree](https://mobx-state-tree.js.org/)

## Creating a Next.js Project

To create our [Next.js](https://nextjs.org/) app, we navigate to our preferred directory and run the terminal command below:

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

On installation, choose the following:

```bash
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:

```bash
yarn dev
# or
pnpm dev
# or
bun dev
```

You should have your app running at [http://localhost:3000](http://localhost:3000)

### Installing the required dependencies

```bash
# 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](https://github.com/tuantvk/wwwhat.dev/blob/main/app/models/Issues.ts)

```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](https://docs.github.com/en/rest/issues/issues).

In file [IssuesStore](https://github.com/tuantvk/wwwhat.dev/blob/main/app/models/IssuesStore.ts), we call endpoint and set data response to state.

```ts
// 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.

```ts
import parseFrontMatter from "front-matter"

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

## Render UI

Skip some [components](https://github.com/tuantvk/wwwhat.dev/tree/main/app/components) in my github repo, we get data from mobx and render `CardIssue` with map function.

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

  return (
    <InfiniteScroll>
      {issues.items.map((item) => (
        <CardIssue key={item.node_id} item={item} />
      ))}
    </InfiniteScroll>
  )
}
```

```tsx
// 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](https://github.com/tuantvk/wwwhat.dev/issues) or [Pull requests](https://github.com/tuantvk/wwwhat.dev/pulls) for me.