Job Application Tracker & Discovery — Next.js, TypeScript, Prisma, PostgreSQL, Job Postings Catalog (+ FastAPI AI Pipeline) FullStack Project
A production-style, full-stack job application CRM — not a generic job board. It helps you track applications you have already submitted, link them to postings in the app's own Postgres job postings catalog (salary, status, workplace and lifecycle changes), discover new roles to track, visualize your search with charts and KPIs, and (Phase 2) run an AI insights pipeline. Built for learning: App Router SSR, Server Actions, TanStack Query hydration, optimistic UI, multi-layer caching, SSE cross-tab sync, and strict TypeScript end-to-end.
- Live-Demo: https://jobify-tracker.vercel.app/
- Overview
- Features
- Technology Stack
- Architecture & Data Flow
- Project Structure
- Prerequisites
- Getting Started
- Environment Variables
- Database Setup
- Running the Project
- Routes & Pages
- API Endpoints
- Server Actions (Backend)
- Authentication
- State Management & Caching
- Job Postings Catalog & Discover
- Notifications & Email Alerts
- AI Pipeline (Phase 2)
- Components Guide
- Custom Hooks
- Code Examples
- Testing & Quality
- Deployment
- Keywords
- Conclusion
- License
Jobify helps job seekers organize, track, enrich, and analyze their job search in one place. Each authenticated user gets a private dashboard where they can:
- Add and manage job applications (position, company, location, status, employment mode)
- Attach an apply URL so the job links to a catalog posting with salary, status, and workplace data
- Search and filter their pipeline with URL-synced filters and pagination
- Discover open postings from the built-in catalog and track any role with one click
- View stats, KPIs, and multiple chart types (monthly trend, weekly velocity, status/mode breakdown)
- Export data as CSV or Excel
- Receive in-app notifications (and optional email) when a tracked posting closes or changes
The app is built as a learning-oriented full-stack reference: server-rendered pages for fast first paint, client-side TanStack Query for instant interactions, Prisma + PostgreSQL for persistence, and optional Redis/Sentry/Resend for production polish.
| Feature | Description |
|---|---|
| CRUD | Create, read, update, delete job applications |
| Search & filters | Filter by position/company, status, mode, month (URL-synced) |
| Pagination | Paginated job list for large datasets |
| Stats dashboard | Status cards + KPI row (response rate, interview rate, top job type) |
| Charts | Monthly trend + projection, weekly velocity, status donut, mode bars |
| Export | Download CSV/Excel with monthly grouping |
| Dialogs | Add/Edit jobs in modal dialogs (no page navigation) |
| Feature | Description |
|---|---|
| Own catalog | Company, JobPosting, JobPostingEvent tables in Postgres (seeded) |
| Posting link | Tracked jobs link by exact apply URL, else same company + title similarity |
| Posting badges | Live / Closed / JD Changed / Salary Added on job cards |
| Discover page | Keyset-paginated search with filters; one-click Track Application |
| Nightly cron | Re-sync linked jobs and notify on changes (/api/cron/enrich) |
| Read API | /api/postings, /api/postings/facets, /api/postings/:id, /:id/events |
- NextAuth v5 authentication — credentials, Google OAuth, guest demo sign-in dropdown
- Custom auth UI — branded Sign In / Sign Up forms (no hosted provider chrome)
- Notification bell — SSE + BroadcastChannel; unread badge + popover list
- Dark / light / system theme — via
next-themes - Glassmorphism UI —
GlassCard, glass dropdowns, targeted skeletons - Responsive layout — mobile hamburger nav, grid cards on desktop
- Toast feedback — success/error notifications via Sonner
- Form validation — React Hook Form + Zod (client + server)
- SSR prefetch + hydration — data ready on first paint
- Optimistic mutations — UI updates before server round-trip
- Multi-layer cache —
unstable_cache, tags, optional Redis read-through - Cross-tab sync — BroadcastChannel + SSE (
/api/jobs/events) - localStorage persist — jobs/stats/charts cached client-side (not discover/ai)
- Sentry integration — optional error tracking with browser tunnel
- Type-safe end-to-end — TypeScript + Prisma + Zod
| Library | Version | What it does |
|---|---|---|
| Next.js | 16.x | React framework — App Router, SSR, Server Actions, Turbopack dev |
| React | 19.x | UI library with Server/Client Components |
| TypeScript | 5.8.x | Static typing across the codebase |
| Tailwind CSS | 3.4.x | Utility-first styling |
| shadcn/ui | — | Accessible Radix-based components (Button, Dialog, Select…) |
| Lucide React | — | Icon set |
| React Hook Form | 7.x | Form state with minimal re-renders |
| Zod | 3.x | Schema validation (shared client + server) |
| Recharts | 2.x | Stats page charts (Bar, Area, Pie, Composed) |
| next-themes | — | Theme switching without flash |
| TanStack Query | 5.x | Server state, cache, optimistic updates, persist |
| Sonner | — | Toast notifications |
| Library | Version | What it does |
|---|---|---|
| Next.js Server Actions | — | Type-safe server functions ("use server") |
| Prisma | 6.x | ORM — type-safe DB queries and migrations |
| PostgreSQL | — | Relational database for job records |
| NextAuth v5 | 5.x | Authentication — JWT session, Prisma adapter, OAuth + credentials |
| Upstash Redis | optional | Read-through cache + SSE invalidation streams |
| Resend | optional | Email alerts when postings change |
| exceljs / papaparse | — | Excel/CSV export generation |
| dayjs | — | Date formatting in exports and charts |
| Library | What it does |
|---|---|
| FastAPI | HTTP API for 9-agent LLM pipeline |
| Ollama | Local LLM (primary) |
| Groq / OpenRouter / Anthropic | Free-tier cloud fallbacks |
See python-ai-service/ and docs/PROJECT_PLAN.md for the full roadmap.
| Tool | Purpose |
|---|---|
| Vitest | Unit tests (lib/__tests__, hooks/__tests__, components/__tests__) |
| ESLint | Linting (eslint-config-next) |
| Sentry | Optional production error monitoring |
Understanding the flow is key to extending this project.
page.tsx (export const dynamic = 'force-dynamic')
└─ await prefetchQuery on server (QueryClient)
└─ HydrationBoundary → dehydrate state
└─ Client component (JobsGrid, StatsContainer, DiscoverResults…)
└─ useQuery with same queryKey → instant data, no loading flash
└─ lib/jobs/queries.ts (unstable_cache + tags + optional Redis)
└─ Prisma → PostgreSQL
Why force-dynamic? Every dashboard page is user-specific. SSR prefetch + client cache gives fast first paint without stale public HTML.
User action (CreateJobForm / DiscoverJobCard / DeleteJobButton)
└─ useJobsMutation (optimistic UI patch)
└─ Server Action (utils/actions.ts)
└─ Prisma write
└─ after() → linkJobToPosting() (non-blocking)
└─ invalidateUserJobCaches(userId, jobId?)
├─ revalidateTag / revalidatePath (Next cache)
├─ Redis cache key delete (optional)
└─ publishInvalidation → SSE stream
└─ useJobsCacheSync → invalidateAllJobQueries (React Query)
└─ BroadcastChannel → other browser tabs
- SSR prefetch eliminates loading spinners on first visit
- Optimistic updates make the UI feel instant
- Tag-based revalidation keeps server cache correct after mutations
- SSE + BroadcastChannel keeps multiple tabs/instances in sync
- Targeted skeletons — static chrome always visible; only data slots pulse on cold cache
18-nextjs-jobify-app/
├── app/ # Next.js App Router
│ ├── page.tsx # Landing page (/)
│ ├── layout.tsx # Root layout + providers
│ ├── providers.tsx # Theme + React Query providers
│ ├── error.tsx / global-error.tsx # Error boundaries
│ ├── (dashboard)/ # Authenticated area (route group)
│ │ ├── layout.tsx # DashboardNav + NotificationsProvider
│ │ ├── dashboard/
│ │ │ ├── page.tsx # /dashboard — jobs list + Add Job dialog
│ │ │ └── [id]/page.tsx # /dashboard/[id] — edit dialog via URL
│ │ ├── discover/
│ │ │ ├── page.tsx # /discover — catalog postings search
│ │ │ └── loading.tsx # Instant shell: same page view, cache-only queries
│ │ └── stats/
│ │ └── page.tsx # /stats — analytics + charts
│ ├── sign-in/[[...sign-in]]/ # Custom sign-in page
│ ├── sign-up/[[...sign-up]]/ # Custom sign-up page
│ ├── user-profile/[[...user-profile]]/
│ └── api/
│ ├── jobs/events/route.ts # SSE invalidation + notification stream
│ ├── postings/ # Read API: search, facets, :id, :id/events
│ ├── cron/enrich/route.ts # Nightly batch re-sync (CRON_SECRET)
│ ├── ai/pipeline/route.ts # AI proxy → Python FastAPI
│ └── monitoring/route.ts # Sentry browser tunnel
├── components/
│ ├── layout/ # NavShell, DashboardNav, NotificationBell…
│ ├── jobs/ # JobsGrid, JobCardShell, enrichment badge…
│ ├── discover/ # DiscoverFilters, DiscoverJobCard, results…
│ ├── stats/ # ApplicationTrendChart, WeeklyVelocity…
│ ├── dialogs/ # AddJobDialog, EditJobDialog
│ ├── ui/ # shadcn + GlassCard, glass-dropdown-menu…
│ └── CreateJobForm.tsx, EditJobForm.tsx, StatsContainer.tsx…
├── context/
│ └── notifications-context.tsx # In-app notification state (max 50)
├── hooks/
│ ├── useJobsMutation.ts # Optimistic CRUD mutations
│ ├── useJobsCacheSync.ts # SSE + BroadcastChannel sync
│ ├── useJobsListParams.ts # URL-driven dashboard filters
│ ├── useAIPipeline.ts # useStreamPipeline — SSE AI insights + progress
│ └── useGuestSignIn.ts, useNavUserSession.ts…
├── lib/
│ ├── jobs/queries.ts # Cached Prisma reads (stats, charts, weekly)
│ ├── postings/ # queries, cache, link (match + resync), schema, types
│ ├── ai/pipeline-client.ts # AI service types + toPipelineJobInput
│ ├── ai/insight-mapper.ts # PipelineResponse ↔ JobAIInsight + fit label
│ ├── ai/pipeline-payload.ts # Server-only job select + DB profile loader
│ ├── notifications/email.ts # Resend wrapper (no-op without key)
│ ├── jobs-events.ts # SSE event bus (invalidate | notify)
│ ├── invalidate-jobs.ts # Client query invalidation
│ ├── invalidate-jobs-server.ts # Server cache bust
│ ├── query-keys.ts # Canonical React Query keys
│ ├── query-persist.ts # localStorage persist rules
│ └── cache-tags.ts, redis.ts…
├── utils/
│ ├── actions.ts # Server Actions (CRUD + postings + stats)
│ ├── types.ts # JobType + Zod schema
│ └── db.ts # Prisma client singleton
├── prisma/
│ ├── schema.prisma # Job + Company / JobPosting / JobPostingEvent
│ ├── seed.ts # Deterministic demo seed (npm run db:seed)
│ └── seed-data/ # 15 companies, 50 postings, demo jobs + profile
├── python-ai-service/ # Phase 2 — FastAPI 9-agent pipeline
│ ├── app/main.py
│ ├── app/pipeline/agents/ # Extractor → Final Verifier
│ ├── app/llm/registry.py # Free-tier model chains (Groq → Gemini → OpenRouter → HF)
│ ├── app/llm/router.py # Fallback router (+ optional Ollama / Anthropic last)
│ └── docker-compose.yml
├── proxy.ts # NextAuth JWT gate (Next.js 16; auth + legacy redirects)
├── vercel.json # Cron schedule + security headers
├── next.config.ts # Images, headers, Sentry wrapper
├── .env.example # Environment variable template
└── docs/ # Walkthrough, styling, auth, roadmapBefore you start, install:
| Requirement | Notes |
|---|---|
| Node.js 24.x | Matches engines + .nvmrc |
| npm (or pnpm/yarn) | Package manager |
| PostgreSQL | Local Docker, Neon, Supabase, or VPS |
| PostgreSQL client | psql, TablePlus, or pgAdmin |
Optional: Upstash Redis, Sentry, Resend, Python 3.11+ (for AI service).
git clone https://github.com/arnobt78/Job-Application-Tracker--NextJS-FullStack.git
cd Job-Application-Tracker--NextJS-FullStacknpm installcp .env.example .env.localFill in NextAuth secret and PostgreSQL URLs — see Environment Variables.
npx prisma generate
npx prisma db push
npm run db:seed # optional — sample jobsnpm run devOpen http://localhost:3000.
Different port (if 3000 is busy):
npm run dev -- -p 3001Minimum to run locally:
AUTH_SECRET+DATABASE_URL/DIRECT_URL.
Everything else is optional — the app degrades gracefully without it.
Create .env.local in the project root (never commit it). A full template lives in .env.example.
| Variable | Description | How to get it |
|---|---|---|
AUTH_SECRET |
JWT signing secret for NextAuth sessions | openssl rand -base64 32 |
DATABASE_URL |
PostgreSQL connection string | Neon, Supabase, local Postgres… |
DIRECT_URL |
Direct DB URL for Prisma migrations | Usually same as DATABASE_URL |
Demo credentials (pre-seeded for local dev):
# Sign in with the guest dropdown on /sign-in
Email: test@user.com
Password: 12345678OAuth (optional) — add these to enable Google sign-in:
AUTH_GOOGLE_ID= # Google Cloud Console → OAuth 2.0 → callback: /api/auth/callback/google
AUTH_GOOGLE_SECRET=Example local PostgreSQL:
DATABASE_URL="postgresql://postgres:password@localhost:5432/jobify?schema=public"
DIRECT_URL="postgresql://postgres:password@localhost:5432/jobify?schema=public"| Variable | Purpose |
|---|---|
UPSTASH_REDIS_REST_URL |
Redis REST endpoint for cache + SSE |
UPSTASH_REDIS_REST_TOKEN |
Redis auth token |
NEXT_PUBLIC_SENTRY_DSN |
Browser error reporting |
SENTRY_ORG / SENTRY_PROJECT / SENTRY_AUTH_TOKEN |
Source map upload at build |
CRON_SECRET |
Bearer auth for Vercel cron (fails closed if unset) |
RESEND_API_KEY |
Email alerts when postings change |
EMAIL_FROM |
Verified sender address |
NEXT_PUBLIC_APP_URL |
Base URL for email deep-links |
AI_SERVICE_URL |
FastAPI base URL (Phase 2) |
AI_SERVICE_SECRET |
Shared secret Next.js ↔ FastAPI (required by AI) |
GROQ_API_KEY / GEMINI_API_KEY / OPENROUTER_API_KEY / HF_TOKEN |
Free-tier LLM keys — read by python-ai-service |
Without Redis: in-memory cache + BroadcastChannel (single instance / same browser).
Without Sentry: error boundaries still work; errors are not reported externally.
Without Resend: sendPostingChangeEmail() is a graceful no-op.
Without AI service: AiInsightsPanel, skill gap and interview prep show an error state.
Postings catalog: lives in the same Postgres database — npm run db:seed fills it with demo data.
Defined in prisma/schema.prisma:
model Job {
id String @id @default(uuid())
userId String // FK → User (cascade) — row-level isolation
position String
company String
location String
status String // pending | interview | declined
mode String // full-time | part-time | internship
applyUrl String? // matched against JobPosting.applyUrl when linking
postingId String? // FK → JobPosting (SetNull) — unique per user
postingStatus String? // open | closed — snapshot at last sync
workplaceType String? // remote | hybrid | on_site
salaryMin Int?
salaryMax Int?
salaryCurrency String?
postingDescHash String? // posting.contentHash at last sync — detects JD edits
postingSyncedAt DateTime?
postingChangedAt DateTime?
postingChangeType String? // posting_closed | jd_changed | salary_added
}
model Company { id, name, slug @unique, domain, size, industry, hq }
model JobPosting { companyId → Company (cascade), title, description, location, workplaceType,
employmentType, salary*, applyUrl @unique, status, contentHash, postedAt, closedAt }
model JobPostingEvent { postingId → JobPosting (cascade), type, fieldName, oldValue, newValue, observedAt }Each job belongs to one NextAuth user via userId. Server actions always filter by authenticated userId. JobAIInsight, UserProfile, Team and TeamMember all cascade from User.
npm run prisma:generate # Generate Prisma Client after schema changes
npm run prisma:push # Push schema to database (dev)
npm run prisma:studio # Open visual DB browser
npm run db:seed # Replace catalog + demo account data (other users untouched)
npm run db:reset # DESTRUCTIVE: wipe the whole DB, push schema, then seed| Command | Description |
|---|---|
npm run dev |
Start dev server (Turbopack) at localhost:3000 |
npm run build |
Production build (prisma generate + next build) |
npm start |
Run production server |
npm run lint |
ESLint |
npm run typecheck |
TypeScript check |
npm test |
Vitest unit tests (105 tests) |
npm run test:e2e |
Playwright (needs a running server + seeded DB) |
Full verification (recommended before deploy):
npm run lint && npm run typecheck && npm test && npm run build| Route | Access | Description |
|---|---|---|
/ |
Public | Marketing landing page with hero carousel |
/sign-in |
Public | Custom sign-in form + OAuth + demo login |
/sign-up |
Public | Custom sign-up form + email verification |
/dashboard |
Protected | Main jobs dashboard (list, search, Add Job dialog) |
/dashboard/[id] |
Protected | Opens edit job dialog for direct URL sharing |
/discover |
Protected | Catalog postings — filter, track applications |
/stats |
Protected | Stats cards, KPIs, 4 chart sections |
/profile |
Protected | User profile (skills, target roles, resume for AI) |
Legacy redirects (handled in proxy.ts):
| Old URL | Redirects to |
|---|---|
/add-job |
/dashboard |
/jobs, /jobs/* |
/dashboard |
This project uses Server Actions for most data operations. HTTP routes:
- Purpose: Server-Sent Events — cache invalidation + in-app notifications
- Auth: NextAuth session required (401 if unauthenticated)
- Used by:
hooks/useJobsCacheSync.ts - Events:
{ type: 'invalidate' }and{ type: 'notify', payload }
- Purpose: Read API for the postings catalog (search with keyset
cursor, facet counts, detail, lifecycle events) - Auth: NextAuth session (401 if unauthenticated); query params validated with the same Zod schemas as the Server Actions
- Params:
q,country,workplaceType,employmentType,salaryExists,cursor,limit(max 50)
- Purpose: Nightly re-sync of every job linked to a posting; notifies on closed / JD changed / salary added
- Auth: Bearer
CRON_SECRET, timing-safe compare, fails closed when unset (Vercel cron at 03:00 UTC) - Schedule: Defined in
vercel.json
- Purpose: SSE proxy to the Python 9-agent pipeline (per-agent progress events)
- Auth: NextAuth session; forwards
X-Internal-Secretto FastAPI - Profile: loaded server-side from
UserProfile— never taken from the request body - Requires:
AI_SERVICE_URL+AI_SERVICE_SECRET
- Purpose: Enqueue AI analysis for jobs without an insight (ARQ worker →
/api/internal/ai-complete) - Auth: NextAuth session
- Purpose: Sentry browser tunnel (same-origin proxy)
- Optional: Only active when
NEXT_PUBLIC_SENTRY_DSNis set
All server-side data logic lives in utils/actions.ts with "use server".
| Action | Purpose |
|---|---|
createJobAction(values) |
Create job; links it to a catalog posting in after() |
getAllJobsAction({ search, jobStatus, jobMode, monthYear, page }) |
Paginated filtered list |
getJobFilterOptionsAction() |
Distinct months for month filter dropdown |
getSingleJobAction(id) |
Single job (redirects if not found) |
updateJobAction(id, values) |
Update job; re-link if unlinked or applyUrl changed |
deleteJobAction(id) |
Delete job |
getStatsAction() |
Pending / interview / declined + mode breakdown |
getChartsDataAction() |
Monthly application counts (6 months) |
getWeeklyChartsDataAction() |
Weekly velocity (12 weeks) |
getAllJobsForDownloadAction() |
All jobs for CSV/Excel export |
searchPostingsAction(params) / getPostingFacetsAction(params) |
Discover search (keyset pages) + facet counts |
getPostingDetailsAction(id) / getPostingEventsAction(id) |
Posting detail modal + job Activity tab |
trackJobFromDiscoverAction(postingId) |
Track a posting (server loads it; idempotent upsert) |
Security pattern (every action):
async function authenticateAndRedirect(): Promise<string> {
const { userId } = await auth();
if (!userId) redirect("/");
return userId;
}Prisma queries always include userId in where clauses so users cannot access each other's data.
- Proxy:
proxy.ts— NextAuth JWT gate protects/dashboard,/discover,/stats,/timeline,/profile - Custom UI:
SignInForm.tsx,SignUpForm.tsx— glassmorphic cards, no hosted provider chrome - OAuth:
AuthOAuthButtons—signIn('google')fromnext-auth/react - Demo login: guest dropdown on sign-in page —
useGuestSignIn→signIn('credentials', { email, password }) - SSR user:
dashboard/layout.tsx→auth()→NavUserProvider
After sign-in or sign-up, users land on /dashboard.
queryKeys.jobs.list(search, jobStatus, jobMode, monthYear, page)
queryKeys.jobs.filterOptions // ['jobs', 'filter-options']
queryKeys.stats.all // ['stats']
queryKeys.charts.all // ['charts']
queryKeys.chartsWeekly.all // ['charts-weekly']
queryKeys.job.detail(id) // ['job', id]
queryKeys.discover.search(q, …) // NOT persisted
queryKeys.discover.detail(postingId) // NOT persisted
queryKeys.discover.events(postingId) // NOT persisted
queryKeys.ai.pipeline(jobId) // NOT persisted- Create: prepends new job to list + bumps stats + charts
- Delete: removes job from cache before server confirms
- Update: patches job detail + list + stats (if status/mode changed)
On success: invalidateAllJobQueries (+ BroadcastChannel).
On settled: same invalidation without re-broadcast (avoids ping-pong).
Uses Next.js unstable_cache with per-user tags from lib/cache-tags.ts. Optional Redis in lib/redis.ts adds read-through caching for production.
Persists jobs, stats, charts, charts-weekly, job keys — not discover or ai (live/external data).
lib/format-date.ts formats job dates in UTC so SSR and client render identical text (prevents React hydration mismatch).
Postings live in the app's own Postgres tables: Company → JobPosting → JobPostingEvent (lifecycle history). npm run db:seed loads 15 fictional companies and 50 postings (40 open, 10 closed; US and Canada; every workplace and employment type; ~70% with salary). Reads go through unstable_cache tagged postings (5-minute revalidate).
User saves job (create / update / extension)
└─ after() → linkJobToPosting(jobId, userId)
└─ exact normalised applyUrl → else same company + title similarity ≥ 0.5
└─ buildPostingSnapshot → Prisma update (one posting per user)
└─ invalidateUserJobCaches → SSE → dashboard badge updates
Nightly cron → resyncJob → diffSnapshot → posting_closed | salary_added | jd_changed → bell + email
Badges on job cards: Live · Closed · JD Changed · Salary Added
- Glass filter bar (country, workplace, employment type, salary)
- SSR prefetch + TanStack Query with
placeholderData(no grid flash on filter change) - Track Application uses
useCreateJobMutation→ dashboard updates instantly - Keyset pagination (
cursor= id of the next page's first posting) with real totals
| File | Role |
|---|---|
lib/postings/queries.ts / cache.ts |
Search, facets, detail, events (+ cache) |
lib/postings/link.ts |
Match, snapshot, diff, resync + notify |
components/discover/discover-job-card.tsx |
Result card + track button |
components/jobs/job-enrichment-badge.tsx |
Live status badge |
- SSE bus:
lib/jobs-events.tsmultiplexesinvalidateandnotifyevents - Provider:
context/notifications-context.tsx— max 50, read/unread state - Bell:
components/layout/notification-bell.tsx— badge + popover list - Cross-tab:
useJobsCacheSyncrelaysnotifyvia BroadcastChanneljobify-notifications
Triggered when the posting resync detects: posting closed, JD changed, salary added.
- Resend:
lib/notifications/email.ts— lazy import, no-op withoutRESEND_API_KEY - Requires
EMAIL_FROMandNEXT_PUBLIC_APP_URLfor deep-links
Scaffolded — requires python-ai-service/ running separately.
AiInsightsPanel (client)
└─ useStreamPipeline → POST /api/ai/pipeline/stream (NextAuth session, SSE)
└─ + DB profile, X-Internal-Secret → FastAPI /pipeline/run/stream
└─ 9-agent pipeline:
Extractor → Analyzer → Preprocessor → Optimizer
→ Synthesizer → Validator → Assembler → View → Final Verifier
└─ LLM fallback: Groq → Gemini → OpenRouter (:free) → Hugging Face
(+ Ollama / Anthropic only when configured) — docs/LLM_MODEL_SELECTION.md
cd python-ai-service
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
uvicorn app.main:app --reload --port 8000 # Next.js dev owns :3000
pytest -q # router, callback, auth, pipeline testsThe service reads the repo-root .env.local (shared with Next.js). Set there:
AI_SERVICE_URL=http://localhost:8000
AI_SERVICE_SECRET=change-me-in-production
GROQ_API_KEY=... # plus GEMINI_API_KEY, OPENROUTER_API_KEY, HF_TOKENGET /health lists which LLM providers have keys (names only).
See docs/PROJECT_PLAN.md for Coolify VPS deployment and n8n automation roadmap.
| Component | Path | Reuse |
|---|---|---|
NavShell |
components/layout/nav-shell.tsx |
Fixed glass navbar chrome |
DashboardNav |
components/layout/dashboard-nav.tsx |
Authenticated nav + notification bell |
GlassCard |
components/ui/glass-card.tsx |
Frosted card variants (neutral, sky, violet…) |
PageSectionHeader |
components/ui/page-section-header.tsx |
Icon + title + subtitle + badge |
GlassDropdownTrigger |
components/ui/glass-dropdown-menu.tsx |
Shared filter dropdown pattern |
| Component | Purpose |
|---|---|
JobsGrid / JobCardShell |
Static card chrome; skeleton only on text slots |
JobCard |
Single job card + enrichment badge + edit/delete |
JobsFilterBar |
Glass search + dropdown filters (URL-driven) |
AddJobDialog / EditJobDialog |
Modal forms |
DownloadDropdown |
CSV/Excel export |
AiInsightsPanel |
On-demand AI fit score + cover letter (Phase 2) |
| Component | Purpose |
|---|---|
DiscoverFilters |
Glass filter bar in GlassCard variant="sky" |
DiscoverJobCard |
Result card + Track Application |
DiscoverResults |
TanStack Query grid + static card shells |
DiscoverResultsToolbar |
Live posting count badge |
| Component | Purpose |
|---|---|
StatsContainer |
6-card grid: 3 status cards + StatsKpiCards |
StatsKpiCards |
Response rate, interview rate, top job type |
ApplicationTrendChart |
Monthly bar + projected line |
WeeklyVelocityChart |
12-week area chart |
StatusDistributionChart |
Donut chart |
ModeDistributionChart |
Horizontal bar chart |
Both forms accept a standalone prop:
standalone={true}— renders outerGlassCard(standalone page use)standalone={false}— form only (inside dialog)
<CreateJobForm standalone={false} onSuccess={() => setOpen(false)} />| Hook | File | Purpose |
|---|---|---|
useCreateJobMutation |
useJobsMutation.ts |
Optimistic job creation |
useUpdateJobMutation |
useJobsMutation.ts |
Optimistic job update |
useDeleteJobMutation |
useJobsMutation.ts |
Optimistic job delete |
useJobsCacheSync |
useJobsCacheSync.ts |
SSE + BroadcastChannel invalidation |
useJobsListParams |
useJobsListParams.ts |
URL-driven dashboard filter state |
useJobsListBodyLoading |
useJobsListBodyLoading.ts |
Skeleton only on cold cache |
useStreamPipeline |
useAIPipeline.ts |
SSE AI insights + per-agent progress |
useGuestSignIn |
useGuestSignIn.ts |
Demo account login flow |
useNavUserSession |
useNavUserSession.ts |
SSR avatar + NextAuth useSession |
Reuse useJobsMutation in another project:
- Copy hook +
lib/invalidate-jobs.ts+lib/query-keys.ts - Point mutations at your server actions
- Wrap app in
QueryClientProvider - Optionally add
useJobsCacheSyncfor multi-tab sync
// app/(dashboard)/dashboard/page.tsx
export const dynamic = "force-dynamic";
async function DashboardPage({ searchParams }) {
const filters = parseJobsListFiltersFromSearchParamsRecord(
await searchParams,
);
const queryClient = new QueryClient();
await Promise.all([
queryClient.prefetchQuery({
queryKey: queryKeys.jobs.list(/* … */),
queryFn: () =>
getAllJobsAction({
/* … */
}),
}),
queryClient.prefetchQuery({
queryKey: queryKeys.stats.all,
queryFn: () => getStatsAction(),
}),
]);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DashboardPageHeader />
<JobsFilterSection />
<JobsResultsToolbar />
<JobsGrid />
</HydrationBoundary>
);
}// components/discover/discover-job-card.tsx
const { mutate, isPending } = useCreateJobMutation();
mutate({
position: job.title,
company: job.org_id,
location: cleanLocation(job.location_text ?? job.country ?? "Unknown"),
status: JobStatus.Pending,
mode: toJobMode(job.employment_type),
applyUrl: job.apply_url,
});
// → optimistic dashboard update + invalidateAllJobQueries + posting link in after()// proxy.ts
import { auth } from "@/auth";
const PROTECTED = [
"/dashboard",
"/discover",
"/stats",
"/timeline",
"/profile",
];
export default auth((req) => {
const { pathname } = req.nextUrl;
const isProtected = PROTECTED.some(
(p) => pathname === p || pathname.startsWith(p + "/"),
);
if (isProtected && !req.auth) {
return NextResponse.redirect(new URL("/sign-in", req.url));
}
});Tests live in lib/__tests__/, hooks/__tests__/, and components/__tests__/.
| Test file | Covers |
|---|---|
format-date.test.ts |
UTC date formatting (hydration safety) |
query-keys.test.ts |
Query key shape |
invalidate-jobs.test.ts |
Client cache invalidation |
cache-tags.test.ts |
Per-user cache tags |
chart-optimistic.test.ts |
Optimistic chart patches |
stats-optimistic.test.ts |
Optimistic stats patches |
filter-params.test.ts |
URL filter parse/build/clear |
useJobsMutation.test.ts |
Optimistic list mutations |
npm test # 51 tests- Push to GitHub
- Import project in Vercel
- Add environment variables from
.env.example - Deploy —
npm run buildruns automatically
-
AUTH_SECRETset in Vercel env (generate:openssl rand -base64 32) - PostgreSQL production database
-
DATABASE_URL+DIRECT_URLin Vercel env -
NEXT_PUBLIC_APP_URLset to production domain (needed for webhook registration + email links) - Optional:
AUTH_GOOGLE_ID/AUTH_GOOGLE_SECRET— Google OAuth (callback:/api/auth/callback/google) - Optional: Upstash Redis for multi-instance cache/SSE
-
CRON_SECRETfor the nightly posting re-sync and weekly digest (cron routes reject requests without it) - Optional: Resend (
RESEND_API_KEY+EMAIL_FROM) for email alerts - Optional: Sentry DSN + auth token for source maps
- Optional: Python AI service (
AI_SERVICE_URL+AI_SERVICE_SECRET) on Coolify VPS
See also: docs/VERCEL_PRODUCTION_GUARDRAILS.md, docs/PROJECT_PLAN.md
Next.js App Router · Server Actions · Server Components · Client Components · TypeScript · React 19 · TanStack Query · React Query hydration · Optimistic UI · Prisma ORM · PostgreSQL · NextAuth v5 authentication · Tailwind CSS · shadcn/ui · Glassmorphism · SSR prefetch · Cache invalidation · Server-Sent Events · BroadcastChannel · Redis Streams · Upstash · Job postings catalog · Keyset pagination · Job discovery · Resend email · FastAPI · Ollama · LLM pipeline · Job tracker CRM · Full-stack · Zod validation · React Hook Form · Recharts · Dark mode · Vercel deployment · Sentry monitoring · Vitest
Jobify demonstrates how a modern full-stack application combines secure authentication, type-safe data access, live external API enrichment, performant multi-layer caching, and polished UX in a single Next.js codebase. Use it to:
- Learn App Router patterns (SSR, Server Actions, Client Components)
- Study production-ready cache and invalidation strategies
- Model a relational catalog (companies, postings, lifecycle events) with foreign keys and snapshot-based change detection
- Fork as a starter for dashboards, CRMs, or any CRUD app with live data enrichment
- Teach full-stack concepts with real, runnable code
Explore docs/PROJECT_WALKTHROUGH.md for a shorter technical reference, docs/PROJECT_PLAN.md for the Phase 2 AI roadmap, and docs/JOBIFY_TECH_STACK_ANALYSIS.md for stack deep-dives.
This project is licensed under the MIT License. Feel free to use, modify, and distribute the code as per the terms of the license.
This is an open-source project - feel free to use, enhance, and extend this project further!
If you have any questions or want to share your work, reach out via GitHub or my portfolio at https://www.arnobmahmud.com.
Enjoy building and learning! 🚀
Thank you! 😊



