Files
baya-monorepo/client/CLAUDE.md
T
2026-06-23 23:36:19 +03:30

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.mjs only wires the next-intl plugin + reactStrictMode.)
  • React 19 + TypeScript (strict).
  • MUI v9 (@mui/material) for components and theming; Emotion underneath (RTL via stylis-plugin-rtl).
  • next-intl v4 for i18n — locales fa (default, RTL) and en.
  • 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 typetsc --noEmit. Config in tsconfig.json: strict on, noEmit, @/*src/*.
  • npm run linteslint . driven by flat config in eslint.config.mjs. That config spreads eslint-config-next (core-web-vitals + typescript + react + react-hooks + jsx-a11y + import) and applies eslint-config-prettier last so ESLint never fights Prettier on formatting.
  • npm run check runs 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 in eslint.config.mjs.
  • ESLint owns correctness, Prettier owns formatting. Don't add stylistic ESLint rules.
  • No unused variables or imports. @typescript-eslint/no-unused-vars is raised from eslint-config-next's default warn to error (in eslint.config.mjs), so dead code fails npm 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-cycle is also disabled — its TS resolver has an interface mismatch here (see the note in eslint.config.mjs).

Golden rules (the short list)

A change is "done" only if it respects all of these — each has a full section below.

  1. Never add a layout above [locale]. src/app/[locale]/layout.tsx is the root layout (it renders <html>/<body>). A layout above it freezes lang/dir/messages on the default locale.
  2. Respect the server/client boundary. Never import next/headers, next-intl/server, or @/lib/cookies/server from a client component; never import @/lib/cookies/client from an RSC.
  3. No hard-coded UI strings. Every user-visible string is a key in both messages/en.json and messages/fa.json.
  4. Fetch only through clientFetch/serverFetch (@/lib/api) — never raw fetch(). Domain calls live in src/services/{domain}/apis/.
  5. Cookies only through the cookie manager (@/lib/cookies/*) — never document.cookie, js-cookie, localStorage, or sessionStorage for app/auth state.
  6. Colors come from tokens.css (var(--…)), never hard-coded in sx. Use the pre-built APP_THEME_LTR/APP_THEME_RTL; never call createTheme() in a component.
  7. MUI v9 API only. Use sx={{ mb: 4 }}, not mb={4} as a direct prop. No MUI-v5/v6-only props (useFlexGap, flexWrap on Stack, storageWindow, InitColorSchemeScript, …).
  8. Shared components get a co-located *.test.tsx. (A component imported from >1 place.)
  9. Magic strings become named constants (src/constants/ or a co-located constants.ts).
  10. npm run check is green and translations stay in sync before you finish.
  11. 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 against routing.locales (falls back to defaultLocale). No header reads.
  • Renders <html lang dir> (dir from getDirection(locale)) plus data-mui-color-scheme from getThemeMode().
  • Loads the Mikhak font and attaches its CSS-variable class to <html> only for fa (see Fonts).
  • Calls setRequestLocale(locale) so server components deeper in the tree can call getLocale() / getTranslations() reliably.
  • Calls getMessages({ locale }) with the locale passed explicitly so getRequestConfig receives it via Promise.resolve(locale) (not through the React.cache read), avoiding any cache-ordering race.
  • Wraps children with NextIntlClientProvider, AuthProvider (seeded with server-read auth state), and ThemeProvider.
  • Exports generateStaticParams so 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:

  1. Add the key to messages/en.json AND messages/fa.json. Both files must always be in sync.
  2. 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.


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.ts in a client component; never import client.ts in 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.ts next to that feature's files
  • App-wide constants (used across multiple features): src/constants/ — one file per concern (routes.ts, events.ts, etc.)

Rules:

  1. Import the constant; never copy-paste the string value.
  2. When renaming, update the constant definition — the rest of the codebase follows automatically.

Theme System

How it works (end-to-end, no-flash)

  1. Request arrivesgetThemeMode() reads 'color-scheme' cookie → returns { colorScheme, defaultMode }
  2. Root layout sets data-mui-color-scheme={colorScheme} on <html> server-side
  3. <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's localStorage writes for key 'mode' to our cookie; reads return null so MUI always trusts the defaultMode prop
  4. <MuiThemeProvider defaultMode={defaultMode}> mounts — uses the server-derived mode, not localStorage
  5. ColorSchemeCookieSync in ThemeProvider writes the cookie via useColorScheme().colorScheme on 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:

  1. setMode('dark') is called
  2. Storage.prototype.setItem intercept fires → writes 'color-scheme'='dark' cookie synchronously
  3. MUI sets data-mui-color-scheme="dark" on <html>
  4. 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 by APP_THEME_LTR)
  • TYPOGRAPHY_RTL — Mikhak for all text including body (used by APP_THEME_RTL, ensures full Persian glyph coverage)
  • TYPOGRAPHY — alias for TYPOGRAPHY_LTR (deprecated, prefer the explicit exports)

Rules:

  • Mikhak is declared with preload: false, and its .variable class is attached to <html> only when locale === 'fa'. Both are required: a next/font loader called in the root layout would otherwise preload on every route (including /en), and preload: false ensures the woff2 only downloads when Persian text actually renders.
  • Font files live in src/app/fonts/ (not public/). 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 via localFont/localFont-equivalent in src/app/[locale]/layout.tsx, attach its .variable class conditionally on the matching locale, and update BRAND_FONT_VARIABLE_* constants in typography.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:

  1. It renders without crashing.
  2. Every documented prop produces the correct HTML attribute or CSS class.
  3. 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 to error — 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 token above setClientCookie(...), 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) and src/lib/auth/token.ts (why the JWT exp check 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 localStorage or document.cookie in render functions — use useEffect or server-side cookies() from next/headers.
  • Do not call createTheme() inside a component or hook — use APP_THEME_LTR / APP_THEME_RTL.
  • Do not use storageWindow={null} on MuiThemeProvider — it is silently ignored in MUI v9.
  • Do not use InitColorSchemeScript from MUI — use ColorSchemeScript from @/theme.
  • Do not set colorSchemeSelector: 'data' — use 'data-mui-color-scheme'.
  • Do not check mode === 'dark' for "is dark active" — use colorScheme === 'dark'.
  • Do not hard-code UI strings — add translation keys to both messages/en.json and messages/fa.json.
  • Do not add a src/app/layout.tsx or any layout above the [locale] segment. Such a layout is shared across locales, gets statically cached at build time with defaultLocale ('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.tsx is 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 to getRequestConfig via Promise.resolve(locale), bypassing potential React.cache ordering issues.
  • Do not remove setRequestLocale(locale) from src/app/[locale]/layout.tsx — without it, getLocale() called by deeper server components always returns defaultLocale.
  • Do not add notFound() to src/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 — use TYPOGRAPHY_LTR or TYPOGRAPHY_RTL explicitly.
  • Do not load fonts inside components or pages — all next/font declarations belong in src/app/[locale]/layout.tsx, with the .variable class attached conditionally per locale (Mikhak only for fa).
  • Do not import @/lib/cookies/server in client components or @/lib/cookies/client in RSCs.
  • Do not call fetch() directly in components or services — use serverFetch (RSC/Server Actions) or clientFetch (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', not import { useLogin } from '@/services').
  • Each domain does have an index.ts that re-exports its hooks (e.g. src/services/auth/index.ts). Do not export types, keys, or apis/* from this barrel — only hooks.
  • Do not mix clientFetch and serverFetch in the same file — keep clientApi.ts and serverApi.ts separate; 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 in onError for 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.cookie directly — use the central client cookie manager.
  • Do not store auth tokens in sessionStorage or localStorage — use cookies via @/lib/cookies/client.
  • Do not pass flexWrap or useFlexGap as direct props to MUI Stack — these are not valid Stack props in MUI v9 and cause a TypeScript overload error. Use sx={{ flexWrap: 'wrap' }} instead. useFlexGap was 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 dispatches LOG_IN to keep AuthContext in 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, dispatches LOG_OUT, and redirects — and automatically by clientFetch on 401.
  • Read on the server by serverFetch / getServerAuthState via getServerCookie.
  • Read on the client by clientFetch via getClientCookie (to attach Authorization: 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 clientFetch can 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 exp but does not verify the signature. The API is the only authority; never gate real authorization on the middleware or isTokenAlive.
  • No refresh-token rotation is implemented — the refresh_token cookie is set/cleared but never exchanged; access expiry just forces re-login on the next 401.
  • No role/permission model yet (User is { id, username }); authorization is binary. Add roles to User/AuthState and 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.


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.tsmaxAge is in seconds (converted to expires: Date internally when calling js-cookie).

  • Do not document.title = title in the render body of any component — it causes ReferenceError: document is not defined during build-time prerendering.