DEV Community

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

Posted on

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

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

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

Prerequisites

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

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

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? @/* 
Enter fullscreen mode Exit fullscreen mode

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

yarn dev # or pnpm dev # or bun dev 
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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.

Top comments (0)