# How to Build a Job Board with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Free
- Last updated: April 2026

## TL;DR

Build a job board in Replit in 1-2 hours where employers post listings and candidates browse, filter, and apply. Replit Agent generates an Express + PostgreSQL app with full-text search, two-role access, salary filtering, and application tracking. No coding experience needed. Deploy on Autoscale.

## Before you start

- A Replit account (Free plan is sufficient for development)
- A list of job categories relevant to your niche (e.g., Engineering, Design, Marketing)
- Basic understanding of what a database table is (no coding experience needed)
- Optional: a payment method if you want to charge employers per listing (requires Stripe setup)

## Step-by-step guide

### 1. Scaffold the project with Replit Agent

Create a new Repl and use the Agent prompt below to generate the full Express + PostgreSQL job board with Drizzle schema, routes, and React frontend in one shot.

```
// Type this into Replit Agent:
// Build a two-sided job board platform with Express and PostgreSQL using Drizzle ORM.
// Tables:
// - companies: id serial pk, user_id text unique not null, name text not null, logo_url text,
//   website text, description text, industry text, size text (enum: startup/small/medium/large/enterprise),
//   location text, created_at timestamp default now()
// - job_postings: id serial pk, company_id integer FK companies, title text not null, slug text unique not null,
//   description text not null, location text, type text not null (enum: full_time/part_time/contract/freelance/internship),
//   work_mode text default 'remote' (enum: remote/hybrid/onsite), salary_min integer, salary_max integer,
//   salary_currency text default 'USD', category text not null,
//   experience_level text (enum: entry/mid/senior/lead/executive), skills_required text[],
//   application_url text, status text default 'active' (enum: draft/active/closed/expired),
//   expires_at timestamp, posted_at timestamp, view_count integer default 0, created_at timestamp default now()
// - applications: id serial pk, job_posting_id integer FK, applicant_id text not null,
//   resume_url text, cover_letter text, status text default 'submitted'
//   (enum: submitted/reviewing/shortlisted/rejected/hired), applied_at timestamp default now(),
//   unique(job_posting_id, applicant_id)
// - saved_jobs: id serial pk, user_id text not null, job_posting_id integer FK,
//   saved_at timestamp default now(), unique(user_id, job_posting_id)
// Routes: GET /api/jobs (public search with filters), GET /api/jobs/:slug (detail),
// POST /api/companies, POST /api/jobs, PUT /api/jobs/:id, PATCH /api/jobs/:id/close,
// GET /api/companies/:id/jobs, POST /api/jobs/:id/apply, GET /api/jobs/:id/applications,
// PATCH /api/applications/:id/status, POST /api/jobs/:id/save, GET /api/saved-jobs,
// GET /api/me/applications.
// Use Replit Auth. Build React frontend with job search page, job detail page,
// employer dashboard, and applicant dashboard. Bind server to 0.0.0.0.
```

> Pro tip: After Agent creates the schema, open the Replit SQL editor (database icon → SQL Editor) and manually run: CREATE INDEX idx_jobs_fts ON job_postings USING GIN(to_tsvector('english', title || ' ' || description)); to enable full-text search.

**Expected result:** A running Express app with all four tables created and a React frontend with a job listing page. Opening the app shows job cards (empty until you seed some test data).

### 2. Build the multi-faceted job search route

The search endpoint combines PostgreSQL full-text search with structured filters in a single query. Employers and admins pass a status filter; public searches always return only active listings.

```
const express = require('express');
const { db } = require('../db');
const { jobPostings, companies } = require('../../shared/schema');
const { eq, and, gte, lte, ilike, sql, or, isNull } = require('drizzle-orm');

const router = express.Router();

// GET /api/jobs?keyword=react&category=engineering&type=full_time&work_mode=remote&salary_min=80000
router.get('/', async (req, res) => {
  const { keyword, category, type, work_mode, experience_level, salary_min, salary_max } = req.query;

  const conditions = [eq(jobPostings.status, 'active')];

  if (keyword) {
    conditions.push(
      sql`to_tsvector('english', ${jobPostings.title} || ' ' || ${jobPostings.description})
          @@ plainto_tsquery('english', ${keyword})`
    );
  }
  if (category) conditions.push(eq(jobPostings.category, category));
  if (type) conditions.push(eq(jobPostings.type, type));
  if (work_mode) conditions.push(eq(jobPostings.workMode, work_mode));
  if (experience_level) conditions.push(eq(jobPostings.experienceLevel, experience_level));
  if (salary_min) {
    conditions.push(
      or(gte(jobPostings.salaryMax, parseInt(salary_min)), isNull(jobPostings.salaryMax))
    );
  }
  if (salary_max) {
    conditions.push(
      or(lte(jobPostings.salaryMin, parseInt(salary_max)), isNull(jobPostings.salaryMin))
    );
  }

  const jobs = await db
    .select({
      id: jobPostings.id,
      title: jobPostings.title,
      slug: jobPostings.slug,
      location: jobPostings.location,
      type: jobPostings.type,
      workMode: jobPostings.workMode,
      salaryMin: jobPostings.salaryMin,
      salaryMax: jobPostings.salaryMax,
      salaryCurrency: jobPostings.salaryCurrency,
      category: jobPostings.category,
      experienceLevel: jobPostings.experienceLevel,
      postedAt: jobPostings.postedAt,
      companyName: companies.name,
      companyLogo: companies.logoUrl,
    })
    .from(jobPostings)
    .innerJoin(companies, eq(jobPostings.companyId, companies.id))
    .where(and(...conditions))
    .orderBy(sql`${jobPostings.postedAt} DESC`)
    .limit(50);

  res.json(jobs);
});

module.exports = router;
```

**Expected result:** GET /api/jobs?keyword=react returns all active listings with 'react' in the title or description. Adding &work_mode=remote narrows results further. Response time under 100ms with the GIN index.

### 3. Build the employer dashboard routes

Employers need to create postings, view applicants, and update application statuses. The company registration step differentiates employer accounts from regular candidates.

```
// POST /api/companies — register as employer
router.post('/companies', async (req, res) => {
  const { name, description, industry, size, location, website } = req.body;
  const userId = req.user?.id;
  if (!userId) return res.status(401).json({ error: 'Login required' });

  const [company] = await db.insert(companies)
    .values({ userId, name, description, industry, size, location, website })
    .returning();
  res.status(201).json(company);
});

// POST /api/jobs — employer creates a posting
router.post('/jobs', async (req, res) => {
  const userId = req.user?.id;
  const [company] = await db.select().from(companies).where(eq(companies.userId, userId));
  if (!company) return res.status(403).json({ error: 'Register as employer first' });

  const slug = req.body.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') + '-' + Date.now();
  const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days

  const [job] = await db.insert(jobPostings).values({
    ...req.body,
    companyId: company.id,
    slug,
    postedAt: new Date(),
    expiresAt,
  }).returning();
  res.status(201).json(job);
});

// GET /api/jobs/:id/applications — employer views applicants
router.get('/jobs/:id/applications', async (req, res) => {
  const { applications } = require('../../shared/schema');
  const rows = await db.select().from(applications)
    .where(eq(applications.jobPostingId, parseInt(req.params.id)))
    .orderBy(sql`${applications.appliedAt} DESC`);
  res.json(rows);
});

// PATCH /api/applications/:id/status — update applicant status
router.patch('/applications/:id/status', async (req, res) => {
  const { applications } = require('../../shared/schema');
  const [updated] = await db.update(applications)
    .set({ status: req.body.status })
    .where(eq(applications.id, parseInt(req.params.id)))
    .returning();
  res.json(updated);
});
```

> Pro tip: Add a view_count increment to the GET /api/jobs/:slug route using db.update(jobPostings).set({ viewCount: sql`view_count + 1` }). This gives employers insight into how many candidates saw their listing before applying.

**Expected result:** POST /api/companies registers the employer. POST /api/jobs creates a listing with auto-generated slug and 30-day expiry. Employer dashboard shows listings with application counts.

### 4. Add job applications and saved jobs

Candidates apply once per job (enforced by a unique constraint) and can save listings for later. The application route prevents duplicates and the saved jobs toggle works as a bookmark system.

```
const { applications, savedJobs } = require('../../shared/schema');

// POST /api/jobs/:id/apply — submit application
router.post('/jobs/:id/apply', async (req, res) => {
  const applicantId = req.user?.id;
  if (!applicantId) return res.status(401).json({ error: 'Login required to apply' });

  const { resumeUrl, coverLetter } = req.body;
  try {
    const [application] = await db.insert(applications).values({
      jobPostingId: parseInt(req.params.id),
      applicantId,
      resumeUrl,
      coverLetter,
    }).returning();
    res.status(201).json(application);
  } catch (err) {
    if (err.code === '23505') { // unique_violation
      return res.status(409).json({ error: 'You have already applied to this job' });
    }
    res.status(500).json({ error: err.message });
  }
});

// POST /api/jobs/:id/save — toggle saved
router.post('/jobs/:id/save', async (req, res) => {
  const userId = req.user?.id;
  if (!userId) return res.status(401).json({ error: 'Login required' });

  const existing = await db.select().from(savedJobs)
    .where(and(eq(savedJobs.userId, userId), eq(savedJobs.jobPostingId, parseInt(req.params.id))));

  if (existing.length > 0) {
    await db.delete(savedJobs).where(eq(savedJobs.id, existing[0].id));
    return res.json({ saved: false });
  }

  await db.insert(savedJobs).values({ userId, jobPostingId: parseInt(req.params.id) });
  res.json({ saved: true });
});

// GET /api/me/applications — applicant's application history
router.get('/me/applications', async (req, res) => {
  const applicantId = req.user?.id;
  const rows = await db.select({
    id: applications.id,
    status: applications.status,
    appliedAt: applications.appliedAt,
    jobTitle: jobPostings.title,
    companyName: companies.name,
  })
  .from(applications)
  .innerJoin(jobPostings, eq(applications.jobPostingId, jobPostings.id))
  .innerJoin(companies, eq(jobPostings.companyId, companies.id))
  .where(eq(applications.applicantId, applicantId))
  .orderBy(sql`${applications.appliedAt} DESC`);
  res.json(rows);
});
```

**Expected result:** Applying twice to the same job returns HTTP 409 'You have already applied'. The applicant dashboard shows all applications with current status and company name.

### 5. Deploy on Autoscale

Job boards get traffic in bursts — candidates browse during lunch breaks and after work. Autoscale handles spikes without wasting money during quiet periods. Add the DB retry wrapper before deploying.

```
// server/index.js — complete server entry point
const express = require('express');
const path = require('path');
const { requireAuth } = require('@replit/repl-auth');

const jobsRouter = require('./routes/jobs');
const employerRouter = require('./routes/employer');
const applicationsRouter = require('./routes/applications');

const app = express();
app.use(express.json());
app.use(requireAuth);

// API routes
app.use('/api', jobsRouter);
app.use('/api', employerRouter);
app.use('/api', applicationsRouter);

// Serve React frontend in production
app.use(express.static(path.join(__dirname, '../client/dist')));
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, '../client/dist/index.html'));
});

// IMPORTANT: bind to 0.0.0.0, not localhost, for Replit
app.listen(5000, '0.0.0.0', () => {
  console.log('Job board running on port 5000');
});
```

> Pro tip: Set expires_at on job postings to 30 days from creation. Add a cron job (Scheduled Deployment on Replit) that runs daily and sets status = 'expired' for postings where expires_at < now(). This keeps your listing count accurate.

**Expected result:** The app deploys to a public URL. Job search is accessible without login. Employer dashboard and apply button require Replit Auth login.

## Complete code example

File: `shared/schema.js`

```javascript
const { pgTable, serial, text, integer, timestamp, uniqueIndex } = require('drizzle-orm/pg-core');

exports.companies = pgTable('companies', {
  id: serial('id').primaryKey(),
  userId: text('user_id').notNull().unique(),
  name: text('name').notNull(),
  logoUrl: text('logo_url'),
  website: text('website'),
  description: text('description'),
  industry: text('industry'),
  size: text('size'),
  location: text('location'),
  createdAt: timestamp('created_at').defaultNow(),
});

exports.jobPostings = pgTable('job_postings', {
  id: serial('id').primaryKey(),
  companyId: integer('company_id').references(() => exports.companies.id).notNull(),
  title: text('title').notNull(),
  slug: text('slug').unique().notNull(),
  description: text('description').notNull(),
  location: text('location'),
  type: text('type').notNull(),
  workMode: text('work_mode').default('remote'),
  salaryMin: integer('salary_min'),
  salaryMax: integer('salary_max'),
  salaryCurrency: text('salary_currency').default('USD'),
  category: text('category').notNull(),
  experienceLevel: text('experience_level'),
  applicationUrl: text('application_url'),
  status: text('status').default('active'),
  expiresAt: timestamp('expires_at'),
  postedAt: timestamp('posted_at'),
  viewCount: integer('view_count').default(0),
  createdAt: timestamp('created_at').defaultNow(),
});

exports.applications = pgTable('applications', {
  id: serial('id').primaryKey(),
  jobPostingId: integer('job_posting_id').references(() => exports.jobPostings.id).notNull(),
  applicantId: text('applicant_id').notNull(),
  resumeUrl: text('resume_url'),
  coverLetter: text('cover_letter'),
  status: text('status').default('submitted'),
  appliedAt: timestamp('applied_at').defaultNow(),
});

exports.savedJobs = pgTable('saved_jobs', {
  id: serial('id').primaryKey(),
  userId: text('user_id').notNull(),
  jobPostingId: integer('job_posting_id').references(() => exports.jobPostings.id).notNull(),
  savedAt: timestamp('saved_at').defaultNow(),
});
```

## Common mistakes

- **Slugs not being unique when two companies post similarly titled jobs** — If two employers both post 'Senior Engineer', the slug 'senior-engineer' already exists and the second insert fails. Fix: Append a timestamp or random suffix to all slugs: title.toLowerCase().replace(/\s+/g, '-') + '-' + Date.now(). The unique constraint catches any remaining collisions with a clear error.
- **Returning all job columns including salary for closed/expired listings** — Employers may not want salary ranges visible on expired listings, and returning all data wastes bandwidth on list pages. Fix: Use a select projection in the list query to return only the columns needed for cards (title, company, location, type, salary range, posted_at). Fetch the full description only on the detail page.
- **Not building the GIN index for full-text search** — Without the GIN index, every keyword search scans all job descriptions, which becomes noticeably slow at 1,000+ listings. Fix: Run CREATE INDEX idx_jobs_fts ON job_postings USING GIN(to_tsvector('english', title || ' ' || description)) in the Replit SQL editor after Agent creates the tables.
- **Allowing candidates to apply without authentication** — Without requiring login, the unique constraint on (job_posting_id, applicant_id) can be bypassed by submitting with a null applicant_id. Fix: Add an authentication check at the top of the apply route: if (!req.user?.id) return res.status(401).json({ error: 'Login required to apply' }). Replit Auth handles the login flow.

## Best practices

- Add the GIN full-text index manually after Agent creates the schema — run it in the Replit SQL editor using CREATE INDEX idx_jobs_fts ON job_postings USING GIN(to_tsvector('english', title || ' ' || description)).
- Use Replit Secrets (lock icon) to store any API keys for email notifications or payment processing — never hardcode keys in Express routes.
- Use Drizzle Studio to seed initial test listings by inserting rows directly into the job_postings table during development.
- Increment view_count on every job detail page load using a fire-and-forget db.update — it gives employers engagement data without blocking the response.
- Set an expires_at timestamp 30 days from posting creation and run a daily Scheduled Deployment to expire outdated listings automatically.
- Build the employer check into a middleware function that fetches company by user_id and attaches it to req.company — reuse it across all employer-only routes.
- Deploy on Autoscale — job boards have bursty traffic patterns (mornings, lunch, after work) and Autoscale handles spikes cost-effectively.

## Frequently asked questions

### How do I differentiate employer accounts from candidate accounts?

All users log in with Replit Auth (Google, GitHub, or email). After login, the app checks if a companies row exists for that user_id. If yes, the user sees the employer dashboard. If no, they see the candidate view. Employers self-register by completing the company profile form.

### Can candidates apply without creating an account?

No — and this is intentional. Requiring a Replit Auth login enforces the unique constraint on (job_posting_id, applicant_id) and lets you display application status to returning candidates. For anonymous applications, you could relax this requirement, but you lose the duplicate-prevention guarantee.

### How does the full-text search work?

PostgreSQL converts job titles and descriptions to tsvector (a normalized token list) and indexes them with a GIN index. The query uses plainto_tsquery() to match the candidate's search terms against that index. This handles stemming (searches for 'manage' also match 'manager', 'managing') and is much more flexible than a simple ILIKE search.

### What Replit plan do I need for deployment?

Development is free on any Replit plan. Deployment with a public URL requires a paid plan (Core or higher). Autoscale is the recommended deployment type — it scales down to zero when no one is browsing, keeping costs low.

### How do I prevent employers from editing each other's job postings?

Add a company ownership check to all employer-only routes: first load the company by req.user.id, then verify the job_posting.company_id matches that company's id before allowing PUT or PATCH operations.

### Can I charge employers to post jobs?

Yes. Use the /stripe command in Replit to auto-provision a Stripe integration. Create a Checkout Session when the employer clicks 'Post a Job'. After checkout.session.completed fires in the webhook, set a job posting's status from 'pending_payment' to 'active'.

### Can RapidDev help me build a custom job board?

Yes. RapidDev has built 600+ apps including niche job boards and hiring platforms. They can add custom features like employer subscriptions, applicant tracking workflows, or integration with LinkedIn and Indeed APIs. Book a free consultation at rapidevelopers.com.

### How do I handle job expiry automatically?

Create a Scheduled Deployment in Replit that runs once per day. The job runs: UPDATE job_postings SET status = 'expired' WHERE expires_at < now() AND status = 'active'. This keeps expired listings off the public search without manual intervention.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/job-board
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/job-board
