Files
baya-monorepo/docs/rules/client/structure.md
T
2026-07-30 02:26:52 +03:30

9.2 KiB

Client structure

The route tree, the server/client boundary, and the page pattern every screen follows.

Last verified: 2026-07-30 against commit d3ec723.


1. src/ at a glance

Folder Holds
app/ The App Router tree. Everything under [locale]/
components/ Shared UI — common/ primitives plus one folder per domain composite family
constants/ App-wide named constants (routes.ts, roles.ts, headers.ts, policy.ts)
context/ React context providers — auth/ (AuthContext + reducer)
hooks/ Cross-cutting hooks (auth.ts, capabilities.ts, layout.ts, useAdminListState.ts)
i18n/ next-intl wiring: routing.ts, request.ts, navigation.ts
layout/ The one mobile app shell: AppFrame, MobileShell, the four actor layouts, chrome components
lib/ Infrastructure: api/ (fetch), auth/ (token/session), cookies/, query/, toast/
services/ 22 domain services, one folder each — the data layer
theme/ Palette, tokens, typography, the pre-built themes
utils/ Money, dates, numbers, CSV, text helpers

Plus messages/{en,fa}.json, middleware.ts, and next.config.mjs (which only wires the next-intl plugin and reactStrictMode) outside src/.

This is a pattern map, not a file listing. git ls-files client/src enumerates files for free and never goes stale; what follows is the shape those files have to fit.


2. The one absolute rule: no layout above [locale]

src/app/[locale]/layout.tsx IS the root layout. It renders <html> and <body>. There is no src/app/layout.tsx, and adding one — or any layout above the [locale] segment — breaks both locales.

Why, because this is worth understanding rather than obeying: a layout above [locale] is shared between /fa and /en. Next.js statically caches it at build time with defaultLocale (fa) and never re-renders it on a client-side locale switch, because the segment it is keyed on doesn't change. Its lang, dir, messages, providers, and fonts therefore freeze on fa/rtl for every route, including /en. The [locale] layout is the lowest boundary keyed on the locale param, so it is the only place <html lang dir> can reliably track the active locale.

What the root layout owns

  • The locale, sourced from the URL param (params.locale), validated against routing.locales with a fallback to defaultLocale. No header reads.
  • <html lang dir>dir from getDirection(locale) — plus data-mui-color-scheme from getThemeMode().
  • The per-locale font class (Mikhak on fa only — see theme.md).
  • setRequestLocale(locale), so server components deeper in the tree can call getLocale() / getTranslations() reliably. Never remove it — without it, deeper RSCs always see defaultLocale.
  • getMessages({ locale }) with the locale passed explicitly, so getRequestConfig receives it via Promise.resolve(locale) rather than through the React.cache read — which avoids a cache-ordering race. Never call getMessages() bare.
  • The providers: NextIntlClientProvider, AuthProvider (seeded with getServerAuthState()), ThemeProvider, NotistackProvider + ToastBridge.
  • generateStaticParams, so Next can enumerate locale routes at build time.
  • generateMetadata — the '%s | بالین‌یار' / '%s | Balinyaar' title template, the default title and description, and metadataBase: new URL(SITE_URL) so child pages' relative OG/canonical URLs resolve absolute. SITE_URL comes from src/config.ts, never a hard-coded origin.

Never add notFound() to the [locale] layout. Unknown locales are handled by middleware; a hard 404 there breaks the fallback.

The two files that legitimately sit above [locale]

File Why it's allowed
app/global-error.tsx It replaces the root layout on a root-level crash, so it renders its own <html> — which means it cannot use next-intl. It is the one sanctioned static-string exception: keep it minimal and bilingual (fa + en)
app/robots.ts, app/sitemap.ts Route handlers, not layouts. They enumerate the public surface across both locales

3. The server/client boundary

Never import From
next/headers a client component
next-intl/server a client component
@/lib/cookies/server a client component
@/lib/cookies/client an RSC

The build fails on the first three. The fourth fails at runtime, quietly, which is worse.

Route-group layouts ((private-routes)/layout.tsx, (public-routes)/layout.tsx) are 'use client' — they only wrap a layout component and need no server capabilities.

Never mix clientFetch and serverFetch in the same file; keep clientApi.ts and serverApi.ts separate. Next enforces the environment boundary at build time.


4. The route tree, by shape

Everything lives under src/app/[locale]/. Route groups add no URL segment.

[locale]/
├── layout.tsx  error.tsx  not-found.tsx  [...rest]/page.tsx
├── (private-routes)/          layout.tsx mounts useSessionRoleSync
│   ├── _chrome/               shared loading skeleton (private, not a route)
│   ├── select-role/           first-use role picker, own FocusedLayout
│   ├── (customer)/            the family app — no URL segment
│   ├── (customer-focused)/    chrome-free counterpart, same URL space (onboarding)
│   ├── nurse/                 the nurse app
│   ├── admin/                 the backoffice
│   └── partner/               the partner-centre portal — a SEPARATE authz scope
└── (public-routes)/           login · terms · privacy · welcome
Convention Meaning
(parenthesised) A route group. Adds no URL segment; exists to attach a layout and a RoleGuard
_-prefixed folder Private, not a route_chrome/, admin/_hub/
[...rest]/page.tsx The catch-all. Calls notFound() so any unmatched path under a locale renders not-found.tsx — next-intl's recommended 404 pattern
loading.tsx A route-group skeleton shaped like that group's content area. The MobileShell chrome is already rendered by the enclosing layout, so a skeleton shapes the content only

Each private group's layout.tsx is 'use client' and wraps RoleGuard → that actor's layout: (customer)CustomerLayout, nurseNurseLayout, adminAdminLayout, partnerPartnerLayout. (customer-focused) and select-role wrap FocusedLayout instead, for flows a user must not be able to tab away from mid-setup.

The partner portal is a separate authorization scope: a centre admin is not a Balinyaar admin. Its RoleGuard passes no expected role (it isn't an AppRole) and each page resolves the caller's own centre via useMyPartnerCenter. See auth.md.

When you add, remove, or rename a route group, a provider, or a top-level src/ folder, update the "Project structure" section in client/CLAUDE.md and §1 above in the same change.


5. The page pattern

The root layout owns a title template; a route supplies the %s. So a route that wants its own tab title splits in two:

// page.tsx — a thin RSC. No 'use client'.
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import HomeScreen from './HomeScreen';

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'shell' });
  return { title: t('customer_app') };
}

export default function Page() {
  return <HomeScreen />;
}
// HomeScreen.tsx — 'use client'. All the logic and JSX.

Rules that fall out of it:

  • The screen component is co-located with page.tsx and named <PageName>Screen.tsx, so its existing relative imports keep working unchanged.
  • page.tsx never renders <title> and never touches document.title. Assigning document.title in a render body throws ReferenceError: document is not defined during build-time prerendering.
  • A static metadata export is fine when the title needs no translation lookup.
  • Page bodies stay composition + content. Reusable visuals move to src/components/; page-only, never-reused composition can stay in the page.

Adoption is partial by design: the landing pages (customer home, /login, /search, /bookings, /nurse, /admin, /partner) plus /welcome use it. The rest still render directly and gain it when the page is next substantially touched.

metadataBase makes the pattern extend to OG: /welcome sets alternates.canonical + openGraph in its generateMetadata and supplies og:image from a co-located opengraph-image.tsx (next/og's ImageResponse).


6. This is not a static export

The app relies on server components, middleware, and server-side cookies. next.config.mjs wires the next-intl plugin and reactStrictMode and nothing else — don't add output: 'export', and don't assume a page can be prerendered without its request context.