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 againstrouting.localeswith a fallback todefaultLocale. No header reads. <html lang dir>—dirfromgetDirection(locale)— plusdata-mui-color-schemefromgetThemeMode().- The per-locale font class (Mikhak on
faonly — see theme.md). setRequestLocale(locale), so server components deeper in the tree can callgetLocale()/getTranslations()reliably. Never remove it — without it, deeper RSCs always seedefaultLocale.getMessages({ locale })with the locale passed explicitly, sogetRequestConfigreceives it viaPromise.resolve(locale)rather than through theReact.cacheread — which avoids a cache-ordering race. Never callgetMessages()bare.- The providers:
NextIntlClientProvider,AuthProvider(seeded withgetServerAuthState()),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, andmetadataBase: new URL(SITE_URL)so child pages' relative OG/canonical URLs resolve absolute.SITE_URLcomes fromsrc/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, nurse → NurseLayout, admin → AdminLayout, partner →
PartnerLayout. (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.tsxand named<PageName>Screen.tsx, so its existing relative imports keep working unchanged. page.tsxnever renders<title>and never touchesdocument.title. Assigningdocument.titlein a render body throwsReferenceError: document is not definedduring build-time prerendering.- A static
metadataexport 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.