Repository navigation
Frontend
The web app in apps/web is a Next.js 16 App Router project on React 19 and Tailwind CSS 4. It is the only public service in the stack: it serves the pages and forwards every /api/* request to the NestJS API, so the browser talks to one origin and the better-auth session cookie stays first-party. Pages are thin server files that set metadata and render one client "view" component; the views fetch data with TanStack Query through a small fetch wrapper and decode every response with Effect Schema. This page maps the routes, the shell, the data layer and the conventions the views follow. For how it looks, see Visual System, Design Tokens and Components.
| Concern | Choice | Source |
|---|---|---|
| Framework | Next.js 16 (App Router, cacheComponents, partialPrefetching, Turbopack) |
next.config.ts |
| UI runtime | React 19 | package.json |
| Styling | Tailwind CSS 4 via @tailwindcss/turbopack, shadcn base-nova style |
globals.css, components.json
|
| Primitives | Base UI (@base-ui/react) wrapped as shadcn-style components |
components/ui |
| Server state | TanStack Query 5 | providers.tsx |
| Validation and decoding | Effect 4 Schema, shared schemas from @job-matcher/shared
|
lib/validate.ts |
| Auth client | better-auth React client | lib/auth-client.ts |
| Toasts | Sonner | components/ui/sonner.tsx |
| Icons | lucide-react | components.json |
| Path alias |
@/* maps to apps/web/*
|
tsconfig.json |
Every page file exports metadata with a title of the form <Page> | Job Matcher and renders a single component. The signed-in pages live in the (app) route group, which adds no URL segment but wraps them in the app shell.
flowchart TD
root["app/layout.tsx<br/>font, Providers, Toaster"] --> landing["/ (page.tsx)"]
root --> signin["/sign-in"]
root --> signup["/sign-up"]
root --> group["(app)/layout.tsx<br/>AppShell"]
group --> home["/home"]
group --> welcome["/welcome"]
group --> analyze["/analyze"]
group --> run["/runs/[id]"]
group --> matches["/matches"]
group --> settings["/settings"]
| Route | File | Title | Renders | Purpose |
|---|---|---|---|---|
/ |
app/page.tsx |
Job Matcher |
Logo, HeaderNav, ExampleRun, Separator, an inline GitHub icon |
Public landing page: hero, a static example run, "How a run works" in four steps, footer. A server component. |
/sign-in |
app/sign-in/page.tsx |
Sign in |
AuthPage mode="sign-in" → AuthForm
|
Email and password sign-in. |
/sign-up |
app/sign-up/page.tsx |
Create an account |
AuthPage mode="sign-up" → AuthForm
|
Account creation (name, email, password of at least 8 characters). |
/home |
app/(app)/home/page.tsx |
Home | HomeView |
Dashboard: one primary card (active run, setup checklist, "You're all set", setup card or start-run card), pending matches, latest run, next scheduled check. |
/welcome |
app/(app)/welcome/page.tsx |
Get set up |
WelcomeView → welcome-steps
|
Five-step setup wizard. The step is in ?step=. |
/analyze |
app/(app)/analyze/page.tsx |
Analyze |
AnalyzeView, RunCard, PastRuns
|
Pick sources and start a run; shows the latest run live and the list of earlier runs. |
/runs/[id] |
app/(app)/runs/[id]/page.tsx |
Run details |
RunDetailsView, FirstRunExplainer, RunCard, ScoringHelp
|
One run with every job result, its verdict and reason. ?first=1 marks the first run opened from the wizard. |
/matches |
app/(app)/matches/page.tsx |
Matches |
MatchesView, ScoringHelp
|
Pending and Applied tabs with cursor pagination and a "Mark applied" / "Move to pending" toggle. |
/settings |
app/(app)/settings/page.tsx |
Settings |
SettingsView → ResumeSection, JobSources, MatchingSection, RunSection, ScheduleSection, ClearCache
|
Six anchored sections with a section nav. Opening /settings#<id> scrolls to the section and focuses its heading. |
Pages whose content reads the URL (/welcome, /runs/[id]) wrap that content in its own Suspense boundary, so the shell and title can be prerendered under cacheComponents. The same reason puts usePathname (app shell nav) and useSearchParams (auth switch link) inside Suspense.
| File | Role |
|---|---|
layout.tsx |
Root layout. Loads Schibsted Grotesk with next/font/google into --font-schibsted, sets <html lang="en" class="… h-full antialiased">, a flex-column <body>, wraps children in Providers and mounts one Toaster. Default metadata title and description. |
providers.tsx |
Client component that creates one QueryClient per browser session (useState initialiser) and provides it. It is the only provider: there is no theme provider. |
globals.css |
Tailwind, tw-animate-css and shadcn/tailwind.css imports, the @theme inline mapping and the :root tokens. See Design Tokens. |
manifest.ts |
Web app manifest (see PWA manifest). |
icon.svg, favicon.ico, apple-icon.png
|
File-convention icons. See Brand. |
(app)/layout.tsx renders AppShell, which does three jobs:
-
Layout. A
max-w-6xlcontainer with a header: theLogolinking to/home, the primary nav (Home, Analyze, Matches, Settings as ghost buttons, hidden belowsm), a mobile dropdown nav (sm:hidden), and the user menu (Settings, an optional "Setup guide" while onboarding is incomplete, Sign out). The Analyze item is also current on/runs/*. The current item getsaria-current="page". -
Session guard. It reads
authClient.useSession()and exposes aSessionGate(loading,signed-inorfailed, plusretry) through context. Only a 401 counts as signed out; a network or server failure leaves the cookie alone and yieldsfailed. A signed-out visitor has the stale cookie cleared withauthClient.signOut()before being sent to/sign-in?next=…, so the proxy cannot bounce them back in a loop. -
Account isolation. When the session's user id changes (for example after signing in as someone else in another tab), it calls
queryClient.clear()and remounts the page subtree through a keyedFragment, so no cached data or form state crosses accounts. Sign-in, sign-up and sign-out also clear the query cache.
AppPage is the body of every signed-in page. It renders the <main> (max-w-2xl), the page <h1>, and then, depending on the gate, the page content, a "Couldn't reach the server." retry block, or a three-line skeleton. Content never renders before the session is confirmed.
The landing and auth pages use the same max-w-6xl px-5 sm:px-8 container without the shell.
Two separate mechanisms sit in front of the pages. They are easy to confuse because Next.js 16 calls its request middleware a "proxy".
| Mechanism | File | What it does |
|---|---|---|
/api rewrite |
next.config.ts |
rewrites() maps /api/:path* to ${API_URL}/api/:path*. API_URL defaults to http://localhost:4000. This is the reverse proxy to the NestJS API. |
| Auth redirects | proxy.ts |
An optimistic check for the better-auth session cookie (getSessionCookie), with no database or API call. A signed-in visitor on /, /sign-in or /sign-up is redirected to the safe next page or /home. A visitor without a cookie on /home, /analyze, /matches, /runs, /settings or /welcome is redirected to /sign-in?next=<path>. The matcher skips api/, _next/, favicon.ico and any path with a file extension. |
lib/safe-next.ts validates every next value: same-site paths only, no protocol-relative or backslash tricks (including percent-encoded ones), no control characters, at most 2,048 characters, and never an auth page. AFTER_SIGN_IN is /home; AFTER_SIGN_UP is /welcome. See Authentication for the server side.
next.config.ts also sets agentRules: false and registers the Tailwind Turbopack loader for *.css.
api(path, init) prefixes /api, always sends content-type: application/json, and returns the parsed JSON body (or null for 204). A non-2xx response throws ApiError(status, message), where message is the body's message string when present and "Something went wrong. Try again." otherwise. It returns unknown: callers decode the body with a schema before use.
| Module | Exports | Notes |
|---|---|---|
runs.ts |
Run, RunDetail, JobResult, Source, RunStatus, ScrapeVia; fetchRuns, fetchRun, fetchSources, fetchSettings, startRun, retrySave, isActive, useRun
|
Schemas for runs and results. isActive is true for QUEUED and RUNNING. fetchSettings decodes with the shared UpdateSettings schema. |
matches.ts |
Match; fetchMatches(status, cursor), setMatchStatus(id, status)
|
GET /matches?status=…&cursor=… returns { items, nextCursor }; PATCH /matches/:id toggles status. |
settings.ts |
EditableSettings, editableSettingsKey, toEditableSettings, fetchEditableSettings, useEditableSettings
|
The settings form state under ["settings", "editable"]. A saved non-Discord webhook (from before only Discord URLs were accepted) is split out as refusedWebhook so the rest of the settings still decode. |
overview.ts |
Overview, overviewKey, fetchOverview, useOverview
|
GET /overview: setup flags, latest and active run, pending match count, next scheduled run, onboarding state. Refetched every 60 seconds. |
onboarding.ts |
useDismissOnboarding, useRestoreOnboarding, useSkippedPreferences, useSeenIncompleteOnboarding
|
POST /onboarding/dismiss and /restore. Each writes the returned onboarding state into the cached overview immediately, then invalidates the overview. |
welcome-steps.ts |
wizardSteps, reachable, done, nextStepOf, startStep, resolveStep
|
Pure wizard logic, unit tested in welcome-steps.test.ts. |
user-flags.ts |
useUserFlag(name), useDismissed(name)
|
Per-user booleans in localStorage under job-matcher:<name>:<userId>. Storage failures are caught; the flag then lasts for the visit. A dismissible hint counts as dismissed until the session is known, so it never flashes. |
relative-time.ts |
formatRelative(date, now?) |
Intl.RelativeTimeFormat("en", { numeric: "auto" }) over days, hours and minutes: "3 hours ago", "in 20 minutes", "yesterday"; under a minute, "just now" or "in less than a minute". |
serial.ts |
serialQueue() |
Returns an enqueue(task) that runs promises one at a time in order; a task starts after the previous one settles, and each caller still receives its own result or error. Unit tested. |
validate.ts |
validate(schema, input) |
Runs an Effect schema through its Standard Schema adapter synchronously. Returns { value, errors: {} } or { value: null, errors }, where errors maps the first path segment (or "form") to the first message. Async schemas throw. |
safe-next.ts |
safeNext, authHref, AFTER_SIGN_IN, AFTER_SIGN_UP
|
Redirect target validation (see above). Unit tested. |
auth-client.ts |
authClient |
createAuthClient() with defaults, so it calls /api/auth/* on the same origin. |
utils.ts |
cn |
Re-export of the cn package. See Components. |
| Key | Fetcher | Used by |
|---|---|---|
["overview"] |
fetchOverview |
Home, wizard, user menu, empty Matches state |
["runs"] |
fetchRuns |
Analyze, past runs, first-run detection |
["runs", id] |
fetchRun via useRun
|
Run card on Analyze, run details, home active-run card |
["sources"] |
fetchSources |
Analyze, start-run mutations |
["settings"] |
fetchSettings |
Analyze, run details, home active-run card |
["settings", "editable"] |
fetchEditableSettings |
Settings sections and wizard steps |
["matches", status] |
fetchMatches (infinite query) |
Matches |
JobSources is the exception: it loads /sources with api() inside a useEffect and keeps the list in local state, invalidating the overview when a source is added or removed.
| Hook | Interval | Stops when | Retry |
|---|---|---|---|
useRun(id) |
2 s | The loaded run is no longer QUEUED or RUNNING
|
Up to 3 failures, but never after a 404 |
useOverview() |
60 s | Never (keeps the next scheduled run and scheduled runs current) | TanStack defaults |
useRun uses skipToken when id is undefined, so Analyze can call it before the run list has loaded. When the home page's active run settles, it invalidates ["overview"] and ["runs"] so the primary card moves on.
PUT /api/settings replaces every setting, so each section saves only its own fields merged into the saved settings. useSectionSave does this in order:
- Validates the section's fields against the cached settings with
validate(UpdateSettings, …)and shows field errors without a request. - Enqueues the save on a module-level
serialQueue, so saves from different sections in one tab never interleave. - Inside the queue, cancels any in-flight refetch, reads fresh settings, validates again, sends the
PUT, and writes the response into the cache before the next queued save runs. - Invalidates
["settings"](exact) and["overview"], shows a success toast and callsonSaved.
Errors on other fields become one form-level message; a refused Discord webhook gets a message that links to /settings#schedule.
New accounts land on /welcome after sign-up. Onboarding state comes from the API's Overview.onboarding (steps, nextStep, completedAt, dismissedAt); one choice, "Use defaults" on the matching step, is remembered only in the browser through useSkippedPreferences.
| Step id | Label | Required | Reachable when |
|---|---|---|---|
welcome |
Welcome | n/a | Always |
resume |
Resume | Yes | Always |
preferences |
Matching | No | A resume is saved |
source |
Job sources | Yes | A resume is saved |
firstRun |
First run | Yes | A resume and a source are saved |
flowchart LR
signup["Sign up"] --> welcome["/welcome?step=welcome"]
welcome --> resume["resume"]
resume --> prefs["preferences<br/>(save or Use defaults)"]
prefs --> source["source"]
source --> first["firstRun"]
first --> run["/runs/:id?first=1"]
welcome -. "Skip for now" .-> home["/home"]
resume -. "Finish later" .-> home
-
WelcomeViewkeeps the step in the URL (router.replacecorrects a missing, unknown or unreachable step viaresolveStep), shows "Step N of 5", a progress bar and a step nav, and moves focus to the step heading when the step changes. If setup was already complete when the page opened, it redirects to/home. After a save it writes the change into the cached overview so the next step is reachable at once. -
welcome-steps.tsxholds the step bodies. The resume, matching and source steps reuse the Settings sections insideEmbeddedSections, which drops their headings.FirstRunStepstarts a run on every source and opens/runs/<id>?first=1; on a 409 it opens the run already in progress. -
SetupChecklistis the home page's primary card while onboarding is incomplete and not dismissed: four items with done states, "Start first run" when possible, "Continue setup" (to/welcome?step=<next>) and "Hide checklist". The user menu's "Setup guide" restores it. A one-time "You're all set" card appears within 24 hours of completion, only in a browser that saw setup unfinished. -
FirstRunExplaineris a dismissible card above the run on the first run (from?first=1, or when the user has exactly one run). It defines each run status and counter and, while the run is active, says it is safe to leave the page.
The views follow a consistent pattern. Copy is plain and tells the user what to do next.
| Situation | Convention | Examples |
|---|---|---|
| Session unknown |
AppPage shows the title over a skeleton |
All signed-in pages |
| Initial data loading | Skeleton blocks with aria-busy="true" and a visually hidden "Loading", or render nothing until all queries resolve |
HomeSkeleton, WizardSkeleton, SettingsLoader; Analyze and run details return null
|
| Query failed | A red sentence (text-destructive), with a "Try again" outline button where a refetch helps |
"Could not load your overview.", "Could not load this run. Refresh to try again." |
| Not found | A muted explanation and a way back | Run 404: "This run doesn't exist or belongs to someone else." with "Back to Analyze" |
| Empty list | A muted sentence plus the next action | No pending matches → "Run a check"; no runs → points at the Analyze button; no sources → example board URLs |
| Mutation failed |
toast.error with the ApiError message when there is one, otherwise a fixed fallback |
Start run, mark applied, clear cache, sign out |
| Mutation succeeded |
toast.success for saves and clears; navigation or a cache update otherwise |
"Resume saved", "Cleared 3 saved pages." |
| Form validation | Client-side with the shared Effect schemas before any request; errors shown with FieldError under the field and data-invalid on the Field
|
Settings sections, job sources |
| Pending submit | Button disabled, label changes ("Saving", "Signing in", "Starting") | Section forms, auth form, first run |
| Destructive action |
AlertDialog confirmation |
Clear saved scrape data |
| Conflict (409) on starting a run | Refresh the overview or open the active run | Home, wizard |
Response bodies are always decoded: Schema.decodeUnknownSync throws on a shape mismatch, which TanStack Query reports as an error state.
app/manifest.ts makes the app installable.
| Field | Value |
|---|---|
name, short_name
|
Job Matcher |
start_url |
/home |
display |
standalone |
background_color, theme_color
|
#eef1ec (Paper) |
| Icons |
/icon-192.png and /icon-512.png (purpose: "any", rounded tile), /icon-maskable-512.png (purpose: "maskable", full bleed) |
There is no service worker; the manifest only enables installation. Icon details are in Brand.
| Package | Why it is here |
|---|---|
next ^16.4, react / react-dom ^19.3 |
Framework and runtime |
@base-ui/react ^1.9 |
Headless primitives behind Button, Input, Menu, AlertDialog, Progress, Separator, Switch |
@tanstack/react-query ^5.104 |
Server state, polling, infinite queries |
better-auth ^1.7 |
Auth client and getSessionCookie in the proxy |
effect ^4.0 |
Schema for decoding responses and validating forms |
@job-matcher/shared (workspace) |
Request and response schemas shared with the API (UpdateSettings, Overview, Onboarding, CreateJobSource, …) |
class-variance-authority ^0.7 |
Variant APIs for Button, Badge and Field |
cn ^0.4 |
Class merging (replaces clsx + tailwind-merge) |
lucide-react ^1.55 |
Icons |
sonner ^2.0 |
Toasts |
next-themes ^0.4 |
Imported only by components/ui/sonner.tsx for useTheme; no ThemeProvider is mounted (see Visual System) |
shadcn ^4.21 |
Provides shadcn/tailwind.css (custom variants, utilities) and the CLI |
tw-animate-css ^1.4 |
animate-in / fade-in / zoom-in utilities used by the dialog and menu |
tailwindcss ^4.3, @tailwindcss/turbopack ^4.3 (dev) |
Styling and the Turbopack CSS loader |
| Script | Command |
|---|---|
dev |
next dev --port 3000 |
build |
next build |
start |
next start |
test |
bun test (the lib/*.test.ts files) |
typecheck |
next typegen && tsc --noEmit |
See Development Setup and Testing for how these run from the repository root.
Job Matcher · Website · Report an issue · MIT license
Using Job Matcher
System design
Matching engine
Interface and brand
Engineering