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.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. - Fonts (Manrope, Heebo, Rubik, DM Mono, Oswald) are self-hosted: woff2 files in
apps/web/src/fonts/, loaded throughnext/font/localin the generatedapps/web/src/lib/fonts.ts, and exposed as--font-*variables on<body>.next buildnever fetches fonts. It usednext/font/googleuntil 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 byunicode-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, editFAMILIESinscripts/web-fonts.mjs, runnode 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 | 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 (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.tsx | apps/web/src/app/[lang]/(protected)/onboarding/onboarding-content.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 @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
| 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.tssigns in once with a single-use Clerk sign-in token minted forE2E_CLERK_USER_ID, 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.
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’sorganizationgroup. 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.shbuilds it with the key empty), and any preview or staging deploy that leaves the key unset.posthog-provider.tsxinitialises posthog-js at module scope with a dummy project andadvanced_disable_decide, capture opted out and memory-only persistence, so nothing leaves the browser and nothing is stored, and bootstraps the flags fromNEXT_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 restartingnext 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 3000apps/web/.env.local carries the Next.js public env (NEXT_PUBLIC_*). Backend env stays in apps/api/.env.