Building a Job Board with Next.js, Chakra UI, and Directus
Job boards are an ideal way to connect job seekers to career opportunities. However, creating one involves far more than putting up postings. As developers, we need to incorporate essential features like job listing management, search functionality, and more.
A well-designed job board allows employers to effortlessly post and manage job openings. It also enables job seekers to browse through jobs using intuitive filtering and searching.
In this tutorial, we will cover the development of the core components of a job board step-by-step, using Next.js for the frontend and Directus as the backend tool to manage job data.
Here's a quick overview of how the job board looks to job seekers:
GIF
TL;DR: If you'd like to immediately access the code repository, get it here
Prerequisites
To follow along with this tutorial, you need to have the following:
A Directus self-hosted or cloud account
Familiarity with TypeScript, React and Next.js
Basic knowledge of Chakra UI - a React component library.
Setting up the Jobs Collection in Directus
Before we can start fetching and displaying job listings, we need to define the data model that will store our job data in Directus.
Log in to your Directus app and navigate to Settings > Data Model. Click the + icon to create a new collection called “jobs”.
Add the following fields and save:
title (Type: String, Interface: Input)
company (Type: String, Interface: Input)
location (Type: String, Interface: Input)
logo (Type: UUID, Interface: Image)
tags (Type: JSON, Interface: Tags)
remote (Type: Boolean, Interface: Toggle)
datePosted (Type: DateTime, Interface: DateTime)
salaryRange (Type: String, Interface: Input)
content (Type: Text, Interface: WYSIWYG)
Adding Content to the Jobs Collection
With the data model in place, we're now ready to populate our collection with some real job listings.
From the Directus sidebar, navigate to "Content Module" and select the "Jobs" collection we created earlier.
Go ahead and add a few sample jobs to have content to work with as we build out the frontend interface. You can always come back later to enter more postings as needed.
Building the Frontend Application
With the backend in place, we need to set up a working frontend interface to display the job listings.
Installing Next.js
In your terminal, run the following command:
npx create-next-app@latest job-board
cd job-board
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? No
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) No
Would you like to customize the default import alias? Yes
What import alias would you like configured? @/*
Remove boilerplate code from index.tsx
and update meta tags:
import Head from 'next/head';
export default function Home() {
return (
<>
<Head>
<title>Job Board</title>
<meta name='description' content='Job board app to connect job seekers to opportunities' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<link rel='icon' href='/favicon.ico' />
</Head>
<main>
<h1>Find Your Dream Job</h1>
</main>
</>
);
}
Start your local server using the command:
npm run dev
You should have your app running at localhost:3000
Installing the required dependencies
Run the following command in your terminal:
npm install @directus/sdk @chakra-ui/react @emotion/react @emotion/styled framer-motion react-icons
Directus SDK for fetching jobs
Chakra UI for styling (Emotion and Framer are Chakra UI dependencies)
React icons
Setting up Chakra UI
To use Chakra UI in your project, you need to set up the ChakraProvider
at the root of your application.
Go to pages/_app.tsx
and wrap the Component with the ChakraProvider
.
// pages/_app.tsx
import type { AppProps } from 'next/app';
import { ChakraProvider } from '@chakra-ui/react'
export default function App({ Component, pageProps }: AppProps) {
return (
<ChakraProvider>
<Component {...pageProps} />
</ChakraProvider>
);
}
Displaying the Job Listings
To display the job listings from Directus, we need to set up the types for our jobs and also create two components: the JobCard
and JobList
components.
Defining the Types for the Jobs
Since we’re working with TypeScript, to ensure type safety when working with job data from Directus, let’s set up an interface that defines the expected shape of each job object.
At the root of your project, create a new directory called lib, and inside it, a new file called directus.ts
.
export type Job = {
id: number;
title: string;
company: string;
content: string;
location: string;
datePosted: string;
logo: string;
tags: string[];
remote: boolean;
salaryRange: string;
};
type Schema = {
jobs: Job[];
};
Building the Job Card Component
Create a src/job-card.tsx
file and input this code:
import { Avatar, Box, HStack, Heading, Icon, LinkBox, LinkOverlay, Stack, Tag, Text } from '@chakra-ui/react';
import NextLink from 'next/link';
import { MdBusiness, MdLocationPin, MdOutlineAttachMoney } from 'react-icons/md';
import { Job } from '@/lib/directus';
import { friendlyTime } from '@/lib/friendly-time';
type JobCardProps = {
data: Job;
};
export function JobCard(props: JobCardProps) {
const { data, ...rest } = props;
const { id, title, company, location, datePosted, logo, tags, remote, salaryRange } = data;
return (
<Box
border='1px solid'
borderColor='gray.300'
borderRadius='md'
_hover={{ borderColor: 'black', boxShadow: 'sm' }}
p='6'
{...rest}
>
<LinkBox as='article'>
<Stack direction={{ base: 'column', lg: 'row' }} spacing='8'>
<Avatar size='lg' name={title} src={logo} />
<Box>
<LinkOverlay as={NextLink} href={`/${id}`}>
<Heading size='md'>{title}</Heading>
</LinkOverlay>
<Text>{company}</Text>
<Stack mt='2' spacing={1}>
<HStack spacing={1}>
<Icon as={MdLocationPin} boxSize={4} />
<Text>{location}</Text>
</HStack>
<HStack spacing={1}>
<Icon as={MdBusiness} boxSize={4} />
<Text>{remote === 'true' ? 'Remote' : 'Onsite'}</Text>
</HStack>
<HStack spacing={1}>
<Icon as={MdOutlineAttachMoney} boxSize={4} />
<Text>{salaryRange}</Text>
</HStack>
</Stack>
</Box>
<HStack spacing={2} flex='1'>
{tags.map((tag, index) => (
<Tag key={index} colorScheme='gray'>
{tag}
</Tag>
))}
</HStack>
<Text alignSelf={{ base: 'left', lg: 'center' }}>
Posted {friendlyTime(new Date(datePosted))}
</Text>
</Stack>
</LinkBox>
</Box>
);
}
Inside the lib
directory and add a friendly-time.ts
file to format the code:
export const friendlyTime = (date: Date) => {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
let interval = seconds / 31536000;
if (interval > 1) {
return Math.floor(interval) + ' years ago';
}
interval = seconds / 2592000;
if (interval > 1) {
return Math.floor(interval) + ' months ago';
}
interval = seconds / 86400;
if (interval > 1) {
return Math.floor(interval) + ' days ago';
}
interval = seconds / 3600;
if (interval > 1) {
return Math.floor(interval) + ' hours ago';
}
interval = seconds / 60;
if (interval > 1) {
return Math.floor(interval) + ' minutes ago';
}
return Math.floor(seconds) + ' seconds ago';
};
This component is styled and built using the
Box
,Stack
,HStack
, and more components from Chakra UIThe posted date is displayed using the
friendlyTime
code to format thedatePosted
into a user-friendly time format (e.g., "2 days ago")
Building the Job List Component
Inside the components directory, create a job-list.tsx
file that shows the list of jobs
import { Stack } from '@chakra-ui/react';
import { JobCard } from './job-card';
import { Job } from '@/lib/directus';
type JobListProps = {
data: Job[];
};
export function JobList(props: JobListProps) {
const { data } = props;
return (
<Stack spacing='4'>
{data.map((job, index) => (
<JobCard key={index} data={job} />
))}
</Stack>
);
}
Fetching the Jobs from Directus
A crucial part of the job board is being able to fetch and display the latest job listings. To accomplish this, we’re going to use the Directus SDK.
At the root of your project, create a .env
file and add your Directus URL
DIRECTUS_URL=add-your-directus-url-here
To share a single instance of the Directus SDK between multiple pages in this project, we need to set up a helper file that can be imported later.
Inside the directus.ts
file, create a Directus client by importing the createDirectus
hook and rest
composable.
import { createDirectus, rest } from '@directus/sdk';
export interface Job {
id: number;
title: string;
company: string;
content: string;
location: string;
datePosted: string;
logo: string;
tags: string[];
remote: boolean;
salaryRange: string;
}
interface Schema {
jobs: Job[];
}
const directus = createDirectus<Schema>(process.env.DIRECTUS_URL!).with(rest());
export default directus;
Now, go over to index.tsx
and update the code to render the jobs
import { JobList } from '@/components/job-list';
import { Box, Heading, Stack } from '@chakra-ui/react';
import { readItems } from '@directus/sdk';
import Head from 'next/head';
import directus, { Job } from '../lib/directus';
export default function Home({ jobs }: { jobs: Job[] }) {
return (
<>
<Head>
<title>Job Board</title>
<meta
name='description'
content='Job board app to connect job seekers to opportunities'
/>
<meta name='viewport' content='width=device-width, initial-scale=1' />
<link rel='icon' href='/favicon.ico' />
</Head>
<main>
<Box p={{ base: '12', lg: '24' }}>
<Stack mb='8' maxW='sm'>
<Heading>Find Your Dream Job</Heading>
</Stack>
<JobList data={jobs} />
</Box>
</main>
</>
);
}
export async function getStaticProps() {
try {
const jobs = await directus.request(
readItems('jobs', {
limit: -1,
fields: ['*'],
})
);
if (!jobs) {
return {
notFound: true,
};
}
// Format the image field to have the full URL
jobs.forEach((job) => {
job.logo = `${process.env.DIRECTUS_URL}assets/${job.logo}`;
});
return {
props: {
jobs,
},
};
} catch (error) {
console.error('Error fetching jobs:', error);
return {
notFound: true,
};
}
}
You should have something like this on your frontend
Displaying the Job Detail
With the ability to fetch job listings from Directus established, the next step is to dynamically display each job's content on individual job pages. Next.js provides a streamlined way to do this through its automatic static and server-side rendering features.
But first, we need to build out a JobContent
Component.
Building the Job Content Component
Within the components
folder, create a job-content.tsx
file to show the job content for each job
import { Job } from '@/lib/directus';
import { Avatar, Box, Button, HStack, Heading } from '@chakra-ui/react';
import Link from 'next/link';
type JobContentProps = {
data: Job;
};
export function JobContent(props: JobContentProps) {
const { data } = props;
const { content, logo, title, company } = data;
return (
<Box px={{ base: '12', lg: '24' }}>
<Button as={Link} href='/'>
Back to jobs
</Button>
<Box py='16'>
<HStack spacing='4'>
<Avatar size='lg' name={title} src={logo} />
<Heading size='lg'>{company}</Heading>
</HStack>
<Box maxW='3xl' dangerouslySetInnerHTML={{ __html: content }} />
</Box>
</Box>
);
}
To dynamically render the job pages, create a [jobId].tsx
in the pages
directory and put the following code in it:
import { readItem, readItems } from '@directus/sdk';
import { GetStaticPaths, GetStaticProps } from 'next';
import directus from '../lib/directus';
import { Box } from '@chakra-ui/react';
import { JobContent } from '@/components/job-content';
import { Job } from '../lib/directus';
type JobDetailsProps = {
job: Job;
};
export default function JobDetails(props: JobDetailsProps) {
const { job } = props;
return (
<Box p={{ base: '12', lg: '24' }}>
<JobContent data={job} />
</Box>
);
}
export const getStaticPaths: GetStaticPaths = async () => {
try {
const jobs = await directus.request(
readItems('jobs', {
limit: -1,
fields: ['id'],
})
);
const paths = jobs.map((job) => {
// Access the data property to get the array of jobs
return {
params: { jobId: job.id.toString() },
};
});
return {
paths: paths || [],
fallback: false,
};
} catch (error) {
console.error('Error fetching paths:', error);
return {
paths: [],
fallback: false,
};
}
};
export const getStaticProps: GetStaticProps = async (context) => {
try {
const jobId = context.params?.jobId as string;
const job = await directus.request(
readItem('jobs', jobId, {
fields: ['*'],
})
);
if (job) {
job.logo = `${process.env.DIRECTUS_URL}assets/${job.logo}`;
}
return {
props: {
job,
},
};
} catch (error) {
console.error('Error fetching job:', error);
return {
notFound: true,
};
}
};
The code fetches and renders dynamic job details using static site generation with Next.js.
Static Paths Generation (
getStaticPaths
):Implements the
getStaticPath
s function, a part of Next.js's static site generation.Requests job data using Directus's
readItems
function withlimit: -1
to fetch all job IDs.Maps retrieved job IDs to an array of
params
objects to generate dynamic routes.Sets the paths array to the generated paths and
fallback
tofalse
(no fallback behavior).
Static Props Generation (
getStaticProps
):Implements the
getStaticProps
function, used in static site generation to fetch data for a specific page.Extracts the
jobId
parameter from the context object to identify the requested job.Requests detailed job information using Directus's
readItem
function for the specified job.Modifies the job object by appending the
logo
URL with theDIRECTUS_URL
.Returns fetched job data within the
props
object.
Now, whenever you click on a job, you’ll be able to see all the details about that job
Implementing the Search Functionality
To enable users to search for jobs by title in our application, we need to build out the functionality for querying the jobs data store based on the job title field. We also need to build out the search input UI to handle this.
In index.tsx
update the code as follows:
export default function Home(props: { jobs: Job[] }) {
const { jobs } = props;
const router = useRouter();
const searchQuery = router.query.search?.toString();
const searchResult = searchQuery
? jobs.filter((job) => {
return job.title.toLowerCase().includes(searchQuery.toLowerCase());
})
: jobs;
return (
<>
<Head>
<title>Job Board</title>
<meta
name='description'
content='Job board app to connect job seekers to opportunities'
/>
<meta name='viewport' content='width=device-width, initial-scale=1' />
<link rel='icon' href='/favicon.ico' />
</Head>
<main>
<Box p={{ base: '12', lg: '24' }}>
<Stack mb='8' direction={{ base: 'column', md: 'row' }}>
<Heading flex='1'>Find Your Dream Job</Heading>
<InputGroup w='auto'>
<InputLeftElement color='gray.400'>
<FaSearch />
</InputLeftElement>
<Input
placeholder='Search jobs...'
onChange={(event) => {
const value = event.target.value;
router.replace({
query: { search: value },
});
}}
/>
</InputGroup>
</Stack>
<JobList data={searchResult} />
</Box>
</main>
</>
);
}
We use the
useRouter
hook from Next.js to access the query parameters from the URL.If a search query was provided, we filter the jobs array to only include those whose title matched the search query in a case-insensitive manner. If no search query was present, we simply set the
searchResult
to the original jobs array without any filtering.
Here's the repository to find the code
Conclusion
In this tutorial, you've learned how to fetch job data from a Directus instance, display it on your app, and implement search functionality.
Some natural next steps could include:
Implementing user authentication via JWT tokens to allow job seekers to create custom profiles, save jobs, and track application status
Integrating email and notification systems to allow job alerts to be automatically sent to users when new jobs are posted
Adding advanced filtering and sorting of job results beyond just search title