A curated job board for remote developer roles. Server-rendered list + detail, URL-driven search, client-side filters, a submit-a-role flow backed by zod-validated server actions, and a custom 404 — all on a single Next.js 16 App Router deploy against Prisma Postgres.
|
Sidebar and detail pane are independent route segments. Clicking a job navigates to |
List + detail fetches hit Prisma directly from RSC — no client cache, no HTTP round-trip, no loading spinner for first paint. "Post a role" posts to a zod-validated server action that |
Search lives in |
|
Bookmarks persist to |
axe-core reports zero violations across all routes. Keyboard nav, |
Shared |
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack, React 19) |
| Database | Prisma 5 + Prisma Postgres via Vercel Marketplace |
| State | React Context (Filter, Bookmarks, JobItems) + URL search params |
| Validation | Zod — schema shared between server action and client form |
| Testing | Vitest + Testing Library |
| UI | Vanilla CSS with design tokens, Radix Icons, next/font (Inter + Fraunces), react-hot-toast |
| Hosting | Vercel — one deploy, one dashboard |
| Desktop | Mobile |
|---|---|
![]() |
![]() |
Driftwork connects to Prisma Postgres through the Vercel Marketplace. Once the project is linked, one command pulls the connection string.
npm install
vercel link # connect this repo to your Vercel project (once)
vercel env pull .env # pull DATABASE_URL from the Marketplace integration
npm run db:push # create schema on your DB
npm run db:seed # seed from prisma/data (50 jobs)
npm run dev # → http://localhost:3000Try it: type react / python / a company name · click a job (URL becomes /jobs/[id]?search=...) · refresh (fully hydrated) · stack a senior + TypeScript filter · bookmark a couple jobs and refresh · use Post a role to submit a new job — appears in search immediately.
npm test # run the 32-test suite once (~1s)
npm run test:watch # watch modeCovered surfaces:
| File | Tests | Covers |
|---|---|---|
test/schemas.test.ts |
13 | zod salary normalization, validation rules |
test/filters.test.ts |
14 | filter / sort / paginate pipeline |
test/JobList.test.tsx |
4 | list render + active highlight + search-param preservation |
| Endpoint | Purpose |
|---|---|
GET /api/jobs?search=<term> |
Fuzzy-matches title, company, tags |
GET /api/jobs?ids=id1,id2,... |
Batched detail fetch (bookmarks hydration) |
GET /api/jobs/:id |
Single job detail |
All responses share the same shape ({ public, jobItems } or { public, jobItem }) that the RSC consumers also receive directly from Prisma.
Frontend, API routes, and database all live on Vercel — one dashboard, one deploy.
-
Push to GitHub.
-
Import the repo on Vercel — Next.js is auto-detected, first build succeeds.
-
Provision Prisma Postgres from the Vercel Marketplace:
vercel link vercel integration add prisma-postgres
Injects
DATABASE_URL(plus mirroredPOSTGRES_URLandPRISMA_DATABASE_URLaliases) into every environment — Production, Preview, Development. -
Bootstrap the schema + seed once, from local:
vercel env pull .env npx prisma db push npm run db:seed
-
Redeploy (or push to
main). The build picks upprisma generatevia thepostinstallhook and connects throughDATABASE_URLat runtime.
After the initial seed, subsequent deploys don't need any manual DB steps.
Project layout
.
├── app/
│ ├── layout.tsx # root: providers + header + <main>
│ ├── (main)/ # route group for list + detail
│ │ ├── layout.tsx # adds Container with parallel @detail slot
│ │ ├── page.tsx # RSC: fetches list by ?search
│ │ ├── jobs/[id]/page.tsx # sidebar when on a detail URL (+ metadata)
│ │ └── @detail/
│ │ ├── default.tsx # "pick a job" empty state
│ │ ├── loading.tsx # detail skeleton
│ │ └── jobs/[id]/page.tsx # RSC: fetches detail
│ ├── submit/ # post-a-role form + server action
│ ├── api/jobs/ # route handlers
│ ├── not-found.tsx # custom 404
│ └── globals.css # design tokens + component styles
│
├── components/ # Presentational + client islands
├── context/ # FilterContext, BookmarksContext, JobItemsContext
├── lib/
│ ├── services/jobService.ts # Prisma calls
│ ├── db.ts # PrismaClient singleton + BigInt shim
│ ├── schemas.ts # zod schemas (shared server/client)
│ ├── filters.ts # pure filter / sort / paginate
│ ├── hooks.ts # useDebounce, useLocalStorage, ...
│ └── type.ts # single source of truth for types
├── test/ # Vitest suites
└── prisma/
├── schema.prisma # JobItem model
├── seed.ts # seeds from prisma/data/*.json
└── data/ # 50 curated jobs + expanded details
Implementation notes
- Job IDs are stored as
TEXT— the seeded dataset includes values that exceedint8range. They coerce toNumberat the API boundary, matchingJobItem.id: number. remoteis randomized at seed time so the filter toggle does something meaningful (~80% remote, ~20% hybrid).useLocalStorageis SSR-safe — the lazy initializer only readslocalStorageon the client, avoiding hydration mismatch.- The Prisma singleton in
lib/db.tsstashes the client onglobalThisto survive Next.js HMR and serverless cold starts without exhausting the connection pool. - Mobile route-mode is wired via a small client component (
components/RouteMode.tsx) that setsbody[data-route]fromusePathname()— CSS swaps which pane (sidebar vs detail) is visible without a layout refactor.

