Skip to main content
RapidDev - Software Development Agency

How to Build a Job Board with Replit

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.

What you'll build

  • Public job search page with keyword full-text search and multi-faceted filters (category, type, work mode, salary range, experience level)
  • Employer dashboard to create, manage, and close job postings with application counts per listing
  • Applicant dashboard showing applied and saved jobs with application status tracking
  • Two-role system: employers register a company profile, candidates browse without registration
  • RESTful API with PostgreSQL full-text search using tsvector and GIN index
  • Salary range filtering with nullable min/max salary columns
  • One-application-per-user enforcement via unique constraint on job_posting_id + applicant_id
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate13 min read1-2 hoursReplit FreeApril 2026RapidDev Engineering Team
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.

What you're building

A job board is one of the most valuable platforms you can build for a niche market. Whether it's remote design jobs, local restaurant positions, or tech roles in your industry, a focused job board beats general platforms like Indeed for specific audiences. Employers pay to post (or post for free), and candidates find better-matched roles because the audience is pre-filtered.

Replit Agent generates the full Express backend with all the complexity handled for you. The two-role system — employers and candidates — is built on Replit Auth, which handles Google, GitHub, and email login with zero configuration. Employers who complete a company registration see the employer dashboard; everyone else sees the candidate view.

The technical heart of this build is the multi-faceted search. PostgreSQL's tsvector indexes let you combine keyword search (finds 'senior React developer' in job titles and descriptions) with structured filters (work mode = remote, salary >= 80000) in a single query. The GIN index makes this fast even with thousands of listings. Replit's built-in PostgreSQL stores everything, and Drizzle Studio gives you a visual table editor during development.

Final result

A fully functional two-sided job board where employers post listings and manage applicants, candidates search and apply, and full-text search with salary and type filters powers the discovery experience — deployed on Replit Autoscale.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth

Prerequisites

  • 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)

Build steps

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.

prompt.txt
1// Type this into Replit Agent:
2// Build a two-sided job board platform with Express and PostgreSQL using Drizzle ORM.
3// Tables:
4// - companies: id serial pk, user_id text unique not null, name text not null, logo_url text,
5// website text, description text, industry text, size text (enum: startup/small/medium/large/enterprise),
6// location text, created_at timestamp default now()
7// - job_postings: id serial pk, company_id integer FK companies, title text not null, slug text unique not null,
8// description text not null, location text, type text not null (enum: full_time/part_time/contract/freelance/internship),
9// work_mode text default 'remote' (enum: remote/hybrid/onsite), salary_min integer, salary_max integer,
10// salary_currency text default 'USD', category text not null,
11// experience_level text (enum: entry/mid/senior/lead/executive), skills_required text[],
12// application_url text, status text default 'active' (enum: draft/active/closed/expired),
13// expires_at timestamp, posted_at timestamp, view_count integer default 0, created_at timestamp default now()
14// - applications: id serial pk, job_posting_id integer FK, applicant_id text not null,
15// resume_url text, cover_letter text, status text default 'submitted'
16// (enum: submitted/reviewing/shortlisted/rejected/hired), applied_at timestamp default now(),
17// unique(job_posting_id, applicant_id)
18// - saved_jobs: id serial pk, user_id text not null, job_posting_id integer FK,
19// saved_at timestamp default now(), unique(user_id, job_posting_id)
20// Routes: GET /api/jobs (public search with filters), GET /api/jobs/:slug (detail),
21// POST /api/companies, POST /api/jobs, PUT /api/jobs/:id, PATCH /api/jobs/:id/close,
22// GET /api/companies/:id/jobs, POST /api/jobs/:id/apply, GET /api/jobs/:id/applications,
23// PATCH /api/applications/:id/status, POST /api/jobs/:id/save, GET /api/saved-jobs,
24// GET /api/me/applications.
25// Use Replit Auth. Build React frontend with job search page, job detail page,
26// 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.

server/routes/jobs.js
1const express = require('express');
2const { db } = require('../db');
3const { jobPostings, companies } = require('../../shared/schema');
4const { eq, and, gte, lte, ilike, sql, or, isNull } = require('drizzle-orm');
5
6const router = express.Router();
7
8// GET /api/jobs?keyword=react&category=engineering&type=full_time&work_mode=remote&salary_min=80000
9router.get('/', async (req, res) => {
10 const { keyword, category, type, work_mode, experience_level, salary_min, salary_max } = req.query;
11
12 const conditions = [eq(jobPostings.status, 'active')];
13
14 if (keyword) {
15 conditions.push(
16 sql`to_tsvector('english', ${jobPostings.title} || ' ' || ${jobPostings.description})
17 @@ plainto_tsquery('english', ${keyword})`
18 );
19 }
20 if (category) conditions.push(eq(jobPostings.category, category));
21 if (type) conditions.push(eq(jobPostings.type, type));
22 if (work_mode) conditions.push(eq(jobPostings.workMode, work_mode));
23 if (experience_level) conditions.push(eq(jobPostings.experienceLevel, experience_level));
24 if (salary_min) {
25 conditions.push(
26 or(gte(jobPostings.salaryMax, parseInt(salary_min)), isNull(jobPostings.salaryMax))
27 );
28 }
29 if (salary_max) {
30 conditions.push(
31 or(lte(jobPostings.salaryMin, parseInt(salary_max)), isNull(jobPostings.salaryMin))
32 );
33 }
34
35 const jobs = await db
36 .select({
37 id: jobPostings.id,
38 title: jobPostings.title,
39 slug: jobPostings.slug,
40 location: jobPostings.location,
41 type: jobPostings.type,
42 workMode: jobPostings.workMode,
43 salaryMin: jobPostings.salaryMin,
44 salaryMax: jobPostings.salaryMax,
45 salaryCurrency: jobPostings.salaryCurrency,
46 category: jobPostings.category,
47 experienceLevel: jobPostings.experienceLevel,
48 postedAt: jobPostings.postedAt,
49 companyName: companies.name,
50 companyLogo: companies.logoUrl,
51 })
52 .from(jobPostings)
53 .innerJoin(companies, eq(jobPostings.companyId, companies.id))
54 .where(and(...conditions))
55 .orderBy(sql`${jobPostings.postedAt} DESC`)
56 .limit(50);
57
58 res.json(jobs);
59});
60
61module.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.

server/routes/employer.js
1// POST /api/companies — register as employer
2router.post('/companies', async (req, res) => {
3 const { name, description, industry, size, location, website } = req.body;
4 const userId = req.user?.id;
5 if (!userId) return res.status(401).json({ error: 'Login required' });
6
7 const [company] = await db.insert(companies)
8 .values({ userId, name, description, industry, size, location, website })
9 .returning();
10 res.status(201).json(company);
11});
12
13// POST /api/jobs — employer creates a posting
14router.post('/jobs', async (req, res) => {
15 const userId = req.user?.id;
16 const [company] = await db.select().from(companies).where(eq(companies.userId, userId));
17 if (!company) return res.status(403).json({ error: 'Register as employer first' });
18
19 const slug = req.body.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') + '-' + Date.now();
20 const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
21
22 const [job] = await db.insert(jobPostings).values({
23 ...req.body,
24 companyId: company.id,
25 slug,
26 postedAt: new Date(),
27 expiresAt,
28 }).returning();
29 res.status(201).json(job);
30});
31
32// GET /api/jobs/:id/applications — employer views applicants
33router.get('/jobs/:id/applications', async (req, res) => {
34 const { applications } = require('../../shared/schema');
35 const rows = await db.select().from(applications)
36 .where(eq(applications.jobPostingId, parseInt(req.params.id)))
37 .orderBy(sql`${applications.appliedAt} DESC`);
38 res.json(rows);
39});
40
41// PATCH /api/applications/:id/status — update applicant status
42router.patch('/applications/:id/status', async (req, res) => {
43 const { applications } = require('../../shared/schema');
44 const [updated] = await db.update(applications)
45 .set({ status: req.body.status })
46 .where(eq(applications.id, parseInt(req.params.id)))
47 .returning();
48 res.json(updated);
49});

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.

server/routes/applications.js
1const { applications, savedJobs } = require('../../shared/schema');
2
3// POST /api/jobs/:id/apply — submit application
4router.post('/jobs/:id/apply', async (req, res) => {
5 const applicantId = req.user?.id;
6 if (!applicantId) return res.status(401).json({ error: 'Login required to apply' });
7
8 const { resumeUrl, coverLetter } = req.body;
9 try {
10 const [application] = await db.insert(applications).values({
11 jobPostingId: parseInt(req.params.id),
12 applicantId,
13 resumeUrl,
14 coverLetter,
15 }).returning();
16 res.status(201).json(application);
17 } catch (err) {
18 if (err.code === '23505') { // unique_violation
19 return res.status(409).json({ error: 'You have already applied to this job' });
20 }
21 res.status(500).json({ error: err.message });
22 }
23});
24
25// POST /api/jobs/:id/save — toggle saved
26router.post('/jobs/:id/save', async (req, res) => {
27 const userId = req.user?.id;
28 if (!userId) return res.status(401).json({ error: 'Login required' });
29
30 const existing = await db.select().from(savedJobs)
31 .where(and(eq(savedJobs.userId, userId), eq(savedJobs.jobPostingId, parseInt(req.params.id))));
32
33 if (existing.length > 0) {
34 await db.delete(savedJobs).where(eq(savedJobs.id, existing[0].id));
35 return res.json({ saved: false });
36 }
37
38 await db.insert(savedJobs).values({ userId, jobPostingId: parseInt(req.params.id) });
39 res.json({ saved: true });
40});
41
42// GET /api/me/applications — applicant's application history
43router.get('/me/applications', async (req, res) => {
44 const applicantId = req.user?.id;
45 const rows = await db.select({
46 id: applications.id,
47 status: applications.status,
48 appliedAt: applications.appliedAt,
49 jobTitle: jobPostings.title,
50 companyName: companies.name,
51 })
52 .from(applications)
53 .innerJoin(jobPostings, eq(applications.jobPostingId, jobPostings.id))
54 .innerJoin(companies, eq(jobPostings.companyId, companies.id))
55 .where(eq(applications.applicantId, applicantId))
56 .orderBy(sql`${applications.appliedAt} DESC`);
57 res.json(rows);
58});

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
1// server/index.js — complete server entry point
2const express = require('express');
3const path = require('path');
4const { requireAuth } = require('@replit/repl-auth');
5
6const jobsRouter = require('./routes/jobs');
7const employerRouter = require('./routes/employer');
8const applicationsRouter = require('./routes/applications');
9
10const app = express();
11app.use(express.json());
12app.use(requireAuth);
13
14// API routes
15app.use('/api', jobsRouter);
16app.use('/api', employerRouter);
17app.use('/api', applicationsRouter);
18
19// Serve React frontend in production
20app.use(express.static(path.join(__dirname, '../client/dist')));
21app.get('*', (req, res) => {
22 res.sendFile(path.join(__dirname, '../client/dist/index.html'));
23});
24
25// IMPORTANT: bind to 0.0.0.0, not localhost, for Replit
26app.listen(5000, '0.0.0.0', () => {
27 console.log('Job board running on port 5000');
28});

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

shared/schema.js
1const { pgTable, serial, text, integer, timestamp, uniqueIndex } = require('drizzle-orm/pg-core');
2
3exports.companies = pgTable('companies', {
4 id: serial('id').primaryKey(),
5 userId: text('user_id').notNull().unique(),
6 name: text('name').notNull(),
7 logoUrl: text('logo_url'),
8 website: text('website'),
9 description: text('description'),
10 industry: text('industry'),
11 size: text('size'),
12 location: text('location'),
13 createdAt: timestamp('created_at').defaultNow(),
14});
15
16exports.jobPostings = pgTable('job_postings', {
17 id: serial('id').primaryKey(),
18 companyId: integer('company_id').references(() => exports.companies.id).notNull(),
19 title: text('title').notNull(),
20 slug: text('slug').unique().notNull(),
21 description: text('description').notNull(),
22 location: text('location'),
23 type: text('type').notNull(),
24 workMode: text('work_mode').default('remote'),
25 salaryMin: integer('salary_min'),
26 salaryMax: integer('salary_max'),
27 salaryCurrency: text('salary_currency').default('USD'),
28 category: text('category').notNull(),
29 experienceLevel: text('experience_level'),
30 applicationUrl: text('application_url'),
31 status: text('status').default('active'),
32 expiresAt: timestamp('expires_at'),
33 postedAt: timestamp('posted_at'),
34 viewCount: integer('view_count').default(0),
35 createdAt: timestamp('created_at').defaultNow(),
36});
37
38exports.applications = pgTable('applications', {
39 id: serial('id').primaryKey(),
40 jobPostingId: integer('job_posting_id').references(() => exports.jobPostings.id).notNull(),
41 applicantId: text('applicant_id').notNull(),
42 resumeUrl: text('resume_url'),
43 coverLetter: text('cover_letter'),
44 status: text('status').default('submitted'),
45 appliedAt: timestamp('applied_at').defaultNow(),
46});
47
48exports.savedJobs = pgTable('saved_jobs', {
49 id: serial('id').primaryKey(),
50 userId: text('user_id').notNull(),
51 jobPostingId: integer('job_posting_id').references(() => exports.jobPostings.id).notNull(),
52 savedAt: timestamp('saved_at').defaultNow(),
53});

Customization ideas

Paid job postings with Stripe

Add a Stripe Checkout flow to the posting creation step. Employers pay per listing (e.g., $99/post or $299/month unlimited). Use the /stripe command in Replit to auto-provision the Stripe integration, then gate the POST /api/jobs route behind a payment check.

Email alerts for new matching jobs

Let candidates save a search query with their email. A daily Scheduled Deployment checks for new listings matching saved searches and sends email alerts via SendGrid with a list of new matching jobs.

Company review system

Add a reviews table where past applicants (status = rejected/hired) can leave a star rating and written review for a company. Display average rating on company profiles and job cards.

Resume file upload

Integrate file uploads using Replit's object storage or an S3-compatible service. Add a resume upload button in the application form that stores the PDF and saves the URL to applications.resume_url.

Common pitfalls

Pitfall: Slugs not being unique when two companies post similarly titled jobs

How to avoid: 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.

Pitfall: Returning all job columns including salary for closed/expired listings

How to avoid: 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.

Pitfall: Not building the GIN index for full-text search

How to avoid: 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.

Pitfall: Allowing candidates to apply without authentication

How to avoid: 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.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a job board with Express and PostgreSQL. I have a job_postings table with columns: title (text), description (text), type (enum: full_time/part_time/contract), work_mode (enum: remote/hybrid/onsite), salary_min (integer, nullable), salary_max (integer, nullable), and category (text). Help me write a single PostgreSQL query (with Drizzle ORM syntax) that combines full-text search on title + description using tsvector with structured WHERE filters for type, work_mode, and salary range — including handling nullable salary columns with IS NULL fallbacks.

Build Prompt

Add a job alert subscription system to the job board. Create a job_alerts table (id, user_id text, email text, keywords text[], category text, work_mode text, salary_min integer, created_at timestamp). On a daily Scheduled Deployment in Replit, query job_postings WHERE posted_at >= now() - interval '24 hours' AND status = 'active', then for each alert check if any new listing matches the alert's criteria, and send a digest email via SendGrid listing matching jobs. Store SENDGRID_API_KEY in Replit Secrets.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.