Skip to content
back to /services
case-study / interview-tracker-uishipped

Interview Tracker UI

Next.js 14 App Router frontend with SSR, Pro paywall, and full auth flows

role
Personal full-stack project (production-grade)
duration
~10 weeks alongside the backend build
source
github.com/lekhrocks
Next.js 14 (App Router)React 18TypeScriptTailwind CSSTanStack Query v5react-hook-formZodRechartsPlaywrightJest + RTLjest-axeOpenAPI TypeScript codegenSerwist (PWA)WebAuthn (browser)qrcode.react
tl;dr

Built the full-stack companion frontend for the Interview Tracker API — App Router with server-side prefetch, drag-and-drop Kanban, recharts analytics, AI JD analysis, TOTP + WebAuthn login, Pro-tier paywall wired to the backend's 402 pattern, and Playwright smoke tests covering the highest-blast-radius flows.

role: Sole engineer — owned UX architecture, data-fetching strategy, auth flows, Pro paywall integration, accessibility, and end-to-end test harness.

01.outcome

Outcome

15+ pages
Routes
App Router, mix of server + client components
5 channels
Auth channels
password, OAuth, magic link, TOTP, WebAuthn
Playwright
E2E harness
4 smoke tests, backend mocked at HTTP boundary
jest-axe
A11y
toHaveNoViolations() in every component test
OpenAPI codegen
Type safety
npm run gen:api keeps DTOs in sync
Installable
PWA
Serwist SW, beforeinstallprompt, offline shell
02.problem

The Problem

The API needed a frontend that matched its ambition — not just a CRUD UI, but a product that could demonstrate SSR hydration, a credible auth story (five sign-in channels including WebAuthn passkeys), AI-powered workflows, and a paywall that actually converted rather than just blocked.

The harder constraint was keeping the frontend honest about what it knew. Auth state lives in HttpOnly cookies, never localStorage. The tier query is the only source of truth for Pro vs Free — no localStorage reads, no flash-of-wrong-tier. Every Pro feature must catch the 402 from the API and route it to a single modal, not a toast.

03.constraints

Constraints

The architecture decisions the backend enforced had direct UI implications that couldn't be papered over.

  • Tokens in HttpOnly cookies only — no Authorization header, no localStorage token; all fetches use credentials: 'include'
  • Tier reads exclusively from useIsPro() / useTier() — reading users.tier directly causes flash-of-wrong-tier on load
  • 402 → PaywallError → single PaywallModal singleton — catching a PaywallError and sending it to the toast layer loses the upgrade CTA
  • Server-side prefetch must be opportunistic: server components throw on auth failure, React Query handles the empty cache client-side — SSR is never load-bearing
  • Popovers that escape stacking contexts must use createPortal to document.body — animated dashboard cards create z-index contexts that clip inline menus
  • Accessibility: jest-axe toHaveNoViolations() in every component test; ARIA roles, aria-live, aria-busy on all async submit buttons
04.approach

Approach

The data layer is built around TanStack Query v5 with a shared request() wrapper in api.ts that owns cookie auth, 401→refresh→retry, and 402→PaywallError mapping. Every API call goes through this wrapper — there are no raw fetch() calls in the codebase. OpenAPI TypeScript codegen (npm run gen:api against the live backend) keeps request types in sync with the backend's DTOs; a breaking backend change surfaces as a TypeScript error at every affected call site.

SSR uses a thin server component shell that calls serverApi.ts (server-only helpers that forward the incoming Cookie header) and hands the dehydrated cache to HydrationBoundary. The client tree uses the same useQuery() hooks — SSR is transparent to components. When the server prefetch fails (no cookie, expired token), React Query simply treats the cache as empty and the client's existing 401→refresh flow takes over.

The Pro paywall is a single PaywallProvider mounted at the layout root. Any component that calls a Pro endpoint wraps the await in a try/catch, checks instanceof PaywallError, and calls showPaywall(e.feature). PAYWALL_COPY in billing.ts maps feature keys to modal copy — hardcoding prices or feature names in JSX is explicitly forbidden so a plan change is one diff.

  • Five auth channels: password, OAuth (Google/GitHub), magic link, TOTP 2FA challenge flow, WebAuthn passkeys — all converge on the same persistUser() → finishLoginRedirect() rail
  • Drag-and-drop Kanban using native HTML5 drag/drop API — no third-party DnD library
  • AI JD analysis: textarea seeds from ?applicationId= query param; source pill shows provenance; editing the textarea drops the pill
  • Bulk import: two-step CSV/JSON import — preview with per-row validation errors, then commit the same File; stateless server design
  • PWA: Serwist service worker, beforeinstallprompt stash, install prompt shown after ≥3 applications and 14-day dismissal cooldown
  • Playwright smoke harness: four tests covering demo login → JD redirect, 402 → PaywallModal, Kanban drag-and-drop, SSR-hydrated dashboard; backend mocked at HTTP boundary
05.tradeoffs

Tradeoffs

Engineering is the act of choosing what to give up. These are the deliberate tradeoffs I made and why.

decision · SSR strategy
chose · Opportunistic server prefetch + HydrationBoundaryoverFull SSR with getServerSideProps-style auth enforcement or pure CSR

SSR that fails hard on auth errors complicates the token-refresh story. Opportunistic prefetch gives first-paint speed on happy paths while the client's existing auth flow handles all edge cases — no duplicated refresh logic.

decision · Type safety for API contracts
chose · OpenAPI TypeScript codegen from live backend specoverHand-written interfaces or shared library

A broken backend DTO rename becomes a TypeScript error at every call site on the next codegen run — catch it in the IDE, not in prod. Hand-written types drift silently.

decision · Paywall modal
chose · Single singleton via PaywallProvider at layout rootoverPer-page or per-feature modal instances

One instance means one place to update copy, one place to track analytics, and no risk of two modals stacking. The feature key is the only per-callsite concern.

decision · Popover portals
chose · createPortal to document.body with fixed coords from useLayoutEffectoverInline rendering inside the card

Framer Motion animated cards create new stacking contexts. Inline popovers get clipped or stacked under adjacent cards on some browsers — hit this in production once. The portal pattern is the load-bearing fix.

decision · Drag-and-drop
chose · Native HTML5 drag/drop APIoverreact-beautiful-dnd or @dnd-kit

Zero bundle cost, no additional dependency surface, and sufficient for a single-board Kanban. A more complex multi-board future would revisit this.

06.lessons

What I'd take with me

  • 01HttpOnly cookies are the right auth primitive for a browser app, but they make SSR prefetch genuinely harder — the server has to forward the cookie header explicitly, and any error should be treated as a cache miss rather than a hard failure.
  • 02A single PaywallProvider singleton is not a premature abstraction — it's what makes the upgrade CTA consistent across every feature surface without duplicating modal state.
  • 03OpenAPI codegen is the cheapest possible contract test between frontend and backend. The 30-second regeneration cycle is far cheaper than a runtime surprise in staging.
  • 04jest-axe in every component test catches ARIA regressions before they reach a screen reader user. The cost is one extra line per test; the payoff compounds.
  • 05Playwright mocked at the HTTP boundary is the right granularity for smoke tests — fast, hermetic, and honest about the UI logic without needing a real database.
on-call
Want to walk through this in an interview?

Happy to discuss tradeoffs, run the code live, or whiteboard the next iteration.