DEV Community

Cover image for Building a Job Board with Next.js, Chakra UI, and Directus
Esther Adebayo for Directus

Posted on • Originally published at docs.directus.io

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:

Job Portal Overview

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

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

Start your local server using the command:

npm run dev
Enter fullscreen mode Exit fullscreen mode

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

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[];
};
Enter fullscreen mode Exit fullscreen mode

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

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';
};
Enter fullscreen mode Exit fullscreen mode
  • 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

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

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

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

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

You should have something like this on your frontend

Job Board

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

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

The code fetches and renders dynamic job details using static site generation with Next.js.

  • Static Paths Generation (getStaticPaths):

    • Implements the getStaticPaths 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 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 thejobId 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

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

If you have any questions or need further assistance, please feel free to drop by our Discord server.

Top comments (0)