Guide to Reproduce Job Listing Application with Next.js and React
This guide will help you set up a job listing application using Next.js, React, and MongoDB Atlas. Follow the steps below to recreate the app based on the provided files.
Prerequisites
- Node.js (v16 or later)
- npm or yarn
- MongoDB Atlas account (or local MongoDB instance)
- Basic knowledge of React, Next.js, and MongoDB
Step 1: Project Setup
Create a new Next.js project and install dependencies.
npx create-next-app@latest job-listing-app
cd job-listing-app
npm install axios mongoose
npm install
Note: This app uses MongoDB Atlas for the database. Replace the MongoDB URI in the connection file with your own.
Step 2: File Structure
Organize your project with the following structure:
job-listing-app/
├── lib/
│ └── db.js
├── models/
│ └── job.js
├── pages/
│ ├── api/
│ │ ├── jobs/
│ │ │ └── [id].js
│ │ └── jobs.js
│ ├── _app.js
│ ├── index.js
│ ├── jobs/
│ │ ├── edit/
│ │ │ └── [id].js
│ │ └── new.js
├── styles/
│ └── styles.css
├── package.json
Step 3: Database Setup
File: lib/db.js
This file handles the MongoDB connection using Mongoose.
const mongoose = require('mongoose');
const MONGODB_URI = 'Place your connect string from MongoDB here';
if (!MONGODB_URI) {
throw new Error('Please define MONGODB_URI');
}
let cached = global.mongoose;
if (!cached) {
cached = global.mongoose = { conn: null, promise: null };
}
async function dbConnect() {
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
cached.promise = mongoose.connect(MONGODB_URI)
.then((mongoose) => {
console.log('Connected to MongoDB Atlas');
return mongoose;
})
.catch((err) => {
console.error('MongoDB Atlas connection error:', err);
throw err;
});
}
cached.conn = await cached.promise;
return cached.conn;
}
module.exports = dbConnect;
Note: Replace the MONGODB_URI
with your MongoDB Atlas connection string.
File: models/job.js
Define the Job schema for MongoDB.
const mongoose = require('mongoose');
const jobSchema = new mongoose.Schema({
_id: String,
title: String,
url: String,
location: String,
rate: String,
company: String,
easy_apply: Boolean,
source: String,
hide: Boolean,
applied: Boolean,
notes: String,
employment_type: String,
posted_date: String
});
const Job = mongoose.models.Job || mongoose.model('Job', jobSchema);
module.exports = Job;
Step 4: API Routes
File: pages/api/jobs.js
Handles CRUD operations for job listings.
const dbConnect = require('../../../lib/db');
const Job = require('../../../models/job');
export default async function handler(req, res) {
const { method } = req;
await dbConnect();
switch (method) {
case 'GET':
try {
const { search, source, applied, hide, page = 1, sort, location } = req.query;
const query = {};
if (search) query.title = { $regex: search, $options: 'i' };
if (source) query.source = source;
if (applied) query.applied = applied === 'true';
if (hide) query.hide = hide === 'true';
if (location) {
if (location === 'Remote') {
query.location = { $regex: /remote/i };
} else if (location === 'not-remote') {
query.location = { $not: { $regex: /remote/i } };
}
}
const limit = 10;
const skip = (page - 1) * limit;
const sortObj = sort ? { [sort]: 1 } : {};
const jobs = await Job.find(query).sort(sortObj).skip(skip).limit(limit);
const totalCount = await Job.countDocuments(query);
const stats = {
hiddenCount: await Job.countDocuments({ hide: true }),
appliedCount: await Job.countDocuments({ applied: true }),
totalCount,
diceCount: await Job.countDocuments({ source: 'Dice' }),
linkedinCount: await Job.countDocuments({ source: 'LinkedIn' }),
};
res.status(200).json({
jobs,
stats,
totalPages: Math.ceil(totalCount / limit),
currentPage: parseInt(page),
});
} catch (error) {
console.error('Error in GET /api/jobs:', error);
res.status(500).json({ error: 'Internal server error' });
}
break;
case 'POST':
try {
const body = req.body;
const job = new Job(body);
await job.save();
res.status(201).json(job);
} catch (error) {
console.error('Error in POST /api/jobs:', error);
res.status(500).json({ error: 'Internal server error' });
}
break;
case 'DELETE':
try {
const { ids } = req.body;
if (!ids || !Array.isArray(ids)) {
return res.status(400).json({ error: 'Invalid request: ids must be an array' });
}
await Job.deleteMany({ _id: { $in: ids } });
res.status(200).json({ success: true });
} catch (error) {
console.error('Error in DELETE /api/jobs:', error);
res.status(500).json({ error: 'Internal server error' });
}
break;
default:
res.status(405).json({ error: 'Method not allowed' });
}
}
File: pages/api/jobs/[id].js
Handles individual job operations (GET, PUT, DELETE).
const dbConnect = require('../../../lib/db');
const Job = require('../../../models/job');
export default async function handler(req, res) {
const { method } = req;
const { id } = req.query;
await dbConnect();
switch (method) {
case 'GET':
try {
const job = await Job.findById(id);
if (!job) {
return res.status(404).json({ error: 'Job not found' });
}
res.status(200).json(job);
} catch (error) {
console.error('Error in GET /api/jobs/[id]:', error);
res.status(500).json({ error: 'Internal server error' });
}
break;
case 'PUT':
try {
const body = req.body;
const job = await Job.findByIdAndUpdate(id, body, { new: true });
if (!job) {
return res.status(404).json({ error: 'Job not found' });
}
res.status(200).json({ success: true });
} catch (error) {
console.error('Error in PUT /api/jobs/[id]:', error);
res.status(500).json({ error: 'Internal server error' });
}
break;
case 'DELETE':
try {
const job = await Job.findByIdAndDelete(id);
if (!job) {
return res.status(404).json({ error: 'Job not found' });
}
refetchJobs(200).json({ success: true });
} catch (error) {
console.error('Error in DELETE /api/jobs/[id]:', error);
res.status(500).json({ error: 'Internal server error' });
}
break;
default:
res.status(405).json({ error: 'Method not allowed' });
}
}
Step 5: Frontend Pages
File: pages/_app.js
Custom App component with navigation.
import '../styles.css';
function MyApp({ Component, pageProps }) {
return (
<div className="page-container">
<nav>
<a href="/">Home</a>
<a href="/jobs/new">Add New</a>
</nav>
<Component {...pageProps} />
</div>
);
}
export default MyApp;
File: pages/index.js
Main job listing page with filters and infinite scroll.
import { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';
function JobList({ initialJobs, initialStats, initialTotalPages }) {
const [jobs, setJobs] = useState(initialJobs || []);
const [stats, setStats] = useState(initialStats || {});
const [filters, setFilters] = useState({ search: '', source: '', applied: '', hide: 'false', location: '' });
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(initialTotalPages || 1);
const [selectedJobs, setSelectedJobs] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const [isFetchingMore, setIsFetchingMore] = useState(false);
const observer = useRef(null);
const loadMoreRef = useRef(null);
const fetchJobs = useCallback(async (page, append = false) => {
setLoading(!append);
setError(null);
try {
const params = { ...filters, page };
if (filters.location === 'Remote') {
params.location = 'Remote';
} else if (filters.location === 'Not Remote') {
params.location = 'not-remote';
}
const response = await axios.get('/api/jobs', { params });
if (append) {
setJobs((prevJobs) => [...prevJobs, ...response.data.jobs]);
} else {
setJobs(response.data.jobs);
}
setStats(response.data.stats);
setTotalPages(response.data.totalPages);
} catch (err) {
console.error('Error fetching jobs:', err);
setError('Failed to load jobs. Please try again later.');
} finally {
setLoading(false);
setIsFetchingMore(false);
}
}, [filters]);
useEffect(() => {
setJobs([]);
setCurrentPage(1);
fetchJobs(1);
}, [filters, fetchJobs]);
useEffect(() => {
if (currentPage > 1) {
fetchJobs(currentPage, true);
}
}, [currentPage, fetchJobs]);
useEffect(() => {
const loadMoreNode = loadMoreRef.current;
if (!loadMoreNode || loading || isFetchingMore || currentPage >= totalPages) return;
observer.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setIsFetchingMore(true);
setCurrentPage((prevPage) => prevPage + 1);
}
},
{ threshold: 1.0 }
);
observer.current.observe(loadMoreNode);
return () => {
if (loadMoreNode && observer.current) {
observer.current.unobserve(loadMoreNode);
}
};
}, [loading, isFetchingMore, currentPage, totalPages]);
const handleFilterChange = (e) => {
const { name, value } = e.target;
let newValue = value;
if (name === 'hide') {
newValue = value === 'ALL' ? '' : value === 'HIDDEN' ? 'true' : 'false';
}
if (name === 'applied') {
newValue = value === 'ALL' ? '' : value === 'APPLIED' ? 'true' : 'false';
}
if (name === 'source' && value === 'ALL') {
newValue = '';
}
if (name === 'location' && value === 'ALL') {
newValue = '';
}
setFilters({ ...filters, [name]: newValue });
};
const handleBulkDelete = async (e) => {
e.preventDefault();
if (selectedJobs.length === 0) return;
try {
await axios.delete('/api/jobs', { data: { ids: selectedJobs } });
setSelectedJobs([]);
fetchJobs(1);
} catch (err) {
console.error('Error bulk deleting jobs:', err);
setError('Failed to delete selected jobs. Please try again.');
}
};
const handleSelectJob = (id) => {
setSelectedJobs((prev) =>
prev.includes(id) ? prev.filter((jobId) => jobId !== id) : [...prev, id]
);
};
const handleToggleHide = async (id, currentHideStatus) => {
const newHideStatus = !currentHideStatus;
try {
setJobs((prevJobs) => {
const updatedJobs = prevJobs.map((job) =>
job._id === id ? { ...job, hide: newHideStatus } : job
);
if (filters.hide === 'false' && newHideStatus) {
return updatedJobs.filter((job) => job._id !== id);
}
return updatedJobs;
});
setStats((prevStats) => ({
...prevStats,
hiddenCount: currentHideStatus
? (prevStats.hiddenCount || 0) - 1
: (prevStats.hiddenCount || 0) + 1,
}));
await axios.put(`/api/jobs/${id}`, {
...jobs.find((job) => job._id === id),
hide: newHideStatus,
});
} catch (err) {
console.error('Error toggling hide status:', err);
setError('Failed to update hide status. Please try again.');
setJobs((prevJobs) =>
prevJobs.map((job) =>
job._id === id ? { ...job, hide: currentHideStatus } : job
)
);
setStats((prevStats) => ({
...prevStats,
hiddenCount: currentHideStatus
? (prevStats.hiddenCount || 0) + 1
: (prevStats.hiddenCount || 0) - 1,
}));
fetchJobs(1);
}
};
return (
<div>
<div className="header-row">
<div className="job-stats">
<h3>Job Statistics</h3>
<div className="stats-container">
<span>Hidden: {stats.hiddenCount || 0}</span>
<span>Applied: {stats.appliedCount || 0}</span>
<span>Total: {stats.totalCount || 0}</span>
<span>Dice: {stats.diceCount || 0}</span>
<span>LinkedIn: {stats.linkedinCount || 0}</span>
</div>
</div>
<div className="search-section">
<input
name="search"
placeholder="Search by title"
value={filters.search}
onChange={handleFilterChange}
/>
</div>
<div className="filters-section">
<select name="source" value={filters.source || 'ALL'} onChange={handleFilterChange}>
<option value="ALL">ALL Sources</option>
<option value="Dice">Dice</option>
<option value="LinkedIn">LinkedIn</option>
</select>
<select name="hide" value={filters.hide === '' ? 'ALL' : filters.hide === 'true' ? 'HIDDEN' : 'VISIBLE'} onChange={handleFilterChange}>
<option value="ALL">ALL Visibility</option>
<option value="HIDDEN">HIDDEN</option>
<option value="VISIBLE">VISIBLE</option>
</select>
<select name="applied" value={filters.applied === '' ? 'ALL' : filters.applied === 'true' ? 'APPLIED' : 'NOT_APPLIED'} onChange={handleFilterChange}>
<option value="ALL">ALL Applied</option>
<option value="APPLIED">APPLIED</option>
<option value="NOT_APPLIED">NOT_APPLIED</option>
</select>
<select name="location" value={filters.location || 'ALL'} onChange={handleFilterChange}>
<option value="ALL">ALL Locations</option>
<option value="Remote">Remote</option>
<option value="Not Remote">Not Remote</option>
</select>
<button onClick={() => fetchJobs(1)}>Filter</button>
</div>
</div>
{error && <p className="error">{error}</p>}
{loading ? (
<p className="loading">Loading jobs...</p>
) : jobs.length === 0 ? (
<p>No jobs found.</p>
) : (
<form onSubmit={handleBulkDelete}>
<div className="job-list-container">
<table>
<thead>
<tr>
<th></th>
<th>Title</th>
<th>Company</th>
<th>Location</th>
<th>Posted Date</th>
<th>Hide</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job._id}>
<td>
<input
type="checkbox"
checked={selectedJobs.includes(job._id)}
onChange={() => handleSelectJob(job._id)}
/>
</td>
<td>
<a href={job.url} target="_blank" rel="noopener noreferrer">
{job.title}
</a>
</td>
<td>{job.company}</td>
<td>{job.location || 'N/A'}</td>
<td>{job.posted_date || 'NA'}</td>
<td>
<input
type="checkbox"
checked={job.hide}
onChange={() => handleToggleHide(job._id, job.hide)}
/>
</td>
<td>
<a href={`/jobs/edit/${job._id}`}>Edit</a>
</td>
</tr>
))}
</tbody>
</table>
<div ref={loadMoreRef} style={{ height: '20px' }}>
{isFetchingMore && <p>Loading more jobs...</p>}
</div>
</div>
<button type="submit">Delete Selected</button>
</form>
)}
</div>
);
}
export async function getServerSideProps() {
const res = await fetch('http://localhost:3033/api/jobs?hide=false');
const data = await res.json();
return {
props: {
initialJobs: data.jobs || [],
initialStats: data.stats || {},
initialTotalPages: data.totalPages || 1,
},
};
}
export default JobList;
File: pages/jobs/new.js
Form to add a new job.
import { useState } from 'react';
import axios from 'axios';
import { useRouter } from 'next/router';
function JobNew() {
const router = useRouter();
const [job, setJob] = useState({
_id: '',
title: '',
company: '',
url: '',
location: '',
rate: '',
easy_apply: false,
source: '',
hide: false,
applied: false,
notes: '',
employment_type: '',
posted_date: '',
});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setJob({ ...job, [name]: type === 'checkbox' ? checked : value });
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
await axios.post('/api/jobs', job);
router.push('/');
} catch (error) {
console.error('Error creating job:', error);
}
};
return (
<div>
<h1>Add New Job</h1>
<form onSubmit={handleSubmit} className="edit-form">
<div className="form-row">
<div className="form-group">
<label htmlFor="job_id">Job ID</label>
<input
id="job_id"
name="_id"
placeholder="Job ID"
value={job._id}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="title">Title</label>
<input
id="title"
name="title"
placeholder="Title"
value={job.title}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="company">Company</label>
<input
id="company"
name="company"
placeholder="Company"
value={job.company}
onChange={handleChange}
required
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="url">URL</label>
<input
id="url"
name="url"
placeholder="URL"
value={job.url}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="location">Location</label>
<input
id="location"
name="location"
placeholder="Location"
value={job.location}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="rate">Rate</label>
<input
id="rate"
name="rate"
placeholder="Rate"
value={job.rate}
onChange={handleChange}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="source">Source</label>
<input
id="source"
name="source"
placeholder="Source"
value={job.source}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="employment_type">Employment Type</label>
<input
id="employment_type"
name="employment_type"
placeholder="Employment Type"
value={job.employment_type}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="posted_date">Posted Date</label>
<input
id="posted_date"
name="posted_date"
placeholder="Posted Date"
value={job.posted_date}
onChange={handleChange}
/>
</div>
</div>
<div className="form-row">
<div className="form-group checkbox-group">
<label htmlFor="easy_apply">
<input
id="easy_apply"
type="checkbox"
name="easy_apply"
checked={job.easy_apply}
onChange={handleChange}
/>
Easy Apply
</label>
</div>
<div className="form-group checkbox-group">
<label htmlFor="hide">
<input
id="hide"
type="checkbox"
name="hide"
checked={job.hide}
onChange={handleChange}
/>
Hide
</label>
</div>
<div className="form-group checkbox-group">
<label htmlFor="applied">
<input
id="applied"
type="checkbox"
name="applied"
checked={job.applied}
onChange={handleChange}
/>
Applied
</label>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="notes">Notes</label>
<textarea
id="notes"
name="notes"
placeholder="Notes"
value={job.notes}
onChange={handleChange}
rows="2"
/>
</div>
</div>
<button type="submit" className="submit-btn">Create Job</button>
</form>
</div>
);
}
export default JobNew;
File: pages/jobs/edit/[id].js
Form to edit an existing job.
import { useState, useEffect } from 'react';
import axios from 'axios';
import { useRouter } from 'next/router';
function JobEdit({ job: initialJob }) {
const router = useRouter();
const { id } = router.query;
const [job, setJob] = useState(initialJob);
useEffect(() => {
if (!initialJob && id) {
const fetchJob = async () => {
try {
const response = await axios.get(`/api/jobs/${id}`);
setJob(response.data);
} catch (error) {
console.error('Error fetching job:', error);
}
};
fetchJob();
}
}, [id, initialJob]);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setJob({ ...job, [name]: type === 'checkbox' ? checked : value });
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
await axios.put(`/api/jobs/${id}`, job);
router.push('/');
} catch (error) {
console.error('Error updating job:', error);
}
};
const handleDelete = async (e) => {
e.preventDefault();
if (confirm('Are you sure you want to delete this job?')) {
try {
await axios.delete(`/api/jobs/${id}`);
router.push('/');
} catch (error) {
console.error('Error deleting job:', error);
}
}
};
if (!job) return <div>Loading...</div>;
return (
<div>
<h1>Edit Job</h1>
<form onSubmit={handleSubmit} className="edit-form">
<div className="form-row">
<div className="form-group">
<label htmlFor="job_id">Job ID</label>
<input
id="job_id"
name="_id"
value={job._id}
onChange={handleChange}
disabled
/>
</div>
<div className="form-group">
<label htmlFor="title">Title</label>
<input
id="title"
name="title"
value={job.title}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="company">Company</label>
<input
id="company"
name="company"
value={job.company}
onChange={handleChange}
required
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="url">URL</label>
<a
href={job.url}
target="_blank"
rel="noreferrer"
className="url-button"
>
View Job Posting
</a>
</div>
<div className="form-group">
<label htmlFor="location">Location</label>
<input
id="location"
name="location"
value={job.location}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="rate">Rate</label>
<input
id="rate"
name="rate"
value={job.rate}
onChange={handleChange}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="source">Source</label>
<input
id="source"
name="source"
value={job.source}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="employment_type">Employment Type</label>
<input
id="employment_type"
name="employment_type"
value={job.employment_type}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="posted_date">Posted Date</label>
<input
id="posted_date"
name="posted_date"
value={job.posted_date}
onChange={handleChange}
/>
</div>
</div>
<div className="form-row">
<div className="form-group checkbox-group">
<label htmlFor="easy_apply">
<input
id="easy_apply"
type="checkbox"
name="easy_apply"
checked={job.easy_apply}
onChange={handleChange}
/>
Easy Apply
</label>
</div>
<div className="form-group checkbox-group">
<label htmlFor="hide">
<input
id="hide"
type="checkbox"
name="hide"
checked={job.hide}
onChange={handleChange}
/>
Hide
</label>
</div>
<div className="form-group checkbox-group">
<label htmlFor="applied">
<input
id="applied"
type="checkbox"
name="applied"
checked={job.applied}
onChange={handleChange}
/>
Applied
</label>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="notes">Notes</label>
<textarea
id="notes"
name="notes"
value={job.notes}
onChange={handleChange}
rows="2"
/>
</div>
</div>
<div className="form-row">
<button type="submit" className="submit-btn">Update Job</button>
<button type="button" onClick={handleDelete} className="delete-btn">Delete</button>
</div>
</form>
</div>
);
}
export async function getServerSideProps({ params }) {
const res = await fetch(`http://localhost:3033/api/jobs/${params.id}`);
const job = await res.json();
if (!res.ok) {
return { notFound: true };
}
return {
props: { job },
};
}
export default JobEdit;
Step 6: Styling
File: styles/styles.css
Basic CSS for layout and styling (customize as needed).
.page-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
nav {
margin-bottom: 20px;
}
nav a {
margin-right: 20px;
text-decoration: none;
color: #0070f3;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.job-stats h3 {
margin: 0 0 10px 0;
}
.stats-container span {
margin-right: 15px;
}
.search-section input {
padding: 8px;
width: 200px;
}
.filters-section {
display: flex;
gap: 10px;
}
.filters-section select,
.filters-section button {
padding: 8px;
}
.job-list-container {
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f5f5f5;
}
.error {
color: red;
}
.loading {
text-align: center;
}
.edit-form {
max-width: 800px;
}
.form-row {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.form-group {
flex: 1;
}
.form-group label {
display: block;
margin-bottom: 5px;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px;
box-sizing: border-box;
}
.checkbox-group {
display: flex;
align-items: center;
}
.checkbox-group input {
margin-right: 5px;
}
.submit-btn,
.delete-btn {
padding: 10px 20px;
margin-right: 10px;
border: none;
cursor: pointer;
}
.submit-btn {
background-color: #0070f3;
color: white;
}
.delete-btn {
background-color: #ff4444;
color: white;
}
.url-button {
display: inline-block;
padding: 8px 12px;
background-color: #0070f3;
color: white;
text-decoration: none;
border-radius: 4px;
}
Step 7: Running the Application
Start the development server:
npm run dev
Visit http://localhost:3000
in your browser to see the app.
Notes
- Ensure MongoDB Atlas is running and the URI is correct.
- The app uses port 3033 for API calls in
getServerSideProps
. Adjust if your backend runs on a different port. - Add error handling and validation as needed for production use.
- Customize the CSS in
styles.css
to match your design preferences.