Building a Job Board with Next.js, Chakra UI, and Directus
Tags: nextjs, directus, api, chakraui
Published: 2023-09-13 10:23:14

---
title: Building a Job Board with Next.js, Chakra UI, and Directus
published: true
description: This article covers 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.
tags: nextjs, directus, api, chakraui
cover_image: "https://marketing.directus.app/assets/5d7706ee-921a-432b-a052-e4292ab501a7.png"
canonical_url: "https://docs.directus.io/blog/building-a-job-board"
---

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:

![Job Portal Overview](https://marketing.directus.app/assets/57e3e75d-d240-4a6f-b751-0af945983d73.gif)

**TL;DR: If you'd like to immediately access the code repository, get it [here](https://github.com/directus-community/job-board)**

## 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:

```tsx
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 **http://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`.

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

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

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

```js
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 UI
- The posted date is displayed using the `friendlyTime` code to format the `datePosted` 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

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


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

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

![Job Board ](https://marketing.directus.app/assets/9fb98c51-69e4-4e0e-940d-33028f8270eb.png)

## 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

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

```tsx
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 with `limit: -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` to` false` (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 the `DIRECTUS_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

![Job Detail](https://marketing.directus.app/assets/3699c2b7-b8b4-4443-af40-f1e4557fa6e1.png)


## 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:

```tsx
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](https://github.com/directus-community/job-board)

## 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

If you have any questions or need further assistance, please feel free to drop by our [Discord server](https://directus.chat/).