34 KiB
Balinyaar Client — Claude Code Guidelines
The web frontend of Balinyaar, a trust-first home-nursing marketplace in Iran. This file is the
engineering contract for everything under client/: providers, routing, data fetching, theming,
i18n, cookies, and the rules every change must follow.
- Repo-wide context and the backend → root CLAUDE.md.
- Product/domain rules (what to build) →
product/— read the relevant doc before designing a feature; don't infer business rules from code. - Visual/design work (brand palette, tokens, component look-and-feel) → the frontend-designer skill. It is the design contract and defers to this file for engineering rules. Don't restate this file there.
Stack
- Next.js 16 — App Router, Turbopack, React Server Components. Not a static export — the app
relies on server components, middleware, and server-side cookies. (
next.config.mjsonly wires the next-intl plugin +reactStrictMode.) - React 19 + TypeScript (
strict). - MUI v9 (
@mui/material) for components and theming; Emotion underneath (RTL viastylis-plugin-rtl). - next-intl v4 for i18n — locales
fa(default, RTL) anden. - TanStack Query v5 for server state; a small AuthContext (React context + reducer,
src/context/auth/, seeded with server-read auth state) for auth/session state. - notistack for toasts; js-cookie (wrapped) for client cookies.
- Jest + Testing Library for unit tests.
- Quality gates: tsc, ESLint 9 (flat config), Prettier.
Commands
| Task | Command |
|---|---|
| Dev server | npm run dev |
| Production build | npm run build |
| Type-check | npm run type |
| Lint | npm run lint |
| Lint + autofix | npm run lint:fix |
| Type + lint (the gate) | npm run check |
| Format (Prettier) | npm run format |
| Test (watch) | npm test |
| Test (CI, once) | npm run test:ci |
Always run npm run check before declaring work done. Run npm run test:ci as well when you
touch a component that has a co-located *.test.tsx.
Quality gates: lint & type (how they work)
Both gates are plain CLI tools. There is no next lint — it was removed in Next 16; calling it
silently does nothing.
npm run type→tsc --noEmit. Config intsconfig.json:stricton,noEmit,@/*→src/*.npm run lint→eslint .driven by flat config ineslint.config.mjs. That config spreadseslint-config-next(core-web-vitals + typescript + react + react-hooks + jsx-a11y + import) and applieseslint-config-prettierlast so ESLint never fights Prettier on formatting.npm run checkruns type then lint. Keep it green.
Rules for this project:
- This project is flat-config only. Do not add
.eslintrc*files — put any rule changes ineslint.config.mjs. - ESLint owns correctness, Prettier owns formatting. Don't add stylistic ESLint rules.
- No unused variables or imports.
@typescript-eslint/no-unused-varsis raised from eslint-config-next's defaultwarntoerror(ineslint.config.mjs), so dead code failsnpm run check. Delete unused code rather than disabling the rule; prefix a deliberately-unused binding with_(e.g._event,catch (_err)) to opt out. - Prefer fixing code over silencing the linter. When a disable is genuinely correct — e.g. a
deliberate browser-only read after mount that trips
react-hooks/set-state-in-effect— use a scoped// eslint-disable-next-line <rule>with a one-line reason, never a file-wide disable. - Pin to ESLint 9. ESLint 10 currently crashes with this Next 16 toolchain
(
scopeManager.addGlobals is not a function).import/no-cycleis also disabled — its TS resolver has an interface mismatch here (see the note ineslint.config.mjs).
Golden rules (the short list)
A change is "done" only if it respects all of these — each has a full section below.
- Never add a layout above
[locale].src/app/[locale]/layout.tsxis the root layout (it renders<html>/<body>). A layout above it freezeslang/dir/messages on the default locale. - Respect the server/client boundary. Never import
next/headers,next-intl/server, or@/lib/cookies/serverfrom a client component; never import@/lib/cookies/clientfrom an RSC. - No hard-coded UI strings. Every user-visible string is a key in both
messages/en.jsonandmessages/fa.json. - Fetch only through
clientFetch/serverFetch(@/lib/api) — never rawfetch(). Domain calls live insrc/services/{domain}/apis/. - Cookies only through the cookie manager (
@/lib/cookies/*) — neverdocument.cookie,js-cookie,localStorage, orsessionStoragefor app/auth state. - Colors come from
tokens.css(var(--…)), never hard-coded insx. Use the pre-builtAPP_THEME_LTR/APP_THEME_RTL; never callcreateTheme()in a component. - MUI v9 API only. Use
sx={{ mb: 4 }}, notmb={4}as a direct prop. No MUI-v5/v6-only props (useFlexGap,flexWraponStack,storageWindow,InitColorSchemeScript, …). - Shared components get a co-located
*.test.tsx. (A component imported from >1 place.) - Magic strings become named constants (
src/constants/or a co-locatedconstants.ts). npm run checkis green and translations stay in sync before you finish.- No dead code; comment the why, not the what. Unused vars/imports are lint errors — remove them. Don't add comments that restate the code; comment only a non-obvious decision, constraint, or trade-off. See Comments & dead code below.
Project Structure
This section is the canonical description of the client's architecture. When a change adds, removes,
or renames a route group, provider, or top-level src/ folder, update this tree in the same change
(root CLAUDE.md working agreement #7).
client/
├── messages/ # Translation files (add keys to BOTH files)
│ ├── en.json
│ └── fa.json
├── middleware.ts # next-intl routing middleware (locale detection + redirect)
├── next.config.mjs # createNextIntlPlugin wires i18n into Next.js
└── src/
├── app/
│ ├── globals.css
│ ├── fonts/ # Local font files (woff2) — Mikhak for fa
│ └── [locale]/
│ ├── layout.tsx # ROOT RSC: renders <html lang/dir> + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState)
│ ├── (private-routes)/
│ │ ├── layout.tsx # 'use client' — wraps PrivateLayout
│ │ └── page.tsx
│ └── (public-routes)/
│ └── layout.tsx # 'use client' — wraps PublicLayout
├── components/ # Shared UI components (each with .test.tsx if imported >1 place)
├── i18n/
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
│ └── request.ts # getRequestConfig — loads messages/${locale}.json
├── layout/
│ ├── PrivateLayout.tsx # 'use client' — authenticated shell; uses useTranslations('nav')
│ ├── PublicLayout.tsx # unauthenticated shell
│ ├── TopBarAndSideBarLayout.tsx # 'use client' — TopBar + SideBar composition
│ ├── config.ts
│ ├── index.ts
│ └── components/
│ ├── TopBar.tsx
│ ├── SideBar.tsx
│ ├── SideBarNavList.tsx
│ ├── SideBarNavItem.tsx
│ ├── DarkModeButton.tsx # 'use client' — only subscriber to useColorScheme()
│ └── index.tsx
├── lib/
│ ├── api/
│ │ ├── client.ts # clientFetch<T> — throws ApiError on error; use in hooks/client components
│ │ ├── server.ts # serverFetch<T> — throws ApiError on error; use in RSCs/Server Actions
│ │ └── errors.ts # ApiError class (status, message, code)
│ ├── auth/
│ │ ├── token.ts # decodeJwtPayload / isTokenAlive — edge-safe, shared with middleware (no next/headers)
│ │ └── server.ts # getServerAuthState — access-token cookie → AuthState for AuthProvider
│ ├── query/
│ │ ├── queryClient.ts # makeQueryClient factory + getQueryClient() SSR-safe singleton
│ │ └── QueryProvider.tsx # 'use client' — QueryClientProvider + ReactQueryDevtools
│ └── cookies/ # Cookie manager — strict server/client separation
│ ├── constants.ts # COOKIE_NAMES, CookieOptions, AUTH_*_COOKIE_OPTIONS
│ ├── server.ts # getServerCookie, getThemeMode, setServerCookie
│ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie
│ └── index.ts # Re-exports constants ONLY (never server/client)
├── services/ # Domain services — no top-level barrel; import directly from the file
│ └── {domain}/
│ ├── types.ts # Request/response types for this domain
│ ├── keys.ts # React Query key factory
│ ├── apis/
│ │ ├── clientApi.ts # Namespace object wrapping clientFetch calls
│ │ └── serverApi.ts # Namespace object wrapping serverFetch calls (only when needed)
│ └── hooks/
│ └── use{Action}.ts # One hook per file — useQuery or useMutation
├── context/ # React context providers
│ └── auth/ # AuthContext — AuthProvider (server-seeded) + reducer + useAuth
├── theme/
│ ├── ThemeProvider.tsx # MuiThemeProvider wrapper + ColorSchemeScript + ColorSchemeCookieSync
│ ├── colors.ts # BRAND, LIGHT_PALETTE, DARK_PALETTE
│ ├── light.ts / dark.ts # LIGHT_THEME / DARK_THEME ThemeOptions (consumed by theme.ts)
│ ├── direction.ts # getDirection(locale) → 'ltr' | 'rtl'
│ ├── theme.ts # APP_THEME_LTR / APP_THEME_RTL (static, created once)
│ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors
│ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak)
│ └── index.ts # Public re-exports (incl. ColorSchemeScript, ThemeProvider, getDirection)
├── constants/ # App-wide constants (routes, events, etc.)
├── hooks/
├── utils/
└── config.ts
Server / Client Component Boundaries
There is NO src/app/layout.tsx. src/app/[locale]/layout.tsx is the application's root layout — it renders <html> and <body>. This is intentional and load-bearing (see below); do not re-introduce a layout above the [locale] segment.
Root / locale layout (src/app/[locale]/layout.tsx) is an RSC that owns the document shell, all i18n, and theme context. It:
- Sources the locale from the URL param (
params.locale), validated againstrouting.locales(falls back todefaultLocale). No header reads. - Renders
<html lang dir>(dirfromgetDirection(locale)) plusdata-mui-color-schemefromgetThemeMode(). - Loads the Mikhak font and attaches its CSS-variable class to
<html>only forfa(see Fonts). - Calls
setRequestLocale(locale)so server components deeper in the tree can callgetLocale()/getTranslations()reliably. - Calls
getMessages({ locale })with the locale passed explicitly sogetRequestConfigreceives it viaPromise.resolve(locale)(not through the React.cache read), avoiding any cache-ordering race. - Wraps children with
NextIntlClientProvider,AuthProvider(seeded with server-read auth state), andThemeProvider. - Exports
generateStaticParamsso Next.js can enumerate locale routes at build time.
WHY <html> MUST live in [locale]/layout.tsx and not a layout above it: a layout above the [locale] segment 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 (the segment doesn't change). Its lang/dir/messages 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 where <html lang dir> reliably tracks the active locale.
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 import from next/headers, next-intl/server, or @/lib/cookies/server in a client component. The build will fail.
i18n (next-intl v4)
Adding translations:
- Add the key to
messages/en.jsonANDmessages/fa.json. Both files must always be in sync. - Top-level keys are namespaces:
"nav","common", etc.
Using translations in client components:
import { useTranslations } from 'next-intl';
function MyComponent() {
const t = useTranslations('nav'); // namespace
return <span>{t('home')}</span>; // key
}
Using translations in Server Components:
import { getTranslations } from 'next-intl/server';
async function MyServerComponent() {
const t = await getTranslations('nav');
return <span>{t('home')}</span>;
}
Established namespaces and where they're used:
'nav'—PrivateLayout.tsx(sidebar nav items)'common'—DarkModeButton.tsx(dark/light mode labels)
Never hard-code UI strings in English. Any user-visible text must have a translation key in both locale files.
Cookie Manager
The cookie manager in src/lib/cookies/ is split into three files to prevent cross-environment bundling:
| File | Use from | Purpose |
|---|---|---|
constants.ts |
anywhere | COOKIE_NAMES, CookieOptions, COLOR_SCHEME_COOKIE_OPTIONS |
server.ts |
Server Components, Server Actions, Route Handlers only | getServerCookie, getThemeMode, setServerCookie |
client.ts |
client components / useEffect only |
getClientCookie, setClientCookie, deleteClientCookie |
index.ts |
anywhere | Re-exports constants.ts only — safe barrel |
Rules:
- Import constants via the barrel:
import { COOKIE_NAMES } from '@/lib/cookies' - Import server utils directly:
import { getThemeMode } from '@/lib/cookies/server' - Import client utils directly:
import { setClientCookie } from '@/lib/cookies/client' - Never import
server.tsin a client component; never importclient.tsin an RSC. COOKIE_NAMES.COLOR_SCHEME = 'color-scheme'— the single source of truth for the theme cookie name. Do not redeclare it anywhere.
Constants
Rule: every magic string or configurable value must be a named constant — never inline.
A value is "magic" if its meaning isn't obvious from the literal alone: cookie names, event names, localStorage keys, route paths, query-param names, numeric timeouts, API endpoint slugs.
Where to define:
- Cookie names / options:
src/lib/cookies/constants.ts - Feature-scope constants: co-locate in a
constants.tsnext to that feature's files - App-wide constants (used across multiple features):
src/constants/— one file per concern (routes.ts,events.ts, etc.)
Rules:
- Import the constant; never copy-paste the string value.
- When renaming, update the constant definition — the rest of the codebase follows automatically.
Theme System
How it works (end-to-end, no-flash)
- Request arrives →
getThemeMode()reads'color-scheme'cookie → returns{ colorScheme, defaultMode } - Root layout sets
data-mui-color-scheme={colorScheme}on<html>server-side <ColorSchemeScript />in<head>runs before any paint:- Reads the same cookie, sets
data-mui-color-scheme(handles edge cases where server attr might differ) - Patches
Storage.prototype— routes MUI'slocalStoragewrites for key'mode'to our cookie; reads returnnullso MUI always trusts thedefaultModeprop
- Reads the same cookie, sets
<MuiThemeProvider defaultMode={defaultMode}>mounts — uses the server-derived mode, not localStorageColorSchemeCookieSyncin ThemeProvider writes the cookie viauseColorScheme().colorSchemeon mount (safety net for first-visit system mode)
Critical MUI v9 rules
colorSchemeSelector must be the explicit attribute name:
// theme.ts
cssVariables: {
colorSchemeSelector: 'data-mui-color-scheme', // CORRECT
// colorSchemeSelector: 'data', // WRONG — produces boolean data-dark/data-light
},
The shorthand 'data' in MUI v9 generates [data-%s] → data-dark="" / data-light="" (boolean attributes). Our tokens.css uses [data-mui-color-scheme="dark"] which never matches boolean attributes. Always use the explicit attribute name.
Never use storageWindow={null}:
In MUI v9's localStorageManager, the check is if (!storageWindow && typeof window !== 'undefined') — null is falsy, so it silently overrides to window. This prop is a no-op in browsers. The Storage.prototype patch in ColorSchemeScript is the correct intercept.
Never use MUI's InitColorSchemeScript:
It reads from localStorage which diverges from our cookie (especially in 'system' mode). Use ColorSchemeScript from @/theme instead.
MUI v9 localStorage key defaults (different from v5/v6):
- Mode key:
'mode'(was'mui-mode') - Color scheme key:
'color-scheme'(was'mui-color-scheme') - HTML attribute:
'data-color-scheme'(was'data-mui-color-scheme')
We override all of these via colorSchemeSelector: 'data-mui-color-scheme' in the theme and the Storage.prototype patch.
Color tokens
All theme-aware colors live in src/theme/tokens.css under [data-mui-color-scheme] selectors. Do not add color values to inline sx props or component styles — add a CSS variable to tokens.css and reference it via var(--my-token).
This includes feedback colors: --bal-success, --bal-error, --bal-warning, --bal-info (each with a *-contrast text token). These drive the toast variants (see Toast Notifications) and are the place to source any success/error/warning/info color — the MUI palette does not define semantic colors, so prefer these tokens over MUI's defaults for brand consistency.
Pre-built theme objects
APP_THEME_LTR and APP_THEME_RTL are created once at module load. Never call createTheme() inside a component or hook — pass the appropriate pre-built theme to MuiThemeProvider.
Toggle components
DarkModeToggleButton and DarkModeFormSwitch in src/layout/components/DarkModeButton.tsx are the only components that subscribe to useColorScheme(). When the user toggles:
setMode('dark')is calledStorage.prototype.setItemintercept fires → writes'color-scheme'='dark'cookie synchronously- MUI sets
data-mui-color-scheme="dark"on<html> - CSS variables resolve → browser repaints. No React re-render above the button.
Use colorScheme (not mode) for the isDark check — mode can be 'system' even when dark is active.
Direction (RTL / LTR)
Derived from locale via getDirection(locale) in src/theme/direction.ts:
- RTL locales:
fa,ar,he,ur - All others:
ltr
ThemeProvider accepts a dir prop and selects the matching pre-built theme (APP_THEME_RTL for RTL). The RTL Emotion cache uses stylis-plugin-rtl to mirror all generated CSS.
src/app/[locale]/layout.tsx sets dir={dir} on <html> and passes dir to ThemeProvider. Because that layout is keyed on the [locale] URL param, changing locale re-renders it with a fresh dir — on both hard and soft navigation, no client-side state. Do not move the <html dir> render to a layout above [locale]; such a layout is shared across locales, gets statically cached with the default locale, and dir freezes on 'rtl' for /en.
Default locale is fa (RTL). The middleware redirects bare / to /fa/. English is explicitly accessed at /en/.
Fonts
Fonts are loaded per locale — the Persian face is never shipped to English pages:
| Locale | Font | CSS variable | Source | Loaded when |
|---|---|---|---|---|
fa (RTL) |
Mikhak | --font-mikhak |
next/font/local — woff2 files in src/app/fonts/ |
only on fa routes |
en (LTR) |
Space Grotesk | --font-space-grotesk |
(not currently wired — falls back to the system stack) | — |
Typography exports:
TYPOGRAPHY_LTR— Space Grotesk headings, system font body (used byAPP_THEME_LTR)TYPOGRAPHY_RTL— Mikhak for all text including body (used byAPP_THEME_RTL, ensures full Persian glyph coverage)TYPOGRAPHY— alias forTYPOGRAPHY_LTR(deprecated, prefer the explicit exports)
Rules:
- Mikhak is declared with
preload: false, and its.variableclass is attached to<html>only whenlocale === 'fa'. Both are required: anext/fontloader called in the root layout would otherwise preload on every route (including/en), andpreload: falseensures the woff2 only downloads when Persian text actually renders. - Font files live in
src/app/fonts/(notpublic/). next/font/local resolves paths relative to the calling file (src/app/[locale]/layout.tsx) at build time. - Never load fonts inside components — all font loading lives in
src/app/[locale]/layout.tsx. - To add a new font, add woff2 files to
src/app/fonts/, declare vialocalFont/localFont-equivalent insrc/app/[locale]/layout.tsx, attach its.variableclass conditionally on the matching locale, and updateBRAND_FONT_VARIABLE_*constants intypography.ts.
Unit Testing
Rule: every shared component must have a co-located test file.
A component is "shared" if it is imported from more than one place (page, layout, or other component).
Coverage baseline for shared components:
- It renders without crashing.
- Every documented prop produces the correct HTML attribute or CSS class.
- User interactions (click, change) call the expected callbacks.
Test location: src/components/ComponentName/ComponentName.test.tsx next to the component.
Test wrapper: wrap with <ThemeProvider> if the component uses MUI theming.
Do NOT mock MUI components — test against the rendered DOM.
Enforcement: before removing or renaming a shared component, check whether src/**/*.test.{ts,tsx} files import it. If so, update or delete those tests too.
Comments & dead code
- No dead code. Unused variables, imports, parameters, and private members are lint errors
(
@typescript-eslint/no-unused-vars, raised toerror— see Quality gates). Delete them; don't comment them out and don't silence the rule. Prefix a deliberately-unused binding with_to opt out. - Comment the why, never the what. Code should read for itself — a comment that restates what
the code already says is noise. Don't write
// set the access tokenabovesetClientCookie(...), or JSDoc that just echoes a function's name. - Do add a tight comment when a decision is genuinely non-obvious from the code: a workaround for a
framework quirk, a business rule, an ordering or security constraint, a deliberate deviation. Explain
why it is this way. The comments in
src/app/[locale]/layout.tsx(why<html>lives in the[locale]layout) andsrc/lib/auth/token.ts(why the JWTexpcheck is UX-only, never a security boundary) are the model to follow. - Prefer a clearer name or a small helper over a comment whenever that removes the need for it.
Anti-patterns (do not do these)
- Do not read
localStorageordocument.cookiein render functions — useuseEffector server-sidecookies()fromnext/headers. - Do not call
createTheme()inside a component or hook — useAPP_THEME_LTR/APP_THEME_RTL. - Do not use
storageWindow={null}onMuiThemeProvider— it is silently ignored in MUI v9. - Do not use
InitColorSchemeScriptfrom MUI — useColorSchemeScriptfrom@/theme. - Do not set
colorSchemeSelector: 'data'— use'data-mui-color-scheme'. - Do not check
mode === 'dark'for "is dark active" — usecolorScheme === 'dark'. - Do not hard-code UI strings — add translation keys to both
messages/en.jsonandmessages/fa.json. - Do not add a
src/app/layout.tsxor any layout above the[locale]segment. Such a layout is shared across locales, gets statically cached at build time withdefaultLocale('fa'), and never re-renders on a locale switch — so<html lang/dir>, messages, providers, and fonts placed there freeze on 'fa'/'rtl' for/en.src/app/[locale]/layout.tsxis the root layout (it renders<html>/<body>) precisely because it is the lowest boundary keyed on the locale param. - Do not call
getMessages()without passing{ locale }explicitly —getMessages({ locale })passes the locale directly togetRequestConfigviaPromise.resolve(locale), bypassing potential React.cache ordering issues. - Do not remove
setRequestLocale(locale)fromsrc/app/[locale]/layout.tsx— without it,getLocale()called by deeper server components always returnsdefaultLocale. - Do not add
notFound()tosrc/app/[locale]/layout.tsx— unknown locale URLs are handled by middleware (redirect to defaultLocale); a hard 404 here breaks fallback behavior. - Do not import
TYPOGRAPHY— useTYPOGRAPHY_LTRorTYPOGRAPHY_RTLexplicitly. - Do not load fonts inside components or pages — all next/font declarations belong in
src/app/[locale]/layout.tsx, with the.variableclass attached conditionally per locale (Mikhak only forfa). - Do not import
@/lib/cookies/serverin client components or@/lib/cookies/clientin RSCs. - Do not call
fetch()directly in components or services — useserverFetch(RSC/Server Actions) orclientFetch(hooks/Client Components) from@/lib/api. - Do not create a top-level barrel at
src/services/index.ts— imports should make the domain origin clear (e.g.import { useLogin } from '@/services/auth', notimport { useLogin } from '@/services'). - Each domain does have an
index.tsthat re-exports its hooks (e.g.src/services/auth/index.ts). Do not exporttypes,keys, orapis/*from this barrel — only hooks. - Do not mix
clientFetchandserverFetchin the same file — keepclientApi.tsandserverApi.tsseparate; Next.js enforces the environment boundary at build time. - Do not toast inside hooks for 401/403/5xx — those are already toasted by
clientFetch. Only toast inonErrorfor domain-specific 4xx messages. - Do not call
js-cookie(Cookies.*) directly — use the central client cookie manager (@/lib/cookies/client). - Do not read or write
document.cookiedirectly — use the central client cookie manager. - Do not store auth tokens in
sessionStorageorlocalStorage— use cookies via@/lib/cookies/client. - Do not pass
flexWraporuseFlexGapas direct props to MUIStack— these are not valid Stack props in MUI v9 and cause a TypeScript overload error. Usesx={{ flexWrap: 'wrap' }}instead.useFlexGapwas a MUI v5 opt-in and does not exist in v9. - Do not use mui old api which cause errors
API Fetch Services
Central fetch primitives live in src/lib/api/:
| File | Use from | Purpose |
|---|---|---|
client.ts |
hooks, client components | clientFetch<T> — throws ApiError on error |
server.ts |
RSCs, Server Actions only | serverFetch<T> — throws ApiError on error |
errors.ts |
anywhere | ApiError class (status, message, code) |
Error contract — clientFetch:
- 401 — toast "session expired", clear cookies, redirect to login (no throw; page navigates away)
- 403 — toast "forbidden", throw
ApiError - 5xx — toast "server error", throw
ApiError - Other 4xx — throw
ApiError, no toast; the calling hook owns the user-facing message - Network failure — toast "network error", throw
ApiError
Error contract — serverFetch:
- All errors throw
ApiError(no toast — server can't fire browser events) - RSC callers decide whether to
notFound(),redirect(), or let the error propagate to an error boundary
Domain API calls live in src/services/{domain}/apis/clientApi.ts (or serverApi.ts). Never call raw fetch() directly.
Auth Cookies & session state
| Cookie | Constant | TTL | Set by |
|---|---|---|---|
access_token |
COOKIE_NAMES.ACCESS_TOKEN |
15 min | useLogin() in src/services/auth/hooks/useLogin.ts |
refresh_token |
COOKIE_NAMES.REFRESH_TOKEN |
7 days | useLogin() in src/services/auth/hooks/useLogin.ts |
Session state lives in AuthContext (src/context/auth/). The root layout resolves the session on
the server with getServerAuthState() (src/lib/auth/server.ts) — which reads the access_token cookie
and checks the JWT exp via the shared isTokenAlive (src/lib/auth/token.ts) — and passes it to
<AuthProvider initialState={…}>. So the first render already knows whether the user is
authenticated: no logged-out flash, no post-mount cookie read.
Lifecycle:
- Written after a successful login via
setClientCookie(useLogin), which also dispatchesLOG_INto keepAuthContextin sync on the client without a reload. - Deleted by
useLogout()(src/services/auth/hooks/useLogout.ts) — the single logout path: it calls the API, clears both cookies, dispatchesLOG_OUT, and redirects — and automatically byclientFetchon 401. - Read on the server by
serverFetch/getServerAuthStateviagetServerCookie. - Read on the client by
clientFetchviagetClientCookie(to attachAuthorization: Bearer).
useIsAuthenticated() (src/hooks/auth.ts) reads the server-seeded AuthContext, so it is correct
on the first paint (it no longer reads the cookie after mount).
Middleware (middleware.ts) gates private routes with the same isTokenAlive helper before render.
Security posture — current limits and best-practice follow-ups. The flow above is the intended client design, but the auth model has known gaps that need server coordination to close. Don't silently "fix" them client-only:
- Tokens are non-httpOnly cookies (JS-readable) so
clientFetchcan attach the bearer header — this trades XSS-hardening for the bearer pattern. Real hardening (httpOnly cookies set by the server + a same-origin proxy) spans the server. - The middleware check is UX-only, not a security boundary: it decodes the JWT and checks
expbut does not verify the signature. The API is the only authority; never gate real authorization on the middleware orisTokenAlive. - No refresh-token rotation is implemented — the
refresh_tokencookie is set/cleared but never exchanged; access expiry just forces re-login on the next 401. - No role/permission model yet (
Useris{ id, username }); authorization is binary. Add roles toUser/AuthStateand the server before gating UI by permission.
Toast Notifications (notistack)
<SnackbarProvider> wraps all children inside ThemeProvider in src/app/[locale]/layout.tsx.
In React components/hooks — use notistack directly:
import { useSnackbar } from 'notistack'
const { enqueueSnackbar } = useSnackbar()
enqueueSnackbar('Saved!', { variant: 'success' })
Outside React (plain functions, fetch services) — use the event bridge:
import { dispatchToast } from '@/lib/toast'
dispatchToast('Something went wrong', 'error')
dispatchToast fires a window CustomEvent (app:toast). ToastBridge (a zero-UI 'use client' component inside SnackbarProvider) listens and calls enqueueSnackbar.
ToastBridge is already rendered in [locale]/layout.tsx — do not add another instance.
Toast colors follow the theme. NotistackProvider maps every notistack variant to a styled(MaterialDesignContent) whose backgroundColor/color come from the --bal-{success,error,warning,info} (+ *-contrast) tokens in tokens.css. Because those tokens are defined on <html>, they cascade into notistack's Portal and switch with the color scheme automatically. Never hard-code a toast color — adjust the tokens instead.
Direction is inherited, not passed. notistack's Portal mounts under <body>, so it inherits dir from <html dir> (set per-locale in the root layout). Do not pass a dir prop to SnackbarProvider — it is not a valid prop (TS error) and is unnecessary:
<NotistackProvider>{children}</NotistackProvider>
Route Constants
Named path constants live in src/constants/routes.ts:
ROUTES.LOGIN = '/login'
ROUTES.HOME = '/'
PUBLIC_PATHS = [ROUTES.LOGIN, ...] // paths that bypass middleware auth check
Import from the barrel: import { ROUTES, PUBLIC_PATHS } from '@/constants'.
To add a new public route, append it to PUBLIC_PATHS — the middleware picks it up automatically.
Client Cookie Manager (js-cookie)
src/lib/cookies/client.ts uses js-cookie internally. The exported API is unchanged:
| Function | Purpose |
|---|---|
getClientCookie(name) |
Read a cookie by name |
setClientCookie(name, value, options?) |
Write a cookie; options is CookieOptions with maxAge in seconds |
deleteClientCookie(name, path?) |
Delete a cookie |
getColorSchemeCookie() |
Typed helper for the theme cookie |
CookieOptions type is defined in src/lib/cookies/constants.ts — maxAge is in seconds (converted to expires: Date internally when calling js-cookie).
- Do not
document.title = titlein the render body of any component — it causesReferenceError: document is not definedduring build-time prerendering.