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 → ProductsAdd 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.

Related guides