Web architecture
The customer-facing app is apps/web. Next.js 16, React 19, Tailwind v4, shadcn/ui. Renders on Vercel. Talks to apps/api exclusively over HTTPS + Socket.IO.
App router layout
Next.js app router under apps/web/src/app/:
app/
├── [lang]/ locale prefix — 'he' | 'en' | 'ru'
│ ├── (auth)/ sign-in, sign-up — Clerk-backed
│ ├── (protected)/ requires Clerk session
│ │ ├── complete-profile/ first-time profile completion
│ │ ├── accept-terms/ reconsent flow
│ │ ├── onboarding/ org/owner onboarding
│ │ ├── (member)/ member-side routes (home, schedule, profile, ...)
│ │ ├── (course-viewer)/ course content viewer
│ │ └── dashboard/ operator dashboard (overview, members, plans, ...)
│ ├── home/ member home
│ ├── buy/ public purchase landing pages
│ ├── auth/reset/ auth-error recovery
│ └── checkin-display/ QR check-in display
├── api/ Next.js API routes (server-only)
│ ├── health/ Vercel health endpoint
│ ├── legal-document/ SSR legal-doc rendering
│ └── send/ Resend-backed feedback email
├── global-error.tsx
└── globals.cssRoute groups in parens ((auth), (protected), (member)) don’t appear in URLs — they’re for shared layouts.
Middleware
apps/web/src/middleware.ts — Clerk middleware wraps a custom function that handles:
- Locale routing. If the first path segment isn’t a known locale, redirect to
/<locale>/<rest>with a preferred locale picked by:NEXT_LOCALEcookie, if a known locale.Accept-Language(via@formatjs/intl-localematcher+Negotiator).- Fallback to
i18n.defaultLocale = 'he'(seeapps/web/src/i18n/config.ts).
- Query-param preservation on locale redirects — important for Clerk’s
__clerk_ticketinvitation flow. /mshortlink rewrite —/<lang>/m/<rest>→/<lang>/<rest>.- Dashboard root redirect —
/<lang>/dashboard→/<lang>/dashboard/overview. - Legacy dashboard path redirects. Any of
overview | members | plans | payments | community | minisite | analytics | settingsas the second segment is rewritten to/<lang>/dashboard/<segment>. Catches old bookmarks. - Protected-route enforcement via
createRouteMatcher([...])— callsauth.protect()for/[lang],/[lang]/schedule(.*),/[lang]/workouts(.*),/[lang]/shop(.*),/[lang]/courses(.*),/[lang]/profile(.*),/[lang]/dashboard(.*),/[lang]/onboarding(.*),/[lang]/complete-profile(.*). - Pathname header. Sets
x-pathnameon the inbound request so server components can branch on the URL without waiting for client hydration.
E2E auth bypass
When NEXT_PUBLIC_E2E_TEST_MODE === 'true', auth.protect() is not invoked. Combined with TEST_AUTH_BYPASS=true on the API and per-persona x-test-user-id headers, this enables E2E specs to swap personas without re-signing-in.
i18n
Three locales: he (default, RTL), en, ru. Dictionaries: apps/web/src/i18n/dictionaries/{en,he,ru}.json. Loader: apps/web/src/i18n/get-dictionary.ts. Config: apps/web/src/i18n/config.ts.
Hard rule (CLAUDE.md): every user-facing string lives in the dictionary, in all three locales, before the code that references it lands. No hardcoded English.
See i18n.md for the deep dive.
Styling
- Tailwind v4 — utility-first, RTL-aware via logical properties (
ms-*/me-*rather thanml-*/mr-*where direction matters). - shadcn/ui components under
apps/web/src/components/ui/. - Global stylesheet at
apps/web/src/app/globals.css. - Radix overlay rule — every
Drawer | Sheet | Dialogmust include a visually hiddenDescription(DrawerDescription, etc.) for accessibility (seeCLAUDE.md).
Data fetching
Two helpers, both auto-attaching the Clerk bearer:
serverFetch — server components only
apps/web/src/lib/api.ts. Server-side fetch wrapping clerk/nextjs/server’s auth().getToken(). Behaviours:
- Attaches
Authorization: Bearer <token>when a session exists. - Attaches
x-test-*headers viagetTestAuthHeaders()when the E2E bypass is active. - On
401— redirects to a locale-aware auth-reset path (getAuthResetPath(lang)). - On
5xx— captures to Sentry withapi.path,api.status,requestIdtags. - Throws on any non-2xx (so server-component error boundaries fire).
Plus serverFetchGet(path, lang?) — wrapped in React cache() for per-request deduplication, and getCurrentUserServer(lang) — unstable_cached for 20s per Clerk user id with revalidateTag(\user:${userId}`)` invalidation.
useApi — client components
A React hook with the same bearer-attachment behaviour, designed for use inside @tanstack/react-query useQuery / useMutation calls. Lives in apps/web/src/hooks/ (paired with query-keys.ts for stable cache keys).
Why both?
Server components avoid the client bundle and pre-fetch on the server (better LCP). Client components own user-driven mutations and live data. The two share the API URL (NEXT_PUBLIC_API_URL, overridable to E2E_API_URL in tests) and the bearer mechanic — only the source of the token differs (auth() server-side vs useAuth() client-side).
Providers
All client-side providers compose in apps/web/src/providers.tsx. The tree, from outer to inner:
| Provider | Purpose | Source |
|---|---|---|
PostHogProvider | Web analytics client | apps/web/src/providers/posthog-provider.tsx |
QueryClientProvider | React Query — 60s staleTime, 30-min gcTime, keepPreviousData for paginated queries | apps/web/src/providers.tsx |
AnalyticsUserSync | Pushes Clerk user id/email into Sentry + PostHog when the user changes | same file |
DictionaryProvider | Makes the loaded locale dictionary available to client components | apps/web/src/providers/dictionary-provider.tsx |
UserProvider | Wraps /users/me + memberships for the current-user hook | apps/web/src/providers/user-provider.tsx |
OnboardingProvider | Drives the onboarding wizard state | apps/web/src/providers/onboarding-provider.tsx |
TourProvider | In-app product tour | apps/web/src/providers/tour-provider.tsx |
AgentProvider | Spotter (AI assistant) state + WS stream | apps/web/src/providers/agent-provider.tsx |
RealtimeProvider | Socket.IO client + presence | apps/web/src/providers/realtime-provider.tsx |
ReactQueryDevtools | Dev-only |
Feature gating (client-side)
apps/web/src/components/feature-gate.tsx — conditionally renders children based on the user’s org’s platform_tier and the @fitkit/shared tierHasFeature helper. Soft-gate only — the API enforces via PlatformTierGuard. Use FeatureGate to hide UI affordances cleanly when a feature isn’t entitled.
Notable utility code
| Path | Purpose |
|---|---|
apps/web/src/lib/api.ts | serverFetch, serverFetchGet, getCurrentUserServer |
apps/web/src/lib/test-auth-headers.ts | Builds x-test-* headers when E2E test mode is on |
apps/web/src/lib/auth-reset.ts | Maps a locale to the auth-reset URL used on 401 |
apps/web/src/lib/query-keys.ts | React Query key factory |
apps/web/src/lib/prefetch.ts | Server-side React Query prefetching utility |
apps/web/src/lib/zod-i18n.ts | Maps Zod errors to translated messages |
apps/web/src/lib/format-price.ts | Currency formatting (ILS-first) |
apps/web/src/lib/date-utils.ts | Date formatting + RTL-aware helpers |
Testing
- Unit + integration with vitest. Configs:
apps/web/vitest.unit.config.ts,apps/web/vitest.integration.config.ts. - E2E with Playwright. Config:
apps/web/e2e/playwright.config.ts. Specs inapps/web/e2e/specs/. Drivers inapps/web/e2e/drivers/. - Auth caching for E2E. Global setup in
apps/web/e2e/global.setup.tsruns a real Clerk sign-in once withE2E_CLERK_USER_EMAIL+E2E_CLERK_USER_PASSWORD, then writesstorageStatetoe2e/.auth/signed-in-state.json. Specs reuse it via Playwright fixtures. - See
testing/strategy.mdandtesting/driver-pattern.md.
PostHog
Client-side: posthog-js initialised in apps/web/src/providers/posthog-provider.tsx. Sentry session id is tagged into PostHog (posthog_session_id) so a Sentry issue is one click from the PostHog session replay.
Server-side: apps/api/src/event-tracking/ sends auth/funnel events (user_signed_up, checkout_started, etc.) via POSTHOG_API_KEY + POSTHOG_PROJECT_ID.
Local dev
pnpm dev # both API and web in parallel
pnpm dev:web # web only on port 3000apps/web/.env.local carries the Next.js public env (NEXT_PUBLIC_*). Backend env stays in apps/api/.env.