Skip to Content
Living documentation — last reviewed 2026-05-28
ArchitectureWeb architecture

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.css

Route 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:

  1. Locale routing. If the first path segment isn’t a known locale, redirect to /<locale>/<rest> with a preferred locale picked by:
    • NEXT_LOCALE cookie, if a known locale.
    • Accept-Language (via @formatjs/intl-localematcher + Negotiator).
    • Fallback to i18n.defaultLocale = 'he' (see apps/web/src/i18n/config.ts).
  2. Query-param preservation on locale redirects — important for Clerk’s __clerk_ticket invitation flow.
  3. /m shortlink rewrite/<lang>/m/<rest>/<lang>/<rest>.
  4. Dashboard root redirect/<lang>/dashboard/<lang>/dashboard/overview.
  5. Legacy dashboard path redirects. Any of overview | members | plans | payments | community | minisite | analytics | settings as the second segment is rewritten to /<lang>/dashboard/<segment>. Catches old bookmarks.
  6. Protected-route enforcement via createRouteMatcher([...]) — calls auth.protect() for /[lang], /[lang]/schedule(.*), /[lang]/workouts(.*), /[lang]/shop(.*), /[lang]/courses(.*), /[lang]/profile(.*), /[lang]/dashboard(.*), /[lang]/onboarding(.*), /[lang]/complete-profile(.*).
  7. Pathname header. Sets x-pathname on 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 than ml-*/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 | Dialog must include a visually hidden Description (DrawerDescription, etc.) for accessibility (see CLAUDE.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 via getTestAuthHeaders() when the E2E bypass is active.
  • On 401 — redirects to a locale-aware auth-reset path (getAuthResetPath(lang)).
  • On 5xx — captures to Sentry with api.path, api.status, requestId tags.
  • 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:

ProviderPurposeSource
PostHogProviderWeb analytics clientapps/web/src/providers/posthog-provider.tsx
QueryClientProviderReact Query — 60s staleTime, 30-min gcTime, keepPreviousData for paginated queriesapps/web/src/providers.tsx
AnalyticsUserSyncPushes Clerk user id/email into Sentry + PostHog when the user changessame file
DictionaryProviderMakes the loaded locale dictionary available to client componentsapps/web/src/providers/dictionary-provider.tsx
UserProviderWraps /users/me + memberships for the current-user hookapps/web/src/providers/user-provider.tsx
OnboardingProviderDrives the onboarding wizard stateapps/web/src/providers/onboarding-provider.tsx
TourProviderIn-app product tourapps/web/src/providers/tour-provider.tsx
AgentProviderSpotter (AI assistant) state + WS streamapps/web/src/providers/agent-provider.tsx
RealtimeProviderSocket.IO client + presenceapps/web/src/providers/realtime-provider.tsx
ReactQueryDevtoolsDev-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

PathPurpose
apps/web/src/lib/api.tsserverFetch, serverFetchGet, getCurrentUserServer
apps/web/src/lib/test-auth-headers.tsBuilds x-test-* headers when E2E test mode is on
apps/web/src/lib/auth-reset.tsMaps a locale to the auth-reset URL used on 401
apps/web/src/lib/query-keys.tsReact Query key factory
apps/web/src/lib/prefetch.tsServer-side React Query prefetching utility
apps/web/src/lib/zod-i18n.tsMaps Zod errors to translated messages
apps/web/src/lib/format-price.tsCurrency formatting (ILS-first)
apps/web/src/lib/date-utils.tsDate 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 in apps/web/e2e/specs/. Drivers in apps/web/e2e/drivers/.
  • Auth caching for E2E. Global setup in apps/web/e2e/global.setup.ts runs a real Clerk sign-in once with E2E_CLERK_USER_EMAIL + E2E_CLERK_USER_PASSWORD, then writes storageState to e2e/.auth/signed-in-state.json. Specs reuse it via Playwright fixtures.
  • See testing/strategy.md and testing/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 3000

apps/web/.env.local carries the Next.js public env (NEXT_PUBLIC_*). Backend env stays in apps/api/.env.