Build a Job Board with Next.js
Build a job board with Next.js, PostgreSQL and search: listings, filtering, employer portal and Stripe for paid posts, deployed to a VPS.
Build a niche job board (the classic profitable side project): listing CRUD, faceted search, employer accounts and paid posting via Stripe.
What we’re building
flowchart LR
Candidate --> Next[Next.js]
Candidate --> Search[Faceted search]
Employer --> Portal[Employer portal]
Portal --> Stripe[Stripe checkout]
Next --> PG[(PostgreSQL)]
What you’ll learn
- PostgreSQL full-text + filter queries in one endpoint
- Content moderation on an open form (spam is the real enemy)
- Paid listings with auto-expiry
Prerequisites
- Node.js 22+
- PostgreSQL 17
- Stripe account
- 2 GB VPS
1. Scaffold
npx create-next-app@latest jobboard --ts --app
cd jobboard
npm i pg stripe zod
2. Schema
CREATE TABLE jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
company TEXT NOT NULL,
location TEXT,
remote BOOLEAN DEFAULT false,
salary_min INTEGER, -- cents
salary_max INTEGER,
tags TEXT[] DEFAULT '{}',
description TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
paid_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON jobs USING gin (to_tsvector('english', title || ' ' || company || ' ' || location || ' ' || description));
CREATE INDEX ON jobs (status, created_at DESC);
3. Search endpoint
app/api/jobs/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { pool } from '@/lib/db';
export async function GET(req: NextRequest) {
try {
const { searchParams } = req.nextUrl;
const q = searchParams.get('q')?.trim();
const remote = searchParams.get('remote');
const tag = searchParams.get('tag');
const min = searchParams.get('min');
const where = [`status = 'live'`];
const params: string[] = [];
if (q) {
params.push(q);
where.push(`to_tsvector('english', title||company||location||description) @@ plainto_tsquery('english', $${params.length})`);
}
if (remote === '1') where.push(`remote = true`);
if (tag) {
params.push(tag);
where.push(`$${params.length} = ANY(tags)`);
}
if (min) {
if (Number.isNaN(+min)) return NextResponse.json({ error: 'invalid min' }, { status: 400 });
params.push(String(+min));
where.push(`salary_max >= $${params.length}`);
}
const { rows } = await pool.query(
`SELECT * FROM jobs WHERE ${where.join(' AND ')} ORDER BY created_at DESC LIMIT 50`,
params
);
return NextResponse.json({ jobs: rows });
} catch (err) {
console.error('search failed', err);
return NextResponse.json({ error: 'internal error' }, { status: 500 });
}
}
Every filter value goes through a parameterized placeholder — no string concatenation into SQL.
4. Paid posting
Create the payment product first: Stripe Dashboard → Products → Add product (“Job post”, $49 one-time) → copy the Price ID (price_1...) into .env as JOB_PRICE.
Employers publish to pending with a Stripe Checkout session; only paid posts go live:
lib/payment.ts:
import { NextRequest } from 'next/server';
import Stripe from 'stripe';
import { pool } from '@/lib/db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function holdSpot(req: NextRequest, id: string, email: string) {
const origin = req.headers.get('origin') ?? process.env.NEXT_PUBLIC_APP_URL!;
const session = await stripe.checkout.sessions.create({
customer_email: email,
mode: 'payment',
line_items: [{ price: process.env.JOB_PRICE!, quantity: 1 }],
metadata: { jobId: id },
success_url: `${origin}/thanks?job=${id}`,
});
await pool.query(`UPDATE jobs SET status='awaiting_payment' WHERE id=$1`, [id]);
return session.url;
}
success_url comes from the request origin, so it works on localhost and in production unedited.
Webhook at app/api/webhooks/stripe/route.ts — verify the signature, then mark the post live for 30 days:
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { pool } from '@/lib/db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const sig = req.headers.get('stripe-signature');
if (!sig) return NextResponse.json({ error: 'missing stripe-signature' }, { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(await req.text(), sig, endpointSecret);
} catch (err) {
console.error('webhook signature failed', err);
return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
}
if (event.type !== 'checkout.session.completed') return NextResponse.json({ received: true });
try {
const session = event.data.object as Stripe.Checkout.Session;
if (session.metadata?.jobId) {
await pool.query(
`UPDATE jobs SET status='live', paid_until = now() + interval '30 days' WHERE id=$1`,
[session.metadata.jobId]
);
}
} catch (err) {
console.error('webhook handler failed', err);
return NextResponse.json({ error: 'handler failed' }, { status: 500 });
}
return NextResponse.json({ received: true });
}
Non-2xx responses make Stripe retry. Test locally: stripe listen --forward-to localhost:3000/api/webhooks/stripe. The status='awaiting_payment' update in holdSpot links the checkout metadata to the job row, so the webhook never trusts user input.
5. Moderation list
Open forms attract spam. Keep a pending inbox in the admin page and a one-click approve/reject. Cheap + human beats any filter at low volume — and it catches the LLM-written slop that AI filters wave through.
6. Run, deploy, cost
export DATABASE_URL=postgres://postgres:dev@localhost:5432/jobs
export STRIPE_SECRET_KEY=sk_test_...
export STRIPE_WEBHOOK_SECRET=whsec_...
export JOB_PRICE=price_1...
npm run dev
Dockerfile (uses output: 'standalone' in next.config.ts):
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
docker build -t jobboard .
docker run -d --name jb --restart unless-stopped -p 3000:3000 \
-e DATABASE_URL=... -e STRIPE_SECRET_KEY=... -e STRIPE_WEBHOOK_SECRET=... \
-e JOB_PRICE=... -e NEXT_PUBLIC_APP_URL=https://jobboard.example.com jobboard
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | Postgres connection string |
STRIPE_SECRET_KEY |
yes | sk_test_... from Stripe dashboard |
STRIPE_WEBHOOK_SECRET |
yes | whsec_... from stripe listen |
JOB_PRICE |
yes | price_1... of the one-time job-post product |
NEXT_PUBLIC_APP_URL |
no | production URL, used for checkout redirects |
| Service | Monthly cost |
|---|---|
| VPS 2 GB | $4.49 |
| Stripe (~2.9% + $0.30 per paid post) | ~$1/post |
| Total | ≈ $5–6/month + Stripe per sale |
A niche board with 20 paid posts/month at $49 = $980 revenue on a ~$6 cost base.
Production checklist
- Status flow: pending → live (paid) → expired (cron purge)
- Spam moderation queue
- Rate limit post form (per IP + email)
- Daily pg_dump
- Auto-expire job cron (
UPDATE jobs SET status='expired' WHERE paid_until < now())
Common problems
- Duplicate spam: dedupe by normalized (company+title+email) before insert.
- People hate salary ranges missing: make one field required.
- Search slowness at scale: keep the GIN index; add job-type facet counts in the same query.
Improvements
- Emails to saved searches (daily digest)
- GDPR one-click delete
- Company pages with all their jobs
Conclusion
A job board is one table, one search query and one payment webhook. At $6/month of infrastructure it’s the classic profitable niche — because the real product is moderation + a narrow market, not search.