DEV Community

Shreyash Tripathi
Shreyash Tripathi

Posted on

Building a job portal with Next.js 15 route handlers: auth, applications, and the schema behind them

I built a job portal on my own, end to end: job seekers search, save and apply for jobs, employers post openings, and everyone gets a dashboard. It's a single Next.js 15 app (App Router, TypeScript) where the API lives next to the pages as route handlers.

People sometimes assume a "job board" means the MERN stack. This one isn't: there's no Express server and no MongoDB. The backend is Next.js route handlers, and the data model is relational PostgreSQL.

This post walks through the real code, then ends honestly: the live demo is a prototype, and here's exactly what I'd change before it handled real users.

The shape of the app

app/
  api/
    auth/register/route.ts   POST  create account (bcrypt)
    auth/login/route.ts      POST  check password, sign JWT
    jobs/route.ts            GET   search + filter jobs
    applications/route.ts    GET / POST  list + apply
    profile/route.ts
    resume/route.ts
  jobs/  companies/  dashboard/  post-job/  profile/  resume/ ...
scripts/
  create-database.sql        8-table PostgreSQL schema
  seed-data.sql
Enter fullscreen mode Exit fullscreen mode

Twelve pages, six route handlers, one schema. Keeping the API inside the Next.js app means one deploy on Vercel, one TypeScript codebase and no CORS setup.

1. Registration: validate, then hash

The register handler checks the required fields, confirms the two passwords match, rejects a duplicate email, and only then hashes the password with bcrypt at 12 salt rounds:

// app/api/auth/register/route.ts
export async function POST(request: NextRequest) {
  const body = await request.json()

  const requiredFields = ["firstName", "lastName", "email", "password", "userType"]
  for (const field of requiredFields) {
    if (!body[field]) {
      return NextResponse.json({ error: `${field} is required` }, { status: 400 })
    }
  }

  if (body.password !== body.confirmPassword) {
    return NextResponse.json({ error: "Passwords do not match" }, { status: 400 })
  }

  const hashedPassword = await bcrypt.hash(body.password, 12)
  // ...save the user, then strip the hash before responding
  const { password, ...userResponse } = newUser
  return NextResponse.json({ message: "User registered successfully", user: userResponse }, { status: 201 })
}
Enter fullscreen mode Exit fullscreen mode

Two small habits matter here. The password hash never leaves the server: it's destructured out of the response object. And userType (jobseeker or employer) is set at sign-up, because the two roles see different parts of the app.

2. Login: one error message for both failures

// app/api/auth/login/route.ts
const user = users.find((u) => u.email === body.email)
if (!user) {
  return NextResponse.json({ error: "Invalid email or password" }, { status: 401 })
}

const isPasswordValid = await bcrypt.compare(body.password, user.password)
if (!isPasswordValid) {
  return NextResponse.json({ error: "Invalid email or password" }, { status: 401 })
}

const token = jwt.sign(
  { userId: user.id, email: user.email, userType: user.userType },
  JWT_SECRET,
  { expiresIn: "7d" },
)
Enter fullscreen mode Exit fullscreen mode

"Unknown email" and "wrong password" return the same message on purpose, so the login form can't be used to check which emails have accounts. The token carries the user's role, so later requests can tell an employer from a job seeker without another lookup.

3. Job search: filters as query parameters

The jobs handler reads search, location and type from the URL and narrows the list step by step. Search matches the title, the company or any skill tag:

// app/api/jobs/route.ts
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const search = searchParams.get("search")
  const location = searchParams.get("location")
  const type = searchParams.get("type")

  let filteredJobs = jobs

  if (search) {
    const q = search.toLowerCase()
    filteredJobs = filteredJobs.filter(
      (job) =>
        job.title.toLowerCase().includes(q) ||
        job.company.toLowerCase().includes(q) ||
        job.tags.some((tag) => tag.toLowerCase().includes(q)),
    )
  }
  if (location) {
    filteredJobs = filteredJobs.filter((job) => job.location.toLowerCase().includes(location.toLowerCase()))
  }
  if (type) {
    filteredJobs = filteredJobs.filter((job) => job.type.toLowerCase() === type.toLowerCase())
  }
  // ...return the filtered list
}
Enter fullscreen mode Exit fullscreen mode

Because filters live in the query string, a filtered search is just a URL: it can be bookmarked, shared or cached.

4. Applying: no double applications

A double click or a retried request shouldn't create two applications. The handler checks first:

// app/api/applications/route.ts
const existingApplication = applications.find(
  (app) => app.jobId === body.jobId && app.userId === body.userId,
)
if (existingApplication) {
  return NextResponse.json({ error: "You have already applied to this job" }, { status: 400 })
}
Enter fullscreen mode Exit fullscreen mode

The schema enforces the same rule at the database level, which is the check that actually holds under concurrent requests:

CREATE TABLE IF NOT EXISTS applications (
    id SERIAL PRIMARY KEY,
    job_id INTEGER REFERENCES jobs(id),
    user_id INTEGER REFERENCES users(id),
    cover_letter TEXT,
    resume_url VARCHAR(500),
    status VARCHAR(50) DEFAULT 'applied' CHECK (status IN
      ('applied', 'under_review', 'interview_scheduled', 'rejected', 'accepted')),
    applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(job_id, user_id) -- prevent duplicate applications
);
Enter fullscreen mode Exit fullscreen mode

The CHECK on status also means the application pipeline can only move through states the app knows how to display.

5. The data model

A job board's data is very connected, which is why I chose a relational schema. Eight tables:

Table Holds Key relations
users both roles, CHECK (user_type IN ('jobseeker','employer'))
companies company profiles created_by → users
jobs openings, salary range, skills TEXT[], remote flag company_id → companies, posted_by → users
applications status pipeline UNIQUE(job_id, user_id)
saved_jobs bookmarks UNIQUE(user_id, job_id)
user_skills skill + proficiency user_id → users
work_experience past roles user_id → users
education degrees user_id → users

Constraints like CHECK and UNIQUE push the business rules down into the database, so they hold even if a future API route forgets to check.

What I'd fix before real users (the honest part)

The live demo is a working prototype. These are the gaps, in the order I'd close them:

  1. Connect the schema. The route handlers use in-memory arrays and the dashboard keeps saved jobs and applications in localStorage. The SQL schema is written but not wired up. Next step: a Postgres connection (or Prisma) in each handler.
  2. No fallback secret. The login route falls back to a hard-coded string when JWT_SECRET is missing. That should be a startup error instead, so a misconfigured deploy fails loudly:
   const JWT_SECRET = process.env.JWT_SECRET
   if (!JWT_SECRET) throw new Error("JWT_SECRET is not set")
Enter fullscreen mode Exit fullscreen mode
  1. Verify the token on protected routes. POST /api/applications takes userId from the request body. It should come from the verified JWT, otherwise anyone can apply on someone else's behalf. Store the token in an httpOnly cookie rather than localStorage.
  2. Schema validation. Swap the manual "field is required" loops for a Zod schema per route: one place for types, required fields and email format.
  3. Search in the database. Filtering in JavaScript is fine for a demo list. With real data it belongs in SQL (ILIKE, or Postgres full-text search), with pagination.

Takeaways

  • Next.js route handlers are enough of a backend for a product like this: one codebase, one deploy.
  • Put business rules in the schema (UNIQUE, CHECK, foreign keys), not only in API code.
  • Never trust identity from the request body; take it from the verified token.

The full case study, with the architecture and results, is on my site: shreyashtripathi.in/projects/mern-job-portal.


I'm Shreyash Tripathi, a Full-Stack Developer & UI/UX Engineer in Noida, India. I build with React, Next.js, TypeScript, Node.js and PostgreSQL. More of my work: shreyashtripathi.in.

Top comments (0)