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
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
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.
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).
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.
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');56const router = express.Router();78// GET /api/jobs?keyword=react&category=engineering&type=full_time&work_mode=remote&salary_min=800009router.get('/', async (req, res) => {10 const { keyword, category, type, work_mode, experience_level, salary_min, salary_max } = req.query;1112 const conditions = [eq(jobPostings.status, 'active')];1314 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 }3435 const jobs = await db36 .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);5758 res.json(jobs);59});6061module.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.
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.
1// POST /api/companies — register as employer2router.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' });67 const [company] = await db.insert(companies)8 .values({ userId, name, description, industry, size, location, website })9 .returning();10 res.status(201).json(company);11});1213// POST /api/jobs — employer creates a posting14router.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' });1819 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 days2122 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});3132// GET /api/jobs/:id/applications — employer views applicants33router.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});4041// PATCH /api/applications/:id/status — update applicant status42router.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.
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.
1const { applications, savedJobs } = require('../../shared/schema');23// POST /api/jobs/:id/apply — submit application4router.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' });78 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_violation19 return res.status(409).json({ error: 'You have already applied to this job' });20 }21 res.status(500).json({ error: err.message });22 }23});2425// POST /api/jobs/:id/save — toggle saved26router.post('/jobs/:id/save', async (req, res) => {27 const userId = req.user?.id;28 if (!userId) return res.status(401).json({ error: 'Login required' });2930 const existing = await db.select().from(savedJobs)31 .where(and(eq(savedJobs.userId, userId), eq(savedJobs.jobPostingId, parseInt(req.params.id))));3233 if (existing.length > 0) {34 await db.delete(savedJobs).where(eq(savedJobs.id, existing[0].id));35 return res.json({ saved: false });36 }3738 await db.insert(savedJobs).values({ userId, jobPostingId: parseInt(req.params.id) });39 res.json({ saved: true });40});4142// GET /api/me/applications — applicant's application history43router.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.
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.
1// server/index.js — complete server entry point2const express = require('express');3const path = require('path');4const { requireAuth } = require('@replit/repl-auth');56const jobsRouter = require('./routes/jobs');7const employerRouter = require('./routes/employer');8const applicationsRouter = require('./routes/applications');910const app = express();11app.use(express.json());12app.use(requireAuth);1314// API routes15app.use('/api', jobsRouter);16app.use('/api', employerRouter);17app.use('/api', applicationsRouter);1819// Serve React frontend in production20app.use(express.static(path.join(__dirname, '../client/dist')));21app.get('*', (req, res) => {22 res.sendFile(path.join(__dirname, '../client/dist/index.html'));23});2425// IMPORTANT: bind to 0.0.0.0, not localhost, for Replit26app.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
1const { pgTable, serial, text, integer, timestamp, uniqueIndex } = require('drizzle-orm/pg-core');23exports.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});1516exports.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});3738exports.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});4748exports.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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation