How to Build a Job Board
This guide walks you through building a job board powered by the Jobven API. You'll learn how to sync job data, implement search and filters, and optimize for SEO.
What You'll Build
A job board with:
- Daily automated job sync
- Search and filter functionality
- Individual job detail pages
- SEO-optimized URLs and metadata
Architecture Overview
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Jobven API │────▶│ Your Server │────▶│ Your Database │
└─────────────────┘ └──────────────┘ └─────────────────┘
│
▼
┌──────────────┐
│ Frontend │
│ (Job Board) │
└──────────────┘
Key principle: Store jobs in your own database. Don't call the Jobven API on every page load.
Step 1: Set Up Your Database
Create a jobs table to store synced data:
SQL Schema
CREATE TABLE jobs (
id SERIAL PRIMARY KEY,
external_id VARCHAR(255) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
summary TEXT,
company_name VARCHAR(255),
company_website VARCHAR(500),
-- Location
city VARCHAR(100),
state VARCHAR(100),
country VARCHAR(10),
remote_type VARCHAR(20),
-- Compensation
salary_min INTEGER,
salary_max INTEGER,
salary_currency VARCHAR(10),
-- Classification
experience_level VARCHAR(20),
employment_type VARCHAR(20),
skills TEXT[],
industry TEXT[],
-- Metadata
apply_url VARCHAR(500),
posted_at TIMESTAMP,
expires_at TIMESTAMP,
status VARCHAR(20) DEFAULT 'active',
-- Sync tracking
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for common queries
CREATE INDEX idx_jobs_status ON jobs(status);
CREATE INDEX idx_jobs_remote_type ON jobs(remote_type);
CREATE INDEX idx_jobs_experience ON jobs(experience_level);
CREATE INDEX idx_jobs_posted ON jobs(posted_at DESC);
CREATE INDEX idx_jobs_skills ON jobs USING GIN(skills);
-- Full-text search
CREATE INDEX idx_jobs_search ON jobs USING GIN(
to_tsvector('english', title || ' ' || COALESCE(summary, '') || ' ' || COALESCE(company_name, ''))
);
Prisma Schema (Alternative)
model Job {
id Int @id @default(autoincrement())
externalId String @unique @map("external_id")
title String
slug String @unique
description String?
summary String?
companyName String? @map("company_name")
companyUrl String? @map("company_website")
city String?
state String?
country String?
remoteType String? @map("remote_type")
salaryMin Int? @map("salary_min")
salaryMax Int? @map("salary_max")
salaryCurrency String? @map("salary_currency")
experienceLevel String? @map("experience_level")
employmentType String? @map("employment_type")
skills String[]
industry String[]
applyUrl String? @map("apply_url")
postedAt DateTime? @map("posted_at")
expiresAt DateTime? @map("expires_at")
status String @default("active")
syncedAt DateTime @default(now()) @map("synced_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([status])
@@index([remoteType])
@@index([experienceLevel])
@@index([postedAt(sort: Desc)])
@@map("jobs")
}
Step 2: Build the Sync Script
Create a script that runs daily to fetch new jobs. The API works in Unix seconds — store and compare timestamps in seconds, and multiply by 1000 only when constructing a JavaScript Date.
JavaScript (Node.js)
// sync-jobs.js
import { PrismaClient } from '@prisma/client';
import slugify from 'slugify';
const prisma = new PrismaClient();
const API_KEY = process.env.JOBVEN_API_KEY;
const BASE_URL = 'https://api.jobven.com/v1/public';
// Your niche filters
const NICHE_FILTERS = {
skills: ['react', 'typescript', 'nextjs'],
remoteType: 'remote'
};
async function getLastSyncTime() {
const state = await prisma.syncState.findUnique({
where: { key: 'jobs_last_sync' }
});
// Stored as Unix seconds. Default: last 7 days
return state?.timestamp || Math.floor(Date.now() / 1000) - (7 * 24 * 60 * 60);
}
async function saveLastSyncTime(timestamp) {
await prisma.syncState.upsert({
where: { key: 'jobs_last_sync' },
update: { timestamp },
create: { key: 'jobs_last_sync', timestamp }
});
}
function generateSlug(job) {
const base = `${job.title}-at-${job.companies[0]?.name || 'company'}`;
const slug = slugify(base, { lower: true, strict: true });
return `${slug}-${job.id.slice(0, 8)}`;
}
async function fetchJobs(postedAfter) {
const jobs = [];
let cursor = null;
do {
const params = new URLSearchParams({
postedAfter: String(postedAfter), // Unix seconds
limit: '100'
});
// Array filters use bracket notation: skills[]=react&skills[]=typescript
NICHE_FILTERS.skills.forEach(s => params.append('skills[]', s));
params.append('remoteType[]', NICHE_FILTERS.remoteType);
if (cursor) {
params.set('cursor', cursor);
}
const response = await fetch(`${BASE_URL}/jobs?${params}`, {
headers: { 'X-API-Key': API_KEY }
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const { data, meta } = await response.json();
jobs.push(...data);
cursor = meta.nextCursor;
console.log(`Fetched ${jobs.length} jobs so far...`);
} while (cursor);
return jobs;
}
async function upsertJob(job) {
const slug = generateSlug(job);
const location = job.locations?.[0] || {};
const company = job.companies?.[0] || {};
const salary = job.salary || {};
// postedAt/expiresAt are Unix seconds - convert to Date with * 1000
const postedAt = job.postedAt ? new Date(job.postedAt * 1000) : null;
const expiresAt = job.expiresAt ? new Date(job.expiresAt * 1000) : null;
const fields = {
title: job.title,
description: job.description,
summary: job.summary,
companyName: company.name,
companyUrl: company.website,
city: location.addressLocality,
state: location.addressRegion,
country: location.addressCountry,
remoteType: job.remoteType,
salaryMin: salary.min,
salaryMax: salary.max,
salaryCurrency: salary.currency,
experienceLevel: job.experienceLevel,
employmentType: job.employmentType,
skills: job.skills?.primary_skills || [],
industry: job.industry || [],
applyUrl: job.applyUrl,
postedAt,
expiresAt,
status: job.status
};
await prisma.job.upsert({
where: { externalId: job.id },
update: { ...fields, syncedAt: new Date() },
create: { externalId: job.id, slug, ...fields }
});
}
async function sync() {
const startTime = Math.floor(Date.now() / 1000); // Unix seconds
const lastSync = await getLastSyncTime();
console.log(`Starting sync from ${new Date(lastSync * 1000).toISOString()}`);
try {
const jobs = await fetchJobs(lastSync);
for (const job of jobs) {
await upsertJob(job);
}
await saveLastSyncTime(startTime);
console.log(`Sync complete. Processed ${jobs.length} jobs.`);
} catch (error) {
console.error('Sync failed:', error);
process.exit(1);
}
}
sync();
Schedule with Cron
# Run daily at 3 AM
0 3 * * * cd /app && node sync-jobs.js >> /var/log/sync.log 2>&1
Step 3: Build the Search API
Create an endpoint to search your local database:
Express.js API
// routes/jobs.js
import express from 'express';
import { PrismaClient } from '@prisma/client';
const router = express.Router();
const prisma = new PrismaClient();
router.get('/jobs', async (req, res) => {
const {
q,
remote,
experience,
minSalary,
skills,
page = 1,
limit = 20
} = req.query;
const where = {
status: 'active'
};
// Text search
if (q) {
where.OR = [
{ title: { contains: q, mode: 'insensitive' } },
{ summary: { contains: q, mode: 'insensitive' } },
{ companyName: { contains: q, mode: 'insensitive' } }
];
}
// Remote filter
if (remote) {
where.remoteType = remote;
}
// Experience filter
if (experience) {
where.experienceLevel = experience;
}
// Salary filter
if (minSalary) {
where.salaryMin = { gte: parseInt(minSalary) };
}
// Skills filter
if (skills) {
const skillList = Array.isArray(skills) ? skills : [skills];
where.skills = { hasSome: skillList };
}
const [jobs, total] = await Promise.all([
prisma.job.findMany({
where,
orderBy: { postedAt: 'desc' },
skip: (parseInt(page) - 1) * parseInt(limit),
take: parseInt(limit),
select: {
id: true,
slug: true,
title: true,
companyName: true,
city: true,
remoteType: true,
salaryMin: true,
salaryMax: true,
salaryCurrency: true,
experienceLevel: true,
postedAt: true,
skills: true
}
}),
prisma.job.count({ where })
]);
res.json({
jobs,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / parseInt(limit))
}
});
});
router.get('/jobs/:slug', async (req, res) => {
const job = await prisma.job.findUnique({
where: { slug: req.params.slug }
});
if (!job) {
return res.status(404).json({ error: 'Job not found' });
}
res.json(job);
});
export default router;
Step 4: Build the Frontend
Job Listing Page (Next.js)
// app/jobs/page.js
import Link from 'next/link';
async function getJobs(searchParams) {
const params = new URLSearchParams(searchParams);
const res = await fetch(`${process.env.API_URL}/jobs?${params}`, {
next: { revalidate: 60 }
});
return res.json();
}
export default async function JobsPage({ searchParams }) {
const { jobs, pagination } = await getJobs(searchParams);
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">Remote React Jobs</h1>
{/* Filters */}
<JobFilters />
{/* Job List */}
<div className="space-y-4">
{jobs.map(job => (
<JobCard key={job.id} job={job} />
))}
</div>
{/* Pagination */}
<Pagination {...pagination} />
</div>
);
}
function JobCard({ job }) {
return (
<Link
href={`/jobs/${job.slug}`}
className="block p-4 border rounded-lg hover:shadow-md transition"
>
<div className="flex justify-between items-start">
<div>
<h2 className="text-xl font-semibold">{job.title}</h2>
<p className="text-gray-600">{job.companyName}</p>
<div className="flex gap-2 mt-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm">
{job.remoteType}
</span>
<span className="px-2 py-1 bg-gray-100 rounded text-sm">
{job.experienceLevel}
</span>
</div>
</div>
{job.salaryMin && (
<div className="text-right">
<p className="font-semibold">
${job.salaryMin.toLocaleString()} - ${job.salaryMax?.toLocaleString()}
</p>
<p className="text-sm text-gray-500">{job.salaryCurrency}/year</p>
</div>
)}
</div>
</Link>
);
}
Job Detail Page (Next.js)
// app/jobs/[slug]/page.js
import { notFound } from 'next/navigation';
async function getJob(slug) {
const res = await fetch(`${process.env.API_URL}/jobs/${slug}`, {
next: { revalidate: 3600 }
});
if (!res.ok) return null;
return res.json();
}
export async function generateMetadata({ params }) {
const job = await getJob(params.slug);
if (!job) return { title: 'Job Not Found' };
return {
title: `${job.title} at ${job.companyName} | YourJobBoard`,
description: job.summary || `${job.title} position at ${job.companyName}`,
openGraph: {
title: job.title,
description: job.summary
}
};
}
export default async function JobPage({ params }) {
const job = await getJob(params.slug);
if (!job) notFound();
return (
<article className="max-w-3xl mx-auto p-6">
<header className="mb-8">
<h1 className="text-3xl font-bold">{job.title}</h1>
<p className="text-xl text-gray-600 mt-2">
{job.companyName}
{job.city && ` • ${job.city}, ${job.state}`}
</p>
<div className="flex gap-2 mt-4">
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded">
{job.remoteType}
</span>
<span className="px-3 py-1 bg-gray-100 rounded">
{job.employmentType}
</span>
</div>
</header>
{/* Salary */}
{job.salaryMin && (
<div className="p-4 bg-green-50 rounded-lg mb-6">
<p className="text-lg font-semibold text-green-800">
${job.salaryMin.toLocaleString()} - ${job.salaryMax?.toLocaleString()} {job.salaryCurrency}
</p>
</div>
)}
{/* Description */}
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: job.description }}
/>
{/* Apply Button */}
<div className="mt-8">
<a
href={job.applyUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700"
>
Apply Now
</a>
</div>
</article>
);
}
Step 5: SEO Optimization
Sitemap Generation
// app/sitemap.js
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default async function sitemap() {
const jobs = await prisma.job.findMany({
where: { status: 'active' },
select: { slug: true, updatedAt: true }
});
const jobUrls = jobs.map(job => ({
url: `https://yourjobboard.com/jobs/${job.slug}`,
lastModified: job.updatedAt,
changeFrequency: 'daily',
priority: 0.8
}));
return [
{
url: 'https://yourjobboard.com',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1
},
{
url: 'https://yourjobboard.com/jobs',
lastModified: new Date(),
changeFrequency: 'hourly',
priority: 0.9
},
...jobUrls
];
}
Structured Data (JSON-LD)
Google's job search features rely on JobPosting structured data. datePosted and validThrough must be ISO 8601 strings — when your stored postedAt/expiresAt are JavaScript Date objects, JSON.stringify serializes them to ISO automatically.
// components/JobSchema.jsx
export function JobSchema({ job }) {
const schema = {
'@context': 'https://schema.org',
'@type': 'JobPosting',
title: job.title,
description: job.summary,
datePosted: job.postedAt,
validThrough: job.expiresAt,
employmentType: job.employmentType?.toUpperCase(),
hiringOrganization: {
'@type': 'Organization',
name: job.companyName,
sameAs: job.companyUrl
},
jobLocation: {
'@type': 'Place',
address: {
'@type': 'PostalAddress',
addressLocality: job.city,
addressRegion: job.state,
addressCountry: job.country
}
}
};
if (job.salaryMin) {
schema.baseSalary = {
'@type': 'MonetaryAmount',
currency: job.salaryCurrency,
value: {
'@type': 'QuantitativeValue',
minValue: job.salaryMin,
maxValue: job.salaryMax,
unitText: 'YEAR'
}
};
}
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
Monetization Ideas
Once your job board has traffic, consider these revenue streams:
| Method | Description |
|---|---|
| Featured listings | Charge companies to highlight their jobs |
| Sponsored slots | Sell top positions on search results |
| Affiliate clicks | Track apply clicks and negotiate CPA |
| Job alerts | Premium email alerts for specific criteria |
| Resume database | Sell access to job seeker profiles |