30 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 AppStore (React context + reducer,
src/store/) for client 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.
- 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.
Project Structure
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 + AppStoreProvider
│ ├── (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)
│ ├── 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
├── store/ # AppStore (Redux-like client state)
├── 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,AppStoreProvider, 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.
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
| 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 |
Both are regular (non-httpOnly) cookies so they are readable by both server and client.
Lifecycle:
- Written after a successful login via
setClientCookiewithAUTH_ACCESS_COOKIE_OPTIONS/AUTH_REFRESH_COOKIE_OPTIONS - Deleted by
useLogout()(src/services/auth/hooks/useLogout.ts) /useEventLogout()(src/hooks/auth.ts), and automatically byclientFetchon 401 - Read by
serverFetchviagetServerCookie(COOKIE_NAMES.ACCESS_TOKEN) - Read by
clientFetchviagetClientCookie(COOKIE_NAMES.ACCESS_TOKEN)
Middleware: validates the exp claim of the access token locally (base64-decode the JWT payload, no signature check) before rendering any private page. An absent or expired token redirects to /{locale}/login.
useIsAuthenticated() hook: SSR-safe — initialises false, sets the real value in useEffect by reading the cookie. This prevents hydration mismatches.
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.