DEV Community

Hiring the right candidate and finding the perfect job can be challenging. Recruiters need an efficient way to manage job postings, screen applicants, and conduct virtual interviewsβall in one platform.
In this tutorial, you will learn how to build a job application and interviewing platform using Next.js, Stream, and Firebase. This app will allow recruiters to post job openings, review applications, and schedule interviews. Job seekers can also apply for jobs and communicate with recruiters.
The Stream Video & Audio API will enable recruiters to schedule and conduct virtual interviews, and the Stream Chat SDK will allow them to chat with shortlisted candidates.
App Overview
The application has two types of users: job seekers and recruiters.
Job seekers can:
- Have a public profile URL and can upload a downloadable resume.
- See a job feed tailored to their selected interests upon signing in.
- Apply for jobs, chat with recruiters, and join online interviews within the application.
- Track their progress on every job application.
Recruiters can:
- Create job postings.
- Manage applications by accepting or rejecting candidates.
- Chat privately with shortlisted applicants.
- Schedule online interviews and hire the best candidate.
Here is the application demo:
Prerequisites
To fully understand this tutorial, you need to have a basic understanding of React or Next.js.
We will use the following tools:
- Firebase - a Backend-as-a-service platform developed by Google to enable us to add authentication, database, real-time communication, file storage, cloud functions, and many others within software applications.
- Stream React Chat SDK and React Video SDK - SDKs that enable real-time chat and video/audio communication in your application.
- Shadcn UI: a UI component library that provides customizable, beautifully designed, and accessible UI components for your applications.
Create a Next.js project by running the following code snippet:
npx create-next-app job-interview-app
Install the package dependencies for the project:
npm install firebase @stream-io/node-sdk @stream-io/video-react-sdk stream-chat stream-chat-react
To install the Shadcn UI library, follow the installation guide.
Once everything is set up, your Next.js project is ready.
Now, let's start building! π
How to Set up Firebase in a Next.js Application
Firebase is a cloud platform that enables you to build full-stack software applications without worrying about managing your database or server infrastructure. It provides features like authentication, real-time database, cloud functions, file storage, and more.
In this section, you'll learn how to install Firebase in a Next.js application and configure the Firestore Database, Firebase storage and authentication within your Next.js application.
Setting up Firebase in a Next.js Application
Install the Firebase Node.js package by running the code snippet below:
npm install firebase
Open the Firebase Console in your browser and create a new Firebase project.
Within the project dashboard, click the web icon </>
to add a Firebase app to the project.
Register the app by entering a nickname, then copy the auto-generated Firebase configuration code. You will need this code to connect your application to the Firebase backend.
Create a lib/firebase.ts
file within the Next.js src folder and paste the following code snippet into the file:
import { initializeApp, getApps } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { EmailAuthProvider } from "firebase/auth";
import { getAuth } from "firebase/auth";
import { getStorage } from "firebase/storage";
const firebaseConfig = {
// ππ» your Firebase app configuration code
};
//ππ» Initialize Firebase
const app =
getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
const provider = new EmailAuthProvider();
const storage = getStorage(app);
const db = getFirestore(app);
const auth = getAuth(app);
//ππ» exports each variable for use within the application
export { provider, auth, storage };
export default db;
The code snippet above initializes Firebase Storage, Firestore, and the Email Authentication provider. This setup allows you to add file storage, interact with a database, and implement email/password authentication within the application.
Before interacting with Firebase features, you must set them up in your project dashboard.
Click Build in the sidebar navigation of your dashboard. This will open a dropdown menu with various features you can enable for your project.
Select Authentication, Firestore Database, and Storage from the drop-down and add them to the project.
Congratulations! You can now start interacting with these Firebase features in your Next.js project.
Authenticating Job Seekers
In this section, youβll learn about the necessary attributes for job seekers and how they can create an account, sign in, and sign out of the application.
Before we proceed, create a types.d.ts
file at the root of your Next.js project and copy the following code into it:
//ππ» before Firebase upload
interface JobSeeker {
name: string;
email: string;
password: string;
image: File;
bio: string;
cv: File;
fieldOfInterest: "tech" | "consulting" | "finance" | "healthcare";
portfolioUrl: string;
}
//ππ» after Firebase upload
interface JobSeekerFirebase {
name: string;
email: string;
image: string;
bio: string;
cv: string;
id: string;
fieldOfInterest: "tech" | "consulting" | "finance" | "healthcare";
portfolioUrl: string;
}
The code snippet above defines the data structure for a job seeker. The JobSeeker interface shows that job seekers provide their name, email, password, short bio, field of interest, and portfolio URL when signing up. They must also upload their CV/Resume and a professional headshot image.
In the JobSeekerFirebase structure, the CV and image fields are represented as strings because, after uploading the files, we'll retrieve their Firebase storage URLs.
Create a lib/auth-functions.ts
file. It will contain the Firebase authentication functions for both job seekers and recruiters, so you can easily import them into any component where these functions are needed.
mkdir lib && cd lib && \
touch auth-functions.ts
Next, add the signup function to the file:
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
} from "firebase/auth";
import { getDownloadURL, ref, uploadBytes } from "@firebase/storage";
import db, { auth, storage } from "./firebase";
import { doc, setDoc, getDoc } from "firebase/firestore";
export const jobSeekerSignUp = async (form: FormData) => {
//ππ» Things to do here:
// 1. get the form data
// 2. create a user using the email and password
// 3. upload the CV and headshot to Firebase storage
// 4. create a Firestore collection using the jobseeker data
// 5. return errors at each stage.
}
Modify the function to create a new user in Firebase.
export const jobSeekerSignUp = async (form: FormData) => {
const userData: JobSeeker = {
name: form.get("name") as string,
email: form.get("email") as string,
bio: form.get("bio") as string,
password: form.get("password") as string,
fieldOfInterest: form.get("field") as JobSeeker["fieldOfInterest"],
image: form.get("image") as File,
cv: form.get("cv") as File,
portfolioUrl: form.get("url") as string,
};
//ππ» creates a Firebase user using the email and password
const { user } = await createUserWithEmailAndPassword(
auth,
userData.email,
userData.password
);
//ππ» returns an error message
if (!user) {
return {
code: "auth/failed",
status: 500,
user: null,
message: "Failed to create user",
};
}
// 3. upload the CV and headshot to Firebase storage
// 4. create a Firestore collection using the jobseeker data
// 5. return errors at each stage.
};
Finally, add the following code snippet within the jobSeekerSignUp
function to upload the user's resume and headshot to Firebase storage and create a Firebase collection for the user.
export const jobSeekerSignUp = async (form: FormData) => {
//ππ» ...other functions for step 1-2
//ππ» creates a Firebase storage ref using the user's ID
const cvRef = ref(storage, `resumes/${user.uid}/cv`);
const imageRef = ref(storage, `applicants/${user.uid}/image`);
//ππ» uploads the image and CV to Firebase and create a Firebase collection
await uploadBytes(cvRef, userData.cv).then(async () => {
await uploadBytes(imageRef, userData.image).then(async () => {
const [cvDownloadURL, imageDownloadURL] = await Promise.all([
getDownloadURL(cvRef),
getDownloadURL(imageRef),
]);
if (!cvDownloadURL || !imageDownloadURL) {
return {
code: "auth/failed",
status: 500,
user: null,
message: "Failed to upload cv or image",
};
}
//ππ» creates a Firebase collection including the user ID & download URLs
const docRef = doc(db, "jobSeekers", user.uid);
await setDoc(docRef, {
name: userData.name,
email: userData.email,
bio: userData.bio,
fieldOfInterest: userData.fieldOfInterest,
portfolioUrl: userData.portfolioUrl,
cv: cvDownloadURL,
image: imageDownloadURL,
});
});
});
//ππ» returns the user object and success messages
return {
code: "auth/success",
status: 200,
user,
message: "Acount created successfully! π",
};
}
The code snippet above accepts the user form data, uploads the resume and headshot image to Firebase storage, authenticates the user, and creates a Firebase collection containing the user's attributes.
To allow job seekers to sign into the application, add this function to the lib/auth-functions.ts
file:
export const jobSeekerAuthLogin = async (form: FormData) => {
//ππ» Things to do here:
// 1. accept the user's email and password
// 2. authenticates and validate user credentials
// 3. check if user exists in the jobSeekers Firebase collection
// 4. create a Stream user using the data (later in the tutorial)
// 5. return errors at each stage.
};
Modify the function to perform the tasks listed above:
export const jobSeekerAuthLogin = async (form: FormData) => {
const email = form.get("email") as string;
const password = form.get("password") as string;
//ππ» authenticate user
const { user } = await signInWithEmailAndPassword(auth, email, password);
if (!user) {
return {
code: "auth/failed",
status: 500,
user: null,
message: "Failed to login",
};
}
//ππ» check if user exists in the collection
const userRef = doc(db, "jobSeekers", user.uid);
const docSnap = await getDoc(userRef);
if (!docSnap.exists()) {
return {
code: "auth/failed",
status: 500,
user: null,
message: "User Not a Job Seeker",
};
}
return {
code: "auth/success",
status: 200,
user,
message: "Login successful",
};
};
The code snippet above accepts the user's email and password, validates the credentials using Firebase Authentication, and checks if the user exists in the jobSeekers Firebase collection before granting access to the application.
Authenticating Recruiters
Here, youβll learn about the necessary attributes for recruiters and how they can create an account, sign in, and sign out of the application.
Copy the following code snippet into the lib/auth-functions.ts
:
export const recruiterSignUp = async (form: FormData) => {
//ππ» Things to do here:
// 1. get the form data
// 2. create a user using the email and password
// 3. upload the recruiter's image to Firebase storage
// 4. create a Firestore collection using the recruiter data
// 5. return errors at each stage.
};
Modify the sign-up function as shown below:
export const recruiterSignUp = async (form: FormData) => {
const userData: Recruiter = {
name: form.get("name") as string,
email: form.get("email") as string,
companyName: form.get("companyName") as string,
companyPosition: form.get("companyPosition") as string,
password: form.get("password") as string,
field: form.get("field") as Recruiter["field"],
image: form.get("image") as File,
url: form.get("url") as string,
};
const { user } = await createUserWithEmailAndPassword(
auth,
userData.email,
userData.password
);
if (!user) {
return {
code: "auth/failed",
status: 500,
user: null,
message: "Failed to create user",
};
}
// 3. upload the recruiter's image to Firebase storage
// 4. create a Firestore collection using the recruiter data
// 5. return errors at each stage.
}
The recruiterSignUp
function accepts the form data, creates a Firebase user using the recruiter's email and password, and returns the user object.
Next, add the following code snippet within the recruiterSignUp
function:
export const recruiterSignUp = async (form: FormData) => {
//ππ» Recruiter sign up function for steps 1-2
const imageRef = ref(storage, `recruiters/${user.uid}/image`);
await uploadBytes(imageRef, userData.image).then(async () => {
const downloadURL = await getDownloadURL(imageRef);
if (!downloadURL) {
return {
code: "auth/failed",
status: 500,
user: null,
message: "Failed to upload image",
};
}
const docRef = doc(db, "recruiters", user.uid);
await setDoc(docRef, {
name: userData.name,
email: userData.email,
companyName: userData.companyName,
companyPosition: userData.companyPosition,
field: userData.field,
url: userData.url,
image: downloadURL,
});
});
return {
code: "auth/success",
status: 200,
user,
message: "Acount created successfully! π",
};
};
The ID obtained from the user object is used to save the recruiter's image in Firebase Storage and to create a Firebase collection that includes the recruiter's attributes.
Create the following function to allow recruiters to sign into the application:
export const recruiterAuthLogin = async (form: FormData) => {
const email = form.get("email") as string;
const password = form.get("password") as string;
const { user } = await signInWithEmailAndPassword(auth, email, password);
if (!user) {
return {
code: "auth/failed",
status: 500,
user: null,
message: "Failed to login",
};
}
const userRef = doc(db, "recruiters", user.uid);
const docSnap = await getDoc(userRef);
if (!docSnap.exists()) {
return {
code: "auth/failed",
status: 500,
user: null,
message: "User Not a Recruiter",
};
}
return {
code: "auth/success",
status: 200,
user,
message: "Login successful",
};
};
The code snippet above validates the recruiter's credentials and checks if the user exists within the Firebase recruiters collection before signing the recruiter into the application.
Additionally, other functions include the authLogout
and getJobSeekerData
functions.
The authLogout
function below allows both recruiters and job seekers to sign out of the application:
export const authLogout = async () => {
try {
await auth.signOut();
return { code: "auth/success", status: 200, message: "Logout successful" };
} catch (err) {
return {
code: "auth/failed",
status: 500,
message: "Failed to logout",
err,
};
}
};
The getJobSeekerData
function retrieves all the attributes related to a job seeker and can be executed on the jobseekerβs profile page.
export const getUserProfile = async (uid: string) => {
const userRef = doc(db, "jobSeekers", uid);
const docSnap = await getDoc(userRef);
if (!docSnap.exists()) {
return {
code: "auth/failed",
status: 500,
user: null,
message: "Invalid ID",
};
}
return {
code: "auth/success",
status: 200,
user: docSnap.data() as JobSeekerFirebase,
message: "User found",
};
}
Stream Chat Firebase Extension
In the previous sections, you learned how to authenticate users with Firebase. Stream also provides Firebase extensions that let you sync Firebase Authentication and Firestore with Stream. The Authenticate with Stream Chat extension automatically syncs Firebase users with Stream Chat, making it easier for Stream to manage your users.
Stream recommends using these Firebase extensions for easy integration and seamless communication between both platforms.
Alternatively, you can manually add users to Stream.
First, create an actions
folder at the root of your Next.js project and add a stream.action.ts
file:
mkdir actions && cd actions && \
touch stream.action.ts
Copy the following code into the file:
"use server";
import { StreamChat } from "stream-chat";
const STREAM_API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!;
const STREAM_API_SECRET = process.env.STREAM_SECRET_KEY!;
const serverClient = StreamChat.getInstance(STREAM_API_KEY, STREAM_API_SECRET);
export const createStreamUser = async (
id: string,
name: string,
image: string
) => {
const { users } = await serverClient.queryUsers({ id });
if (users.length > 0) return users[0];
const user = await serverClient.upsertUser({
id,
name,
image,
});
return user;
};
The createStreamUser
function takes a user ID, name, and image, checks if the user already exists in Stream, and adds them if they donβt. You can call this function right after a job seeker or recruiter logs into the appβinside jobSeekerAuthLogin
and recruiterAuthLogin
.
To connect your Firebase users to Stream, create a Stream app and add your API credentials to the .env.local
file:
NEXT_PUBLIC_STREAM_API_KEY=<your_Stream_API_key>
STREAM_SECRET_KEY=<your_Stream_Secret_key>
Later in this tutorial, you'll learn about Stream's real-time chat and video calling features and discover how to integrate them into your application.
The Application Database Design
This section will teach you how to create Firestore Database collections and perform CRUD operations within the application.
Beyond the authentication pages, recruiters and job seekers have dedicated pages with specific functionalities.
Recruiter Page Routes
-
/dashboard
β This is the recruiter's dashboard, where they can create job listings and view upcoming interview sessions. -
/jobs
β displays a list of jobs created by the recruiter, including the number of applicants. Recruiters can also delete a job if necessary. -
/jobs/[id]/applicants
β lists all applicants for a particular job along with their details, including profile links, downloadable CVs, cover letters, and notes to the employer. Each job in the /jobs route includes an applicant link that directs recruiters to the complete list of applicants and their information.
Job Seeker Page Routes
-
/[id]
β displays the name, image, downloadable CV, and portfolio link for a job seeker. -
/feed
β shows job listings that match the job seekerβs interests or field (e.g., tech, consulting, etc.). -
/jobs/[id]
- displays the job description and a form that allows job seekers to apply. -
/jobs/applied
β enables job seekers to track the status of their applications, displaying a list of applied jobs and their respective statuses.
The table below outlines the attributes of the Firestore collections:
jobSeekers | recruiters | applications | jobs |
---|---|---|---|
name | name | coverLetter | jobTitle |
jobID | jobDescription | ||
image | image | note | expiryDate |
cv | url | status | applications: [{ applicationId, userId }] |
bio | field | user : { jobSeekers attributes } | recruiter: { recruiters attributes } |
portfolioUrl | companyName | ||
fieldOfInterest | companyPosition |
From the table above:
- The jobSeekers and recruiters collections store essential attributes for job seekers and recruiters, respectively.
- The fieldOfInterest (for job seekers) and field (for recruiters) attributes help personalize job feeds by displaying only relevant job listings.
- The applications collection contains a user object with all job seeker details. For larger applications, storing only the jobSeekerId is more efficient, allowing the system to fetch user details on demand.
- The jobs collection includes an applications array that stores objects with applicationId and userId, representing submitted applications.
- The jobs collection also contains a recruiter attribute that stores the whole recruiter object.
Database Operations for Recruiters
Recruiters can perform the following actions within the application:
- Create job postings.
- Delete a job posting using its ID.
- Retrieve all jobs they have created.
- View all applicants for a specific job, including their details.
- Reject job applications.
- Update the status of a job application.
Next, let's implement these functions. First, create a lib/db-functions.ts
file inside the src
folder of your Next.js project. Then, add the following imports at the top of the file:
import db from "./firebase";
import {
addDoc,
collection,
deleteDoc,
doc,
getDocs,
getDoc,
where,
query,
updateDoc,
arrayUnion,
arrayRemove,
} from "firebase/firestore";
Copy the following code snippet into the file:
export const createJobPosting = async (
form: FormData,
user: RecruiterFirebase
) => {
const jobTitle = form.get("jobTitle") as string;
const jobDescription = form.get("jobDescription") as string;
const expiryDate = form.get("expiryDate") as string;
// ππ» add the job to the listing
const docRef = await addDoc(collection(db, "jobs"), {
jobTitle,
jobDescription,
expiryDate: formatDate(expiryDate),
applications: [],
...user,
});
if (!docRef.id) {
return { code: "job/failed", status: 500, message: "Failed to create job" };
}
return {
code: "job/success",
status: 200,
message: "Job created successfully",
};
};
The createJobPosting
function accepts a job title, description, expiry date, and the recruiter's user object as parameters. It then creates a new job listing in the database. This function is only available to the recruiters on their dashboard.
To delete a job, you can add a delete button on every job displayed to execute this function:
export const deleteJobPosting = async (doc_id: string) => {
try {
const docRef = doc(db, "jobs", doc_id);
//ππ» gets the job via ID
const docSnap = await getDoc(docRef);
if (!docSnap.exists()) {
return { code: "job/failed", status: 404, message: "Job does not exist" };
}
const { applications } = docSnap.data() as JobProps;
//ππ» gets all its applicants and delete them
for (const application of applications) {
const applicationRef = doc(db, "applications", application.applicationId);
await deleteDoc(applicationRef);
}
//ππ» delete the job
await deleteDoc(doc(db, "jobs", doc_id));
return {
code: "job/success",
status: 200,
message: "Job deleted successfully",
};
} catch (error) {
return {
code: "job/failed",
status: 500,
message: "Failed to delete job",
error,
};
}
};
The deleteJobPosting
function first retrieves the job via its ID. If the job exists, it fetches all associated applications and deletes them before removing the job itself from the database. This ensures that no application in the database references a non-existent job.
You can get all recruiter posts by using their ID to search the jobs collection and return the job list.
export const getRecruiterJobs = async (recruiterId: string) => {
const q = query(collection(db, "jobs"), where("id", "==", recruiterId));
const querySnapshot = await getDocs(q);
const jobs: JobProps[] = [];
querySnapshot.forEach((doc) => {
jobs.push({ doc_id: doc.id, ...doc.data() } as JobProps);
});
return jobs;
}
Recruiters need to review applications for a specific job, so you should create a database function that retrieves all applications for that job to help them make a decision.
export const getJobApplicants = async (jobId: string) => {
const docRef = doc(db, "jobs", jobId);
//ππ» get job data using its ID
const docSnap = await getDoc(docRef);
if (!docSnap.exists()) {
return {
code: "job/failed",
status: 404,
message: "Job does not exist",
applicants: [],
};
}
//ππ» retrieves the applications array
const { applications } = docSnap.data() as JobProps;
const applicants: ApplicationProps[] = [];
//ππ» returns the entire application collections collection using the application ID
for (const application of applications) {
const applicationRef = doc(db, "applications", application.applicationId);
const applicationSnap = await getDoc(applicationRef);
if (applicationSnap.exists()) {
applicants.push({
id: application.applicationId,
...applicationSnap.data(),
} as ApplicationProps);
}
}
return {
code: "job/success",
status: 200,
message: "Job applicants retrieved successfully",
applicants,
};
};
The getJobApplicants
function accepts the job ID as a parameter, fetches the job data, and extracts the applications array. Since the applications array contains the document IDs of each application, the function retrieves all applications using these IDs.
Finally, recruiters can reject applications and update a job application's status using the following functions:
//ππ» reject applications
export const handleRejectApplication = async (
application: ApplicationProps
) => {
const applicationRef = doc(db, "applications", application.id);
await deleteDoc(applicationRef);
const jobRef = doc(db, "jobs", application.jobID);
await updateDoc(jobRef, {
applications: arrayRemove({
applicationId: application.id,
userId: application.user.id,
}),
});
return {
code: "job/success",
status: 200,
message: "Application rejected successfully",
};
};
//ππ» update job status
export const updateJobStatus = async (
applicationId: string,
status: ApplicationProps["status"]
) => {
const applicationRef = doc(db, "applications", applicationId);
await updateDoc(applicationRef, { status });
return {
code: "job/success",
status: 200,
message: "Application status updated successfully",
};
};
The handleRejectApplication
function accepts the application Firebase document, extracts the job ID and application ID, and deletes the application using its ID. It then removes the applicant from the applications array in the jobs Firebase document, ensuring that the rejected application is fully removed from both collections.
The updateJobStatus
function updates the status of a specific job application. This helps job seekers track their application status, whether pending or being reviewed. The function can be triggered when a recruiter chats with the job seeker or schedules an interview.
Database Operations for Job Seekers
Job seekers can perform the following key functions:
- Browse available jobs: retrieve all job listings within their chosen field, ensuring that only jobs they haven't applied for are displayed.
- View job details: fetch complete job details, including the job description, company name, and other relevant information, using the job ID. This page also provides a form to allow job seekers to apply after reviewing the requirements.
- Track applications: view a list of jobs they have applied for and monitor their application status.
To display job listings on the job seeker's feed, use the following code snippet:
export const getJobFeed = async (user: JobSeekerFirebase) => {
//ππ» query jobs matching the user's field of interest
const q = query(
collection(db, "jobs"),
where("field", "==", user.fieldOfInterest)
);
const querySnapshot = await getDocs(q);
const jobs: JobProps[] = [];
//ππ» retreieve only jobs that the user hasn't apply for
querySnapshot.forEach((doc) => {
const data = { doc_id: doc.id, ...doc.data() } as JobProps;
if (data.applications.some((application) => application.userId === user.id))
return;
jobs.push(data);
});
return jobs;
};
The getJobFeed
function takes a job seeker's user object as a parameter and returns job listings that match their field of interest and have not been applied for. This ensures they only see relevant opportunities.
To retrieve a job by its ID and allow job seekers to view its full details, use the following code snippet:
export const getJobById = async (doc_id: string) => {
const docRef = doc(db, "jobs", doc_id);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
return { doc_id, ...docSnap.data() } as JobProps;
} else {
return null;
}
};
Next, you need to allow job seekers to apply for jobs.
export const applyForJob = async (
jobID: string,
user: JobSeekerFirebase,
coverLetter: string,
note: string
) => {
///ππ» gets job document reference
const jobRef = doc(db, "jobs", jobID);
const jobSnap = await getDoc(jobRef);
if (!jobSnap.exists()) {
return { code: "job/failed", status: 404, message: "Job does not exist" };
}
const job = jobSnap.data() as JobProps;
//ππ» checks if user already applied
const alreadyApplied = job.applications.find(
(application) => application.userId === user.id
);
if (alreadyApplied) {
return {
code: "job/failed",
status: 400,
message: "You have already applied for this job",
};
}
//ππ» save job seeker's application
const addResult = await addDoc(collection(db, "applications"), {
jobID,
user,
coverLetter,
note,
status: "Pending",
});
if (!addResult.id) {
return {
code: "job/failed",
status: 500,
message: "Failed to submit application",
};
}
//ππ» update the job applications with the new application
await updateDoc(jobRef, {
applications: arrayUnion({ applicationId: addResult.id, userId: user.id }),
});
return {
code: "job/success",
status: 200,
message: "Application submitted successfully",
};
};
The applyForJob function:
- accepts the job ID, job seeker user object, cover letter, and note to the employer as parameters.
- fetches the job document from Firestore and checks if the job exists.
- ensures that the user has not already applied.
- saves the job application to the Firestore application collection.
- updates the job document with the new application.
Finally, you can retrieve all the jobs the user has applied for, including their status.
export const getJobsUserAppliedFor = async (userId: string) => {
//π Fetch all applications for the user
const q = query(
collection(db, "applications"),
where("user.id", "==", userId)
);
const querySnapshot = await getDocs(q);
const applications: ApplicationProps[] = querySnapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as ApplicationProps[];
if (applications.length === 0) return []; // Return empty array if no applications exist
//π Fetch all job documents in a single batch request
const jobIds = applications.map((app) => doc(db, "jobs", app.jobID));
const jobSnaps = await getDocs(query(collection(db, "jobs"), where("__name__", "in", jobIds.map((job) => job.id))));
const jobs: JobProps[] = jobSnaps.docs.map((doc) => {
const application = applications.find((app) => app.jobID === doc.id);
return {
doc_id: doc.id,
...doc.data(),
status: application?.status || "Unknown", // Ensure status is included
} as JobProps;
});
return jobs;
};
The getJobsUserAppliedFor function fetches all job applications for a given user, retrieves the corresponding job details in a batch query, and associates each job with its application status. If no applications exist, it returns an empty array.
How to Integrate the Private Chat Feature with Stream
In this section, you'll learn how to create private channels and send messages to users within the application, enabling recruiters to chat with shortlisted candidates.
Before we proceed, install the following Stream packages to integrate chat and video call features into the application.
// ππ» for Stream Chat SDK
npm install stream-chat stream-chat-react
//ππ» for Stream Video & Audio SDK
npm install @stream-io/node-sdk @stream-io/video-react-sdk
Setting Up the Stream Chat SDK in Next.js
Create a Stream account and a new organization that holds all your apps.
Add a new app to the organization and copy the Stream API and Secret key into the .env.local file.
NEXT_PUBLIC_STREAM_API_KEY=<paste_from_Stream_app_dashboard>
STREAM_SECRET_KEY=<paste_from_Stream_app_dashboard>
Within the actions/stream.action.ts
file created earlier, copy the following code snippet into the file:
"use server";
import { StreamChat } from "stream-chat";
import { StreamClient } from "@stream-io/node-sdk";
const STREAM_API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!;
const STREAM_API_SECRET = process.env.STREAM_SECRET_KEY!;
// ππ» -- Stream server client --
const serverClient = StreamChat.getInstance(STREAM_API_KEY, STREAM_API_SECRET);
//ππ» -- create auth token function
export async function createToken(
user: RecruiterFirebase | JobSeekerFirebase
): Promise<string> {
if (!user) throw new Error("User is not authenticated");
return serverClient.createToken(user.id);
}
The code snippet above initializes the Stream Chat server client, allowing us to perform various Stream actions on the server side. The createToken
function accepts a user object (either a recruiter or job seeker) and creates an authentication token using the user's ID.
Since Firebase works on the client side, we also need a Stream client to perform actions such as fetching upcoming calls and channels, and creating and joining them.
To achieve this, create a hooks folder in your project and add a useGetStreamClient
custom hook within a (stream) folder in your Next.js app. This hook will manage the client-side Stream functionality.
cd app && mkdir (stream) && \
cd (stream) && mkdir hooks && cd hooks && \
touch useGetStreamClient.ts
Copy the following code snippet into the useGetStreamClient.ts
:
import { useCreateChatClient } from "stream-chat-react";
import { createToken } from "../../../../actions/stream.action";
import { useCallback } from "react";
export const useGetStreamClient = (
user: JobSeekerFirebase | RecruiterFirebase
) => {
//ππ» executes the createToken function from the server
const tokenProvider = useCallback(async () => {
return await createToken(user);
}, [user]);
//ππ» creates the chat client
const client = useCreateChatClient({
apiKey: process.env.NEXT_PUBLIC_STREAM_API_KEY!,
tokenOrProvider: tokenProvider,
userData: { id: user.id, name: user.name, image: user.image },
});
//ππ» returns the chat client
if (!client) return { client: null };
return { client };
};
The useGetStreamClient hook generates a token for the current user and creates a chat client using the user's credentials from the user object. It then returns the chat client, which can be used to perform client-side Stream operations.
Private Chat with the Stream Chat SDK
In this section, you will learn how to create private chat channels using the Stream Chat SDK to enable recruiters to send messages to shortlisted job applicants. The chat channels allow recruiters to ask additional questions or interact with the applicant before and after the interview.
Copy the code snippet below into the stream.action.ts file:
export async function createChannel({
recruiterId,
applicant,
}: {
recruiterId: string;
applicant: ApplicationProps;
}) {
try {
const filter = {
type: "messaging",
members: { $in: [recruiterId, applicant.user.id] },
};
const sort = [{ last_message_at: -1 }];
//ππ» check if job applicant already has a channel with the recruiter
const channels = await serverClient.queryChannels(filter, sort, {
watch: true,
state: true,
});
//ππ» if true, return the channel id
if (channels.length > 0) {
return { success: true, error: null, id: channels[0].id };
}
//ππ» otherwise, create a new channel for them
const channel = serverClient.channel(
"messaging",
`${applicant.jobID}-chat-${applicant.user.id}`,
{
name: `Meeting with ${applicant.user.name}`,
members: [recruiterId, applicant.user.id],
created_by_id: recruiterId,
}
);
await channel.create();
//ππ» return the channel id
return { success: true, error: null, id: channel.id };
} catch (err) {
console.log("Error creating channel:", err);
return { success: false, error: "Failed to create channel", id: null };
}
}
The createChannel function checks if a chat channel already exists between the recruiter and the job applicant. If such a channel exists, it returns the existing channel's ID. If not, the function creates a new channel and returns the channel ID. This allows recruiters and job applicants to visit the chat page via the /chat/[id]
route.
Now, let's build the UI components for the chat page.
Create a chat/[id]/page.tsx
file and copy the following code into the file:
"use client";
import StreamChatUI from "../components/StreamChatUI";
import { useState, useEffect, useCallback } from "react";
import { onAuthStateChanged, User } from "firebase/auth";
import { doc, getDoc } from "firebase/firestore";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { auth } from "@/lib/firebase";
import db from "@/lib/firebase";
export default function ChatPage() {
//ππ» Firebase User object
const [user, setUser] = useState<User | null>(null);
//ππ» Firestore user collection states
const [userData, setUserData] = useState<
JobSeekerFirebase | RecruiterFirebase | null
>(null);
const router = useRouter();
//ππ» auth verification functions
return (
<div>{userData ? <StreamChatUI user={userData} /> : <ConfirmMember />}</div>
);
}
const ConfirmMember = () => {
const router = useRouter();
return (
<div className='flex flex-col items-center justify-center h-screen'>
<button
className='text-lg mb-4 p-4 bg-blue-500 text-white rounded-md'
onClick={() => router.back()}
>
Go Back
</button>
<div className='loader'>
<Loader2 size={48} className='animate-spin' />
</div>
</div>
);
};
The ChatPage component conditionally renders either the StreamChatUI component or the ConfirmMember component. The ConfirmMember component acts as a loading page and is displayed until the userData value is available.
Next, add the following authentication functions within the ChatPage component:
//ππ» check if the user is authenticated and save its Firebase user object
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (user) => {
if (user?.uid) {
setUser(user);
} else {
return router.push("/applicant/login");
}
});
return () => unsubscribe();
}, [router]);
//ππ» retrieve user's collection
const getUser = useCallback(async () => {
if (!user) return null;
const jobSeekerSnap = await getDoc(doc(db, "jobSeekers", user.uid));
const recruiterSnap = await getDoc(doc(db, "recruiters", user.uid));
if (jobSeekerSnap.exists()) {
setUserData({
id: user.uid,
...jobSeekerSnap.data(),
} as JobSeekerFirebase);
} else if (recruiterSnap.exists()) {
setUserData({
id: user.uid,
...recruiterSnap.data(),
} as JobSeekerFirebase);
} else {
return null;
}
}, [user]);
useEffect(() => {
getUser();
}, [getUser]);
The code snippet above retrieves the user's Firebase collection when the page loads before passing the userData React state into the StreamChatUI component.
Copy the following code snippet into StreamChatUI component:
"use client";
import {
Chat,
Channel,
ChannelList,
Window,
ChannelHeader,
MessageList,
MessageInput,
} from "stream-chat-react";
import { useGetStreamClient } from "../../hooks/useGetStreamClient";
export default function StreamChatUI({
user,
}: {
user: JobSeekerFirebase | RecruiterFirebase;
}) {
//ππ» retrieves the chat client
const { client } = useGetStreamClient(user!);
//ππ» filter and sorting objects
const filters = { members: { $in: [user.id] }, type: "messaging" };
const options = { presence: true, state: true };
if (!client) return <div>Loading...</div>;
return (
<Chat client={client}>
<div className='chat-container'>
{/* -- Channel List -- */}
<div className='channel-list'>
<ChannelList
sort={{ last_message_at: -1 }}
filters={filters}
options={options}
/>
</div>
{/* -- Messages Panel -- */}
<div className='chat-panel'>
<Channel>
<Window>
<ChannelHeader />
<MessageList />
<MessageInput />
</Window>
</Channel>
</div>
</div>
</Chat>
);
}
From the code snippet above:
- The useGetStreamClient custom hook returns the chat client.
- Chat component initializes the Stream Chat client and wraps the entire Chat page.
- ChannelList shows available chat channels.
- Channel sets up an active chat session.
- Window contains the message display and input areas.
- ChannelHeader, MessageList, and MessageInput provide a fully functional chat interface.
How to Schedule Virtual Interviews with Stream
In this section, you'll learn how to integrate video calling functionality into the Next.js application, allowing recruiters to schedule and conduct virtual interviews with job seekers.
Copy the following code snippet into the actions/stream.action.ts
file:
export const tokenProvider = async (user_id: string) => {
if (!STREAM_API_KEY) throw new Error("Stream API key secret is missing");
if (!STREAM_API_SECRET) throw new Error("Stream API secret is missing");
const streamClient = new StreamClient(STREAM_API_KEY, STREAM_API_SECRET);
const expirationTime = Math.floor(Date.now() / 1000) + 3600;
const issuedAt = Math.floor(Date.now() / 1000) - 60;
const token = streamClient.generateUserToken({
user_id,
exp: expirationTime,
validity_in_seconds: issuedAt,
});
return token;
}
The tokenProvider function generates an authentication token for the user, enabling Stream to identify and manage users during real-time communication.
Within the (stream)/hooks folder, add the following files:
cd app && cd (stream)/hooks && \
touch useGetCallById.ts useGetCalls.ts
The useGetCallById file defines a React hook that fetches details of a specific Stream call via its ID while the useGetCalls hook retrieves all the calls created by a particular Stream user.
Let's create these custom React hooks.
Copy the following code snippet into the useGetCallById.ts file:
import { useEffect, useState } from "react";
import { Call, useStreamVideoClient } from "@stream-io/video-react-sdk";
export const useGetCallById = (id: string | string[]) => {
const [call, setCall] = useState<Call>();
const [isCallLoading, setIsCallLoading] = useState(true);
const client = useStreamVideoClient();
useEffect(() => {
if (!client) return;
const loadCall = async () => {
try {
// https://getstream.io/video/docs/react/guides/querying-calls/#filters
const { calls } = await client.queryCalls({
filter_conditions: { id },
});
if (calls.length > 0) setCall(calls[0]);
setIsCallLoading(false);
} catch (error) {
console.error(error);
setIsCallLoading(false);
}
};
loadCall();
}, [client, id]);
return { call, isCallLoading };
};
Add the following to the useGetCalls.ts file:
import { useEffect, useState } from "react";
import { Call, useStreamVideoClient } from "@stream-io/video-react-sdk";
export const useGetCalls = (id: string) => {
const client = useStreamVideoClient();
const [calls, setCalls] = useState<Call[]>();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const loadCalls = async () => {
if (!client || !id) return;
setIsLoading(true);
try {
const { calls } = await client.queryCalls({
sort: [{ field: "starts_at", direction: 1 }],
filter_conditions: {
starts_at: { $exists: true },
$or: [{ created_by_user_id: id }, { members: { $in: [id] } }],
},
});
setCalls(calls);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
};
loadCalls();
}, [client, id]);
const now = new Date();
const upcomingCalls = calls?.filter(({ state: { startsAt } }: Call) => {
return startsAt && new Date(startsAt) > now;
});
const ongoingCalls = calls?.filter(
({ state: { startsAt, endedAt } }: Call) => {
return startsAt && new Date(startsAt) < now && !endedAt;
}
);
return { upcomingCalls, isLoading, ongoingCalls };
};
The useGetCalls hook retrieves all calls created by the recruiter, including ongoing and upcoming calls. It also provides an isLoading state to indicate when data is being fetched, enabling conditional rendering.
Next, to create, join, and retrieve calls, wrap the pages that require access to the calls with the StreamVideo component.
Add a providers/StreamVideoProvider component inside the (stream) folder to do this. Then, copy the following code snippet into the file:
"use client";
import { tokenProvider } from "../../../../actions/stream.action";
import { StreamVideo, StreamVideoClient } from "@stream-io/video-react-sdk";
import { useState, ReactNode, useEffect, useCallback } from "react";
import { onAuthStateChanged, User } from "firebase/auth";
import { auth } from "@/lib/firebase";
import db from "@/lib/firebase";
import { doc, getDoc } from "firebase/firestore";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
const apiKey = process.env.NEXT_PUBLIC_STREAM_API_KEY!;
export const StreamVideoProvider = ({ children }: { children: ReactNode }) => {
const [videoClient, setVideoClient] = useState<StreamVideoClient | null>(
null
);
const [user, setUser] = useState<User | null>(null);
const router = useRouter();
// ππ» auth functions placeholder
return <StreamVideo client={videoClient}>{children}</StreamVideo>;
};
Update the component with the authentication functions:
//ππ» get the Firebase user object
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (user) => {
if (user?.uid) {
setUser(user);
} else {
return router.push("/applicant/login");
}
});
return () => unsubscribe();
}, [router]);
//ππ» get the Firebase user collection and returns the Stream video client
const getUser = useCallback(async () => {
if (!user) return null;
const [jobSeekerSnap, recruiterSnap] = await Promise.all([
getDoc(doc(db, "jobSeekers", user.uid)),
getDoc(doc(db, "recruiters", user.uid)),
]);
if (!jobSeekerSnap.exists() && !recruiterSnap.exists()) {
console.warn("User data not found in Firestore");
return null;
}
return new StreamVideoClient({
apiKey,
user: {
id: user.uid,
name: jobSeekerSnap.data()?.name || recruiterSnap.data()?.name,
image: jobSeekerSnap.data()?.image || recruiterSnap.data()?.image,
},
tokenProvider: () => tokenProvider(user.uid),
});
}, [user]);
//ππ» update the video client
useEffect(() => {
const result = getUser();
if (result) {
result.then((client) => setVideoClient(client));
}
}, [getUser]);
if (!videoClient)
return (
<div className='h-screen flex items-center justify-center'>
<Loader2 size='32' className='mx-auto animate-spin' />
</div>
);
The code snippet above does the following:
- retrieves the authenticated Firebase user object and redirects them to the login page if they are not signed in.
- fetches the user's data from the jobSeekers or recruiters Firestore collection and initializes a StreamVideoClient with their details.
- updates the videoClient state once the user data is retrieved, displaying a loading spinner while waiting for the client to be set.
Finally, recruiters need to be able to schedule calls with shortlisted applicants. They can execute this function after submitting a form that accepts the meeting title and a specific date and time for the interview.
import { useStreamVideoClient } from "@stream-io/video-react-sdk";
const client = useStreamVideoClient();
const handleScheduleMeeting = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!description || !dateTime || !client) return;
try {
const id = crypto.randomUUID(); //ππ» generates a random id
const call = client.call("default", id);
if (!call) throw new Error("Failed to create meeting");
// ππ» creates a call
await call.getOrCreate({
data: {
starts_at: new Date(dateTime).toISOString(),
custom: {
description,
},
members: [{ user_id: applicantId }, { user_id: recruiterId }],
},
});
} catch (error) {
console.error(error);
}
};
The code snippet above creates a Stream video call with a default call type. It assigns the call a unique ID, sets the scheduled date and time, and includes a custom description.
Ensure that the <StreamVideoProvider> component wraps the recruiter's dashboard where the video call is created. You can achieve this by adding a layout.tsx file to the dashboard page and wrapping all child elements with <StreamVideoProvider>.
Stream Call UI Components
Here, you will learn how to create the interview page, where the recruiter and the applicant can communicate via video call, share screens, and seamlessly meet.
First, create an interview/[id]/page.tsx
file within the (stream)
folder and add the following imports to the file:
"use client";
import { useGetCallById } from "../../hooks/useGetCallById";
import {
StreamCall,
StreamTheme,
PaginatedGridLayout,
SpeakerLayout,
CallControls,
Call,
} from "@stream-io/video-react-sdk";
import { useParams } from "next/navigation";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useCallStateHooks } from "@stream-io/video-react-sdk";
import { toast } from "sonner";
import { auth } from "@/lib/firebase";
import { User, onAuthStateChanged } from "firebase/auth";
type CallLayoutType = "grid" | "speaker-left" | "speaker-right";
Copy the following code snippet into the file to ensure the user is authenticated before rendering the call component.
export default function CallPage() {
const { id } = useParams<{ id: string }>();
const { call, isCallLoading } = useGetCallById(id);
const [confirmJoin, setConfirmJoin] = useState<boolean>(false);
const [camMicEnabled, setCamMicEnabled] = useState<boolean>(false);
const [user, setUser] = useState<User | null>(null);
const router = useRouter();
const authenticateUser = useCallback(async () => {
const unsubscribe = onAuthStateChanged(auth, async (user) => {
if (!user?.uid) {
return router.push("/applicant/login");
}
setUser(user);
if (camMicEnabled) {
call?.camera.enable();
call?.microphone.enable();
} else {
call?.camera.disable();
call?.microphone.disable();
}
});
return () => unsubscribe();
}, [router, call, camMicEnabled]);
useEffect(() => {
authenticateUser();
}, [authenticateUser]);
const handleJoin = () => {
call?.join();
setConfirmJoin(true);
};
if (isCallLoading) return <p>Loading...</p>;
if (!call) return <p>Call not found</p>;
return (
// --- Call UI component ---
)
}
The authenticateUser function checks if a user is logged in; if not, it redirects them to the login page. If authentication is successful, it enables or disables the user's camera and microphone based on the camMicEnabled state. The handleJoin function allows users to join the call when they confirm participation.
Return the following UI elements from the CallPage component:
return (
<main className='min-h-screen w-full items-center justify-center'>
<StreamCall call={call}>
<StreamTheme>
{confirmJoin ? (
<MeetingRoom call={call} />
) : (
<div className='flex flex-col items-center justify-center gap-5 h-screen w-full'>
<h1 className='text-3xl font-bold'>Join Call</h1>
<p className='text-lg'>Are you sure you want to join this call?</p>
<div className='flex gap-5'>
<button
onClick={handleJoin}
className='px-4 py-3 bg-blue-600 text-blue-50'
>
Join
</button>
<button
onClick={() => router.back()}
className='px-4 py-3 bg-red-600 text-red-50'
>
Cancel
</button>
</div>
</div>
)}
</StreamTheme>
</StreamCall>
</main>
)
The component displays a confirmation page where the participant can confirm their decision before joining the interview or call session.
In the code snippet above,
- The StreamCall component wraps the entire call page, allowing access to various audio and video calling features. It accepts the call object as a prop.
- The StreamTheme component provides UI styling for the call, enabling you to use different themes.
- The confirmJoin state is initially set to false. When the user clicks the Join button, it triggers the handleJoin function, which joins the call and updates confirmJoin to true.
- When confirmJoin is true, the component renders the MeetingRoom component, which includes all prebuilt and customizable UI elements for the call provided by Stream.
Add the MeetingRoom component to the interview/[id]/page.tsx file:
const MeetingRoom = ({ call }: { call: Call }) => {
const [layout, setLayout] = useState<CallLayoutType>("grid");
const router = useRouter();
const handleLeave = () => {
if (confirm("Are you sure you want to leave the call?")) {
router.push("/");
}
};
const CallLayout = () => {
switch (layout) {
case "grid":
return <PaginatedGridLayout />;
case "speaker-right":
return <SpeakerLayout participantsBarPosition='left' />;
default:
return <SpeakerLayout participantsBarPosition='right' />;
}
};
return (
<section className='relative min-h-screen w-full overflow-hidden pt-4'>
<div className='relative flex size-full items-center justify-center'>
<div className='flex size-full max-w-[1000px] items-center'>
<CallLayout />
</div>
<div className='fixed bottom-0 flex w-full items-center justify-center gap-5'>
<CallControls onLeave={handleLeave} />
</div>
<div className='fixed bottom-0 right-0 flex items-center justify-center gap-5 p-5'>
<EndCallButton call={call} />
</div>
</div>
</section>
);
};
The CallLayout and CallControls components are rendered on the page, allowing users to communicate, share their screen, turn their camera on or off, and engage in conversations through reactions.
Finally, create the EndCallButton component to enable the host (instructor) to end the call for everyone.
const EndCallButton = ({ call }: { call: Call }) => {
const { useLocalParticipant } = useCallStateHooks();
const localParticipant = useLocalParticipant();
const router = useRouter();
const participantIsHost =
localParticipant &&
call.state.createdBy &&
localParticipant.userId === call.state.createdBy.id;
if (!participantIsHost) return null;
const handleEndCall = () => {
call.endCall();
toast("Call ended for everyone", {
description: "The call has been ended for everyone",
});
router.push("/");
};
return (
<button
className='bg-red-500 text-white px-4 py-2 rounded-md mt-2'
onClick={handleEndCall}
>
End Call for Everyone
</button>
);
};
The code snippet above ensures that only the call host can end the call for all participants. It first checks if the current user is the host before displaying the "End Call for Everyone" button.
Congratulations! Youβve completed the project for this tutorial. The source code for this article is also available on GitHub.
Next Steps
So far, you've learned how to build a full-stack job application and interview platform using Stream and Firebase. This platform allows recruiters to shortlist applicants, schedule virtual interviews, and communicate through real-time chat powered by Stream.
Stream helps you build engaging apps that scale to millions with performant and flexible Chat, Video, Voice, Feeds, and Moderation APIs and SDKs powered by a global edge network and enterprise-grade infrastructure.
Here are some valuable resources to help you get started:
- How to Use Stream, Cronofy, and OpenAI for AI Team Collaboration Apps
- Discover Why Collaboration Apps Choose Stream
- Stream Chat Documentation
- Stream Video and Audio Documentation
Thank you for reading. π
For further actions, you may consider blocking this person and/or reporting abuse
Top comments (6)
very detailed walkthrough and nicely written David. well done.
Thank you, @tyaga001 π
Glad you found it useful! π₯
good work David
Thank you!
Stream is the best! π₯π
Amazing Work David!
Thank you, Arindam!
Glad you found it useful.