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 │ │ ├── kiosk/[sessionId]/ staff-only door check-in kiosk (QR + roster) │ │ └── dashboard/ operator dashboard (overview, members, plans, ...) │ ├── home/ member home │ ├── buy/ public purchase landing pages │ └── auth/reset/ auth-error recovery ├── 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.
  • Fonts (Manrope, Heebo, Rubik, DM Mono, Oswald) are self-hosted: woff2 files in apps/web/src/fonts/, loaded through next/font/local in the generated apps/web/src/lib/fonts.ts, and exposed as --font-* variables on <body>. next build never fetches fonts. It used next/font/google until 2026-10-19, and builds failed whenever Google answered with a shape Turbopack rejects (vercel/next.js#99114). Each subset is its own family limited by unicode-range, so a page downloads only the subsets its text uses. Each stack ends with a metric-matched Arial fallback (fonts/fallbacks.css). To change a family or its weights, edit FAMILIES in scripts/web-fonts.mjs, run node scripts/web-fonts.mjs, and review the pages. The display font Clash Grotesk still loads from fontshare.com at runtime.
  • 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
OnboardingProvider (OnboardJS)Holds the setup sheet’s answers with local persistence; mounted only on /onboarding. The PostHog plugin and captureOnboardingEvent live in apps/web/src/providers/onboarding-provider.tsxapps/web/src/app/[lang]/(protected)/onboarding/onboarding-content.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 @taikan/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 signs in once with a single-use Clerk sign-in token minted for E2E_CLERK_USER_ID, 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.

Feature flags

useFeatureFlag reads posthog-js; per-org rollout lives in PostHog (repo policy: AGENTS.md § Feature flags). Where the flags come from depends on the build:

  • A production build with NEXT_PUBLIC_POSTHOG_KEY (production deploys): PostHog alone, evaluated for the active org’s organization group. Capture waits for cookie consent.
  • next dev, with or without a key, and a production build without one: the production build CI’s Playwright suite serves (scripts/test-e2e-web.sh builds it with the key empty), and any preview or staging deploy that leaves the key unset. posthog-provider.tsx initialises posthog-js at module scope with a dummy project and advanced_disable_decide, capture opted out and memory-only persistence, so nothing leaves the browser and nothing is stored, and bootstraps the flags from NEXT_PUBLIC_FEATURE_FLAGS (key:true,key2:false). Next inlines that variable at build time: every flag it lists is on for every org and every user of that build, and changing it means rebuilding (or restarting next dev).

The keyless flags only decide what the web shows. The API decides from its own flags (PostHog, or its FEATURE_FLAGS when it has no PostHog client) and still refuses what a flag-on screen asks of a flag-off API (a booking-rule save answers 403 feature_disabled). A keyless deploy anyone outside the team can reach should list no flag that touches money or access in NEXT_PUBLIC_FEATURE_FLAGS.

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.