How to Build a Job Board

Build a niche job board powered by the Jobven API. Complete guide covering data sync, search, filtering, and SEO optimization.

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:

MethodDescription
Featured listingsCharge companies to highlight their jobs
Sponsored slotsSell top positions on search results
Affiliate clicksTrack apply clicks and negotiate CPA
Job alertsPremium email alerts for specific criteria
Resume databaseSell access to job seeker profiles

Next Steps

Daily Sync

Detailed sync implementation.

Filter Jobs

Customize your niche filters.

Jobs Reference

All available job fields.