FirstDevJob Docs
Architecture

Architecture Overview

How the Next.js frontend, Convex backend, Clerk auth and Resend email fit together

FirstDevJob is a Next.js 16 App Router frontend deployed on Vercel, backed by a Convex deployment that holds every table, query, mutation, scheduled action and cron job. Clerk handles sign-in and issues the JWT that Convex verifies on each request. Transactional email is sent from inside Convex through the @convex-dev/resend component. There is no separate API server: the browser talks to Convex directly over a WebSocket, and Next.js serves pages, the docs site and one search route.

System diagram

Runtime request paths and how each half of the app is deployed
Diagram source
flowchart TD
subgraph Delivery["Delivery: every push to Master"]
  direction LR
  GH["Push to Master"] -->|"deploy.yml"| GA["GitHub Actions"]
  GH -->|"Git integration"| VG["Vercel build"]
end
subgraph Runtime["Runtime"]
  direction TB
  B["Browser"]
  APP["Next.js app on Vercel"]
  CLK["Clerk"]
  CX["Convex deployment"]
  RS["Resend"]
end
GA -->|"bunx convex deploy"| CX
VG -->|"next build and deploy"| APP
B -->|"HTTP page loads"| APP
B -->|"sign in"| CLK
CLK -->|"session JWT"| B
B -->|"queries and mutations over WebSocket"| CX
CX -->|"verifies JWT via CLERK_JWT_ISSUER_DOMAIN"| CLK
CX -->|"@convex-dev/resend component"| RS
RS -->|"staff and subscriber email"| B

The frontend never proxies Convex calls. ConvexProviderWithClerk (in src/components/ConvexClientProvider.tsx) wires the Clerk session into the Convex client, so every query and mutation carries the user's JWT and ctx.auth.getUserIdentity() resolves on the backend.

What runs where

ConcernWhere it livesSource
Pages, routing, renderingNext.js App Router on Vercelsrc/app/
Data, business logic, authorizationConvex functionsconvex/
Identity, sessions, OAuthClerksrc/app/layout.tsx, src/proxy.ts
Email deliveryResend, called from Convex actionsconvex/resend.ts, convex/notifications.ts
Scheduled cleanupConvex cronconvex/crons.ts
Documentation siteFumadocs, MDX in the repocontent/docs/, src/app/docs/

Frontend routes

RouteFileNotes
/src/app/page.tsx → PageWrapperPublic job list, search and filters
/dashboardsrc/app/dashboard/page.tsxClient component; pushes to /auth/login?redirect_url=%2Fdashboard when signed out. Also hosts the moderation panel
/profilesrc/app/profile/page.tsxServer component; calls Clerk auth() and redirects when there is no userId
/auth/login/[[...rest]]Clerk <SignIn routing="path">Catch-all so Clerk can own its sub-routes
/auth/sso-callback<AuthenticateWithRedirectCallback />OAuth return target
/unsubscribesrc/app/unsubscribe/page.tsxToken-based, works signed out
/feedback, /privacystatic pages/feedback links out to NEXT_PUBLIC_FEEDBACK_BOARD_URL when set
/docs/[[...slug]]Fumadocs pageThis site
/api/searchsrc/app/api/search/route.tsFumadocs search index, GET only

There is no /admin route. Moderation lives in AdminSection, rendered on /dashboard and returning null for anyone who is not a moderator or admin. See Authentication for what actually enforces that.

Backend modules

ModuleResponsibility
convex/schema.tsSix tables: profiles, jobs, tags, trackedApplications, emailSubscriptions, jobNotificationDeliveries
convex/jobs.tsPublic job listing and filtering, postJob with rate limits, the deleteOldJobs cron handler
convex/admin.tsModeration queries and mutations, all behind requireModOrAdmin
convex/bookmarks.tsTracked applications: bookmark toggle, status changes, notes
convex/dashboard.tsOne query that hydrates the whole dashboard in a single round trip
convex/profiles.ts, convex/users.tsProfile read/update, first-sign-in profile creation, account deletion
convex/emailSubscriptions.tsOpt-in, role filters, unsubscribe by token
convex/notifications.ts, convex/resend.tsStaff and subscriber emails, Resend component setup
convex/applicationIntegrity.ts, convex/jobSubmissionSecurity.ts, convex/jobHelpers.tsPure helpers: status transition rules, submission validation, role-level inference and job snapshots

applicationIntegrity.ts, jobSubmissionSecurity.ts and jobHelpers.ts export plain functions rather than Convex functions, which is what makes them directly unit-testable.

Real-time and scheduled work

Convex queries are subscriptions. When a moderator approves a job, every browser holding a listApprovedJobs subscription receives the new result without polling — see Data Flow.

Two things run outside a user request:

  • internal.jobs.deleteOldJobs, scheduled daily at 00:00 UTC by convex/crons.ts, ages jobs out of the listing.
  • internal.notifications.sendJobApprovedUserEmails and internal.notifications.sendNewSubmissionStaffEmail, scheduled with ctx.scheduler.runAfter(0, ...) from updateJobStatus and postJob respectively. Scheduling them instead of awaiting them keeps the mutation transactional and fast.

Convex dev and prod are separate deployments with separate environment variables. Setting EMAIL_FROM, RESEND_API_KEY, STAFF_NOTIFICATION_EMAILS or NEXT_PUBLIC_SITE_URL in dev does nothing for prod — set prod values with bunx convex env set NAME value --prod or in the prod deployment's dashboard. Notification actions log a warning and return success: false when a variable they need is missing, so a misconfigured prod deployment fails quietly.

Deployment

.github/workflows/deploy.yml runs on pushes to Master: lint, type check, tests and a build, then bunx convex deploy. The workflow's Vercel job is gated behind the repository variable VERCEL_CONFIGURED, so in practice the frontend ships through the Vercel Git integration. Full details in Deployment.

NEXT_PUBLIC_* variables are inlined into the JavaScript bundle at build time. Changing NEXT_PUBLIC_CONVEX_URL or NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY requires a fresh build, not just a redeploy of the existing one.

On this page