Skip to content

Frontend

Karthik P edited this page Oct 15, 2026 · 1 revision

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.

At a glance

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

Routes

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"]
Loading
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.

Special files in app/

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 shell and layouts

(app)/layout.tsx renders AppShell, which does three jobs:

  1. Layout. A max-w-6xl container with a header: the Logo linking to /home, the primary nav (Home, Analyze, Matches, Settings as ghost buttons, hidden below sm), 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 gets aria-current="page".
  2. Session guard. It reads authClient.useSession() and exposes a SessionGate (loading, signed-in or failed, plus retry) through context. Only a 401 counts as signed out; a network or server failure leaves the cookie alone and yields failed. A signed-out visitor has the stale cookie cleared with authClient.signOut() before being sent to /sign-in?next=…, so the proxy cannot bounce them back in a loop.
  3. 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 keyed Fragment, 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.

Request path: proxy and /api rewrite

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.

Data layer (apps/web/lib)

api.ts: the fetch wrapper

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.

Domain modules

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.

Query keys

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.

Polling

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.

Settings saves

PUT /api/settings replaces every setting, so each section saves only its own fields merged into the saved settings. useSectionSave does this in order:

  1. Validates the section's fields against the cached settings with validate(UpdateSettings, …) and shows field errors without a request.
  2. Enqueues the save on a module-level serialQueue, so saves from different sections in one tab never interleave.
  3. 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.
  4. Invalidates ["settings"] (exact) and ["overview"], shows a success toast and calls onSaved.

Errors on other fields become one form-level message; a refused Discord webhook gets a message that links to /settings#schedule.

Onboarding flow

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
Loading
  • WelcomeView keeps the step in the URL (router.replace corrects a missing, unknown or unreachable step via resolveStep), 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.tsx holds the step bodies. The resume, matching and source steps reuse the Settings sections inside EmbeddedSections, which drops their headings. FirstRunStep starts a run on every source and opens /runs/<id>?first=1; on a 409 it opens the run already in progress.
  • SetupChecklist is 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.
  • FirstRunExplainer is 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.

State and error conventions

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.

PWA manifest

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.

Dependencies of note

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

Scripts

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.

Clone this wiki locally