Building a Dynamic Job Board with Issues Github, Next.js, Tailwind CSS and MobX-State-Tree
tuantvk
Posted on March 28, 2024
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:
// 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.
Posted on March 28, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.