Frontend Architecture Guide v2¶
Purpose: A universal, framework-agnostic reference for building production-grade frontend applications. Feed this entire document as a system prompt or project context to any LLM before scaffolding a new project. Every rule here is a hard constraint.
Scope: The architectural principles apply universally. Examples use Next.js + React + TypeScript, but substitute your framework and keep the principles.
Table of Contents¶
- Core Principles
- Project Structure
- Routing & Navigation
- API Layer
- Server Components & Server Actions
- State Management
- Component Architecture
- Authentication & Authorization
- Styling & Design Tokens
- TypeScript Conventions
- Error Handling
- Form Handling
- Performance
- Testing Strategy
- Module Configuration Pattern
- Provider Composition
- Adding a New Feature (Checklist)
- Do's and Don'ts
- Appendix: Recommended Tech Stack
1. Core Principles¶
These six rules override everything else. When in doubt, refer back here.
| # | Principle | What it means |
|---|---|---|
| 1 | Domain colocation | Group code by business domain, not by technical layer. All files for "leads" live in one folder. No scattering across api/services/, store/, api/types/. |
| 2 | Single responsibility | Each file does exactly one thing. One component per file. One store per domain. One transformer per domain. One hook per concern. |
| 3 | Server state ≠ Client state | Data from the API is server state (React Query / SWR). UI toggles, auth sessions, sidebar state is client state (Zustand / Pinia / Svelte stores). Never mix them. Never put isLoading, error, items[], or fetchX() in a client store. |
| 4 | Transform at the boundary | Backend shapes (snake_case) are converted to frontend shapes (camelCase) exactly once, in the service/API layer. No backend shapes leak into components. |
| 5 | Explicit over implicit | Type every function. Name every constant. Export through barrels. No magic strings, no any, no implicit return types. |
| 6 | Minimal coupling | Each domain should be self-contained. A change in leads/ should never require touching meetings/. Shared logic goes in lib/shared/ or lib/utils/. |
Why These Matter¶
Violating principle 3 is the single most common mistake in frontend projects. When you put API data into a Zustand/Pinia store with manual isLoading flags, fetchX() methods, and items[] arrays, you lose:
- Automatic caching — the same data re-fetched on every mount
- Background refetching — stale data shown until manual refresh
- Race condition handling — rapid navigation causes state corruption
- Cache invalidation — manual
refetch()calls scattered everywhere - Deduplication — two components mounting = two API calls
A server-state library (React Query, SWR, Apollo) solves all of these. Your client store should only hold things that don't exist on the server: selected tab, sidebar collapsed, modal open/close, theme preference.
2. Project Structure¶
.
├── src/
│ ├── app/ # Framework router (Next.js App Router / equivalent)
│ │ ├── (auth)/ # Unauthenticated pages (login, signup, forgot-password)
│ │ ├── (dashboard)/ # Authenticated dashboard modules
│ │ │ └── <role>/ # One folder per user role
│ │ │ ├── config.ts # Nav items, route constants, theme — NO JSX
│ │ │ ├── layout.tsx # Role-specific layout wrapper
│ │ │ ├── page.tsx # Overview / home page for this role
│ │ │ └── <feature>/
│ │ │ └── page.tsx
│ │ ├── api/ # Route handlers (BFF layer for auth cookies, proxying)
│ │ ├── globals.css # Design tokens & global styles (single source of truth)
│ │ ├── layout.tsx # Root layout — provider composition
│ │ └── page.tsx # Root redirect handler
│ │
│ ├── components/
│ │ ├── layout/ # Shell layout (sidebar, top nav, mobile dock)
│ │ ├── providers/ # Context providers — no visual output
│ │ ├── shared/ # Reusable feature-level components (tables, modals, panels)
│ │ │ ├── index.ts # Barrel — ALL shared imports go through here
│ │ │ └── <component-name>/
│ │ │ └── index.tsx
│ │ └── ui/ # Primitives (Button, Badge, Input, Dialog…)
│ │
│ ├── hooks/ # App-wide custom hooks (useMediaQuery, useDebounce, etc.)
│ │
│ ├── lib/ # Business logic & state — DOMAIN-BASED
│ │ ├── api-client.ts # Singleton HTTP client — the ONLY place fetch is called
│ │ ├── <domain>/ # One folder per business domain
│ │ │ ├── api.ts # Service functions — calls apiClient
│ │ │ ├── hooks.ts # React Query hooks (useQuery / useMutation)
│ │ │ ├── store.ts # Client state store (UI-only — optional)
│ │ │ ├── transformers.ts # Backend ↔ Frontend shape conversions
│ │ │ ├── types.ts # Domain types (Backend* + Frontend shapes)
│ │ │ ├── constants.ts # Domain-specific constants (optional)
│ │ │ └── index.ts # Barrel — export only what other code needs
│ │ └── utils/ # Pure utility functions (formatDate, cn, etc.)
│ │
│ ├── middleware.ts # Route protection + API auth injection
│ └── types/ # System-wide global TypeScript types & enums
│
├── public/ # Static assets (images, icons, SVGs)
├── next.config.ts # Framework configuration
├── tsconfig.json # TypeScript — path aliases configured here
└── package.json
Why Domain-Based?¶
| Layer-Based (avoid) | Domain-Based (use this) |
|---|---|
All API services in api/services/ | Each domain owns its api.ts |
All stores in store/ | Each domain owns its store.ts |
All types in api/types.ts | Each domain owns its types.ts |
| All transformers in one file | Each domain owns its transformers.ts |
| Finding lead code = search 4+ folders | Finding lead code = open lib/leads/ |
| Adding a domain = edit 4+ layer folders | Adding a domain = create 1 folder |
The test: If you search the codebase for "where does lead data get fetched, transformed, cached, and typed?" — the answer should be one folder: lib/leads/. If the answer involves 4+ directories, you have a layer-based structure that will not scale.
Domain Folder Template¶
Every domain folder follows this exact structure. No exceptions.
lib/leads/
├── api.ts # Service functions (fetchLeads, createLead, etc.)
├── hooks.ts # React Query hooks (useLeads, useCreateLead, etc.)
├── store.ts # Zustand store — ONLY if this domain needs shared UI state
├── transformers.ts # snake_case ↔ camelCase conversions
├── types.ts # BackendLead (snake_case) + Lead (camelCase)
├── constants.ts # Domain-specific constants (optional)
└── index.ts # Barrel exports
3. Routing & Navigation¶
Route Group Convention¶
Use framework grouping mechanisms (parenthesized folders in Next.js) to organize pages without affecting the URL:
(auth)/— Unauthenticated pages (login, signup). No shared layout with dashboard.(dashboard)/— All authenticated role dashboards. Each sub-folder is a URL segment.
Role → URL Mapping¶
Each user role maps to a unique URL prefix and folder:
This mapping must be maintained in sync across:
middleware.ts—ROLE_DASHBOARD_MAP(server-side route protection & redirect)lib/auth/store.ts—ROLE_DASHBOARD_MAP(client-side redirect after login)
Rule: If you add a new role, update both files. If they drift, users land on wrong dashboards or get access denied.
Page & Layout Convention¶
(dashboard)/<role>/
├── config.ts # Nav items, route constants — NO JSX, no hooks
├── layout.tsx # Wraps children in DashboardShell — "use client"
├── page.tsx # Overview page
└── <feature>/
└── page.tsx
Route Constants¶
Never hardcode URL strings in components. Define them in config.ts:
export const ADMIN_ROUTES = {
overview: "/admin",
users: "/admin/users",
userDetail: (id: string) => `/admin/users/${id}`,
} as const;
Use these constants in navigation: router.push(ADMIN_ROUTES.userDetail(id)).
4. API Layer¶
Architecture (Request Flow)¶
Component
↓ calls hook
Query Hook (lib/<domain>/hooks.ts)
↓ calls service
Service Function (lib/<domain>/api.ts)
↓ calls singleton
apiClient (lib/api-client.ts)
↓ fetch with credentials
Middleware (injects Bearer token from cookie)
↓
Backend API (/api/v1/*)
Every HTTP request goes through apiClient. Components never call fetch() directly.
The apiClient (lib/api-client.ts)¶
A singleton class (or module) wrapping fetch. Key behaviors:
- Always sends
credentials: 'include'— cookies flow automatically. - On 401: attempts token refresh via
/api/auth/refresh, then retries once. - On second 401: redirects to
/login. - Handles 204 No Content gracefully (returns
null). - Throws a structured
AppErrorfor all non-OK responses — never a rawError. - Provides typed methods:
get<T>(),post<T>(),patch<T>(),delete(). - Accepts an optional
fetchOptionsparameter to pass through framework-specific options.
import { apiClient } from "@/lib/api-client";
const { data } = await apiClient.get<ResponseType>("/api/v1/items", {
page: "1",
});
const { data } = await apiClient.post<ResponseType>("/api/v1/items", payload);
const { data } = await apiClient.patch<ResponseType>(
`/api/v1/items/${id}`,
updates,
);
await apiClient.delete(`/api/v1/items/${id}`);
Singleton Safety on the Server¶
Security Warning: In server-side environments (Node.js), a singleton
apiClientis shared across all incoming requests from all users. If the singleton stores a user's Bearer token, cookie, or any per-user state as an instance property, User B will make requests using User A's credentials.
The rule: apiClient must be stateless. It should never hold per-user data in memory.
- Safe:
apiClientdelegates auth to middleware (middleware reads cookies per-request and injectsAuthorizationheaders). TheapiClientitself knows nothing about tokens. - Safe:
apiClientaccepts auth headers as function parameters, not stored state. - Unsafe:
apiClient.setToken(token)storing a token as a class property. - Unsafe: A constructor that reads cookies at instantiation time.
For server-side code (RSCs, Route Handlers, Server Actions) that needs auth, use one of these approaches:
// Option 1: Per-request client factory (preferred for server-side)
import { cookies } from "next/headers";
function createServerClient() {
const token = cookies().get("access_token")?.value;
return {
get: <T>(url: string) =>
fetch(url, {
headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json() as Promise<T>),
};
}
// Option 2: Pass auth as a parameter to the stateless singleton
await apiClient.get<T>("/api/v1/leads", {
headers: { Authorization: `Bearer ${token}` },
});
Summary: On the client → use the singleton (middleware handles auth). On the server → either use a per-request factory or pass auth explicitly. Never store tokens in the singleton.
Domain Service (lib/<domain>/api.ts)¶
Each domain wraps apiClient and applies transformations. Service functions always return frontend types — never raw backend shapes:
// lib/leads/api.ts
import { apiClient } from "@/lib/api-client";
import { transformLead } from "./transformers";
import type {
BackendLeadListResponse,
Lead,
FetchLeadsParams,
LeadListResult,
} from "./types";
export async function fetchLeads(
params: FetchLeadsParams,
): Promise<LeadListResult> {
const { data } = await apiClient.get<BackendLeadListResponse>(
"/api/v1/leads",
{
page: String(params.page ?? 1),
limit: String(params.limit ?? 50),
},
);
return {
items: data.items.map(transformLead), // ← transform at the boundary
total: data.total,
};
}
Domain Types (lib/<domain>/types.ts)¶
Keep backend and frontend shapes in the same file, separated by clear sections:
// lib/leads/types.ts
// ── Backend shapes (match wire format exactly) ──────────────
export interface BackendLead {
id: string;
lead_name: string;
created_at: string;
assigned_user_id: string | null;
}
export interface BackendLeadListResponse {
items: BackendLead[];
total: number;
skip: number;
limit: number;
}
// ── Frontend shapes (used in components) ────────────────────
export interface Lead {
id: string;
leadName: string;
createdAt: string;
assignedUserId: string | null;
}
export interface LeadListResult {
items: Lead[];
total: number;
}
// ── Query/mutation parameter types ──────────────────────────
export interface FetchLeadsParams {
page?: number;
limit?: number;
status?: string;
}
Naming rule: Backend types use Backend* prefix and snake_case fields. Frontend types use no prefix and camelCase fields.
Domain Transformers (lib/<domain>/transformers.ts)¶
All snake_case → camelCase conversions live here. This is the only place where backend field names appear:
// lib/leads/transformers.ts
import type { BackendLead, Lead } from "./types";
export function transformLead(raw: BackendLead): Lead {
return {
id: raw.id,
leadName: raw.lead_name,
createdAt: raw.created_at,
assignedUserId: raw.assigned_user_id,
};
}
// Reverse transformer for mutations (frontend → backend)
export function toBackendLead(lead: Partial<Lead>): Record<string, unknown> {
return {
lead_name: lead.leadName,
assigned_user_id: lead.assignedUserId,
};
}
Rule: Transform at the service boundary (in
api.ts). Components and query hooks never seesnake_casefields.
Domain Barrel (lib/<domain>/index.ts)¶
Export only what the rest of the app needs:
// lib/leads/index.ts
export { fetchLeads, createLead, updateLead, deleteLead } from "./api";
export { useLeads, useLead, useCreateLead } from "./hooks";
export type { Lead, FetchLeadsParams } from "./types";
Next.js Fetch Compatibility¶
Important if using Next.js App Router: Next.js heavily patches the native
fetchAPI to support granular caching (force-cache,revalidate), server-side request deduplication, and static generation. YourapiClientmust not fight this.
Rules for Next.js compatibility:
- The
apiClientshould accept an optionalfetchOptionsbag that gets spread into the underlyingfetch()call. This allows service functions to pass through Next.js-specific options like{ next: { revalidate: 60 } }or{ cache: 'no-store' }. - On the server (inside RSCs or Server Actions), use native
fetchwith Next.js options directly when you need framework caching. TheapiClientsingleton is primarily for client-side requests. - Never instantiate
apiClientat module scope with hardcoded headers that prevent Next.js from deduplicating requests on the server.
// In a service function that may be called from an RSC:
export async function fetchLeads(
params: FetchLeadsParams,
): Promise<LeadListResult> {
const { data } = await apiClient.get<BackendLeadListResponse>(
"/api/v1/leads",
{ page: String(params.page ?? 1) },
{ next: { revalidate: 60 } }, // ← passed through to fetch()
);
return { items: data.items.map(transformLead), total: data.total };
}
5. Server Components & Server Actions¶
This section applies to frameworks with server rendering capabilities (Next.js App Router, Nuxt 3, SvelteKit). If your framework is fully client-rendered (Vite + React SPA), skip this section — all data fetching goes through React Query on the client.
The Key Distinction¶
Modern frameworks introduce Server Components (RSCs in React/Next.js) that run on the server and ship zero JavaScript to the client. They are designed for initial data loading, not interactive state.
| Scenario | Use | Why |
|---|---|---|
| Page first-load data (dashboard overview, profile, static lists) | Server Component with async/await | No JS shipped, faster TTFB, SEO-friendly |
| Interactive, paginated data (tables with sorting, search, filters) | Client Component with React Query | Needs client-side cache, background refetch, pagination state |
| Form submissions / write operations | Server Action or React Query mutation | Both valid — see decision guide below |
| Real-time or polling data | Client Component with React Query | Needs refetchInterval, WebSocket integration |
| Data shared across many client components | Client Component with React Query | Needs shared cache accessible from multiple components |
Server Component Data Fetching¶
For initial page loads, fetch data directly in the Server Component. No hooks, no "use client", no React Query:
// app/(dashboard)/admin/page.tsx — this is a Server Component by default
import { fetchDashboardStats } from "@/lib/dashboard/api";
export default async function AdminOverview() {
const stats = await fetchDashboardStats(); // runs on server, no JS shipped
return (
<div>
<h1>Dashboard</h1>
<StatsDisplay stats={stats} /> {/* can be a Server Component too */}
<InteractiveLeadTable /> {/* "use client" — uses React Query */}
</div>
);
}
Server → Client Hydration Pattern (Eliminating Loading Spinners)¶
The biggest user-facing performance win: fetch data on the server, pass it to a client component as initialData, and let React Query take over for subsequent interactions. This eliminates the initial loading spinner entirely.
// app/(dashboard)/admin/leads/page.tsx — Server Component
import { fetchLeads } from "@/lib/leads/api";
import { LeadTable } from "./lead-table"; // "use client" component
export default async function LeadsPage() {
// Fetched on the server — no spinner, HTML arrives with data
const initialData = await fetchLeads({ page: 1, limit: 50 });
return <LeadTable initialData={initialData} />;
}
// app/(dashboard)/admin/leads/lead-table.tsx — Client Component
"use client";
import { useLeads } from "@/lib/leads";
import type { LeadListResult, FetchLeadsParams } from "@/lib/leads";
export function LeadTable({ initialData }: { initialData: LeadListResult }) {
const [params, setParams] = useState<FetchLeadsParams>({ page: 1 });
const { data, isLoading } = useLeads(params, {
initialData: params.page === 1 ? initialData : undefined,
// React Query uses server data for page 1 — no loading spinner
// Subsequent pages/filters fetch client-side as normal
});
return (
<DataTable
data={data?.items ?? []}
isLoading={isLoading}
onPageChange={(page) => setParams((p) => ({ ...p, page }))}
/>
);
}
Why this is optimal:
| Approach | Initial load | Navigation | Complexity |
|---|---|---|---|
| Client-only (React Query) | Spinner → download JS → parse → API call → render | Instant (cached) | Simple |
| Server-only (RSC) | HTML arrives with data | Full page reload for interactions | Simple |
| Server + Client hydration | HTML arrives with data | Instant (React Query cache) | Moderate |
Use the hydration pattern for: Any page that has both an initial data load AND interactive features (sorting, pagination, filtering). This is the majority of dashboard pages.
Skip it for: Purely static pages (About, Settings) — just use a Server Component. Purely interactive components with no meaningful first-load (modals, search dropdowns) — just use React Query.
Server Actions for Mutations¶
Server Actions can replace React Query mutations for simple form submissions. They run on the server, have direct access to cookies, and integrate with Next.js revalidation:
// lib/leads/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createLeadAction(formData: FormData) {
const response = await fetch(`${process.env.API_URL}/api/v1/leads`, {
method: "POST",
body: JSON.stringify(Object.fromEntries(formData)),
headers: { "Content-Type": "application/json" },
});
if (!response.ok) throw new Error("Failed to create lead");
revalidatePath("/admin/leads"); // ← automatically refreshes the page data
}
When to Use Server Actions vs React Query Mutations¶
Does the mutation need optimistic UI updates?
├─ YES → React Query mutation (onMutate + rollback)
└─ NO → Does it need to update React Query cache across components?
├─ YES → React Query mutation (queryClient.invalidateQueries)
└─ NO → Server Action (simpler, runs on server, revalidatePath)
Practical guidance:
- Server Actions work best for: simple forms, admin CRUD, settings updates — anything where a page refresh/revalidation is acceptable.
- React Query mutations work best for: interactive UIs where the user expects instant feedback (optimistic updates), or when multiple components need to react to the mutation.
- Don't mix both for the same operation. Pick one pattern per mutation.
6. State Management¶
This is the most critical architectural decision. Get this wrong and the codebase becomes unmaintainable. Split state into two categories based on its origin — never mix them.
The Two-Bucket Rule¶
| Bucket | Tool | What belongs here |
|---|---|---|
| Server state | React Query / SWR | Anything from the API: lists, records, counts, stats, paginated results |
| Client/UI state | Zustand / Pinia | Auth session, modal open/close, selected tab, sidebar collapsed, local form drafts |
Important: Use
useStatefor component-local UI state (opening/closing a tab). Use Zustand/Pinia only if multiple unrelated components need to share that state.
Server State — React Query Hooks (lib/<domain>/hooks.ts)¶
// lib/leads/hooks.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import * as leadService from "./api";
import type { FetchLeadsParams, CreateLeadPayload } from "./types";
// Query key factory — always arrays, never plain strings
export const leadKeys = {
all: ["leads"] as const,
lists: () => [...leadKeys.all, "list"] as const,
list: (params: FetchLeadsParams) => [...leadKeys.lists(), params] as const,
details: () => [...leadKeys.all, "detail"] as const,
detail: (id: string) => [...leadKeys.details(), id] as const,
};
export function useLeads(params: FetchLeadsParams) {
return useQuery({
queryKey: leadKeys.list(params),
queryFn: () => leadService.fetchLeads(params),
staleTime: 30_000, // 30s — adjust per domain
});
}
export function useCreateLead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CreateLeadPayload) => leadService.createLead(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: leadKeys.lists() });
},
});
}
React Query rules:
- Use a query key factory per domain — predictable invalidation, no typos.
- Set
staleTimedeliberately —0causes waterfall refetches on every mount. - Prefer optimistic updates for mutations that update visible list items.
- Never duplicate fetched data into a client store.
- Use
enabledoption for conditional queries (e.g., only fetch when ID is present).
Client State — Zustand Stores (lib/<domain>/store.ts)¶
Reserved only for state with no server equivalent:
// lib/leads/store.ts — only if this domain needs persistent UI state
import { create } from "zustand";
interface LeadUIState {
selectedTab: string;
isSlideOverOpen: boolean;
activeLeadId: string | null;
setSelectedTab: (tab: string) => void;
openSlideOver: (id: string) => void;
closeSlideOver: () => void;
}
export const useLeadUIStore = create<LeadUIState>((set) => ({
selectedTab: "all",
isSlideOverOpen: false,
activeLeadId: null,
setSelectedTab: (tab) => set({ selectedTab: tab }),
openSlideOver: (id) => set({ isSlideOverOpen: true, activeLeadId: id }),
closeSlideOver: () => set({ isSlideOverOpen: false, activeLeadId: null }),
}));
Client store rules:
- Select granularly:
useStore((s) => s.specificValue)— never subscribe to the whole store. - Never put
isLoading,error,items[], orfetchX()methods in a client store. - Keep stores small (< 50 lines). One store per domain, only if needed.
- Most UI state belongs in
useState— Zustand is for cross-component shared state only.
The Anti-Pattern to Avoid (Critical)¶
// WRONG — This is re-implementing React Query inside Zustand
export const useLeadStore = create((set) => ({
leads: [],
isLoading: false,
error: null,
pagination: { page: 1, limit: 50 },
fetchLeads: async (params) => {
set({ isLoading: true });
try {
const data = await leadService.fetchLeads(params);
set({ leads: data.items, isLoading: false });
} catch (err) {
set({ error: err, isLoading: false });
}
},
}));
Why this is wrong: No caching, no background refetching, no stale-while-revalidate, manual loading/error flags, race conditions on rapid navigation, duplicate requests on remount, no retry logic. This pattern creates every problem that React Query was designed to solve.
Decision Flowchart¶
Does this data come from an API?
├─ YES → Is it initial page load data with no client interactivity?
│ ├─ YES → Fetch in a Server Component (RSC) — no JS shipped
│ └─ NO → Use React Query (client-side server state)
└─ NO → Does it need to be shared across unrelated components?
├─ YES → Use Zustand (client state)
└─ NO → Use useState (local component state)
7. Component Architecture¶
Directory Roles¶
| Directory | Purpose | Example contents |
|---|---|---|
components/layout/ | Shell layout — sidebar, top nav, mobile dock | DashboardShell, MobileDockNav |
components/providers/ | Context wrappers — no visual output | AuthProvider, ThemeProvider |
components/shared/ | Reusable feature components shared across roles/pages | DataTable, SlideOver, StatsCard |
components/ui/ | Low-level primitives — no business logic | Button, Badge, Input, Dialog |
hooks/ | App-wide custom hooks (not tied to one domain) | useMediaQuery, useDebounce, useLocalStorage |
File & Folder Conventions¶
- One component per folder, folder name in
kebab-case. - Main component file is
index.tsx. - Colocate sub-components, hooks, and helpers in the same folder.
- If a sub-component is only used within its parent, do not export it from
shared/.
shared/
└── data-table/
├── index.tsx # export const DataTable = ...
├── table-header.tsx # sub-component, not exported from shared/
├── table-row.tsx # sub-component
└── use-sorting.ts # local hook
Import Shared Components via the Barrel¶
// Correct
import { DataTable, StatsCard, SlideOver } from "@/components/shared";
// Wrong — bypasses the barrel
import { DataTable } from "@/components/shared/data-table";
Barrel File Performance Note¶
Caveat: In large projects, a single massive barrel file (
index.tsre-exporting 30+ components) can hurt HMR speed and tree-shaking in bundlers like Webpack. Importing one component evaluates the entire barrel, pulling in all dependencies.
Mitigation strategies (apply as the project grows):
- Next.js
optimizePackageImports— Addcomponents/sharedto theoptimizePackageImportsarray innext.config.ts. This tells Next.js to transform barrel imports into direct imports at build time. - Domain-scoped barrels — Instead of one giant
shared/index.ts, group into smaller barrels:shared/tables/index.ts,shared/overlays/index.ts, etc. - Direct imports as escape hatch — If profiling shows a specific barrel causing slow HMR, bypass it with a direct import and add a comment explaining why.
// next.config.ts
module.exports = {
experimental: {
optimizePackageImports: ["@/components/shared", "lucide-react"],
},
};
Rule of thumb: Start with a single barrel. When the barrel exceeds ~20 re-exports or HMR becomes noticeably slow, split into domain-scoped barrels.
DashboardShell¶
Every role's layout.tsx renders <DashboardShell>, which provides:
- Collapsible sidebar with the role's
navItems - Mobile dock navigation
- Context hook exposing shell state (
isCollapsed, etc.) - Configurable module title, badge styles, user profile slot
// (dashboard)/admin/layout.tsx
<DashboardShell
navItems={adminNavItems} // from config.ts
basePath="/admin"
moduleTitle="Admin"
userProfile={<UserProfile />}
>
{children}
</DashboardShell>
Component Sizing Guidelines¶
| Component type | Max lines | If exceeded |
|---|---|---|
| Page component | ~200 | Extract sub-components or custom hooks |
| Shared component | ~150 | Extract sub-components into the same folder |
| Custom hook | ~80 | Split into smaller hooks |
| Zustand store | ~50 | You're probably storing server state — use React Query |
8. Authentication & Authorization¶
Cookie-Based Auth¶
Authentication uses HTTP-only cookies (access_token, refresh_token). JavaScript never reads the raw token values — this is intentional for XSS protection.
Request Flow¶
Browser Request
↓
middleware.ts
├── /api/v1/* → injects Authorization: Bearer <token> from cookie
└── /<protected-route> → redirects to /login if no cookie
↓
Route Handler (/app/api/auth/*) ← BFF layer, manages cookies
↓
Backend API
middleware.ts¶
- Defines
PROTECTED_PREFIXES— routes requiring authentication. - Defines
AUTH_ROUTES— routes only for unauthenticated users. - For API proxy calls (
/api/v1/*), reads the cookie and injectsAuthorizationheader — no manual token management anywhere else. - Defines
ROLE_DASHBOARD_MAPfor role-based redirects.
Auth Store (lib/auth/store.ts)¶
This is a valid use of Zustand because the current user is client-side session state, not server-cached data:
| Method | Purpose |
|---|---|
fetchMe() | Called on app mount — populates user from /api/auth/me |
login(email, password) | Posts to /api/auth/login, sets user state |
logout() | Posts to /api/auth/logout, clears state, redirects |
getUserRole() | Returns the primary role string |
getDashboardPath() | Resolves the dashboard URL for the current user's role |
Checking Roles¶
// Use the auth store
const role = useAuthStore((s) => s.getUserRole)();
// Never read cookies from JavaScript
// Never hardcode role strings — use constants
9. Styling & Design Tokens¶
globals.css — Single Source of Truth¶
All design tokens are defined once in globals.css:
:root {
--color-primary: 142 71% 45%;
--color-destructive: 0 84% 60%;
--color-muted: 210 40% 96%;
--color-border: 214 32% 91%;
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.1);
--font-sans: "Inter", sans-serif;
--font-mono: "JetBrains Mono", monospace;
}
.dark {
--color-primary: 142 71% 55%;
--color-muted: 217 33% 17%;
}
Rule: If you're about to write a hex value,
rgba(), orstyle={{ color: '#...' }}— stop. Either use an existing token, or add a new named token toglobals.cssfirst.
Use Semantic Tokens¶
text-foreground bg-background
text-muted-foreground bg-muted
text-primary bg-primary/10
border-border bg-destructive
text-gray-700 bg-[#f5f5f5]
border-gray-200 text-[#333]
Typography¶
Fonts are loaded once in the root layout and exposed as CSS variables. Never import a font in a component file.
Animations¶
- Keep animations subtle (< 300ms).
- Use
layouttransitions for DOM changes. - Never animate colors with JavaScript — use CSS transitions.
- Use a consistent animation library (Framer Motion, Auto Animate, CSS transitions).
10. TypeScript Conventions¶
Type Location¶
| Location | Naming | Case | Purpose |
|---|---|---|---|
lib/<domain>/types.ts | Backend* prefix | snake_case | Wire types from the backend |
lib/<domain>/types.ts | No prefix | camelCase | Frontend domain types (UI-side) |
src/types/ | Varies | camelCase | Global enums, shared interfaces |
Rules¶
- No
any— if genuinely unavoidable, add aneslint-disablewith an explaining comment. - Type all function parameters and return types explicitly.
- Use
export typefor pure type-only exports. - Use
@/path aliases for all imports — no relative paths beyond one level (../).
// Aliases everywhere
import { useLeadUIStore } from "@/lib/leads";
import { DataTable } from "@/components/shared";
import { cn } from "@/lib/utils";
// Deep relative paths
import { cn } from "../../../lib/utils";
Enum vs Union Type¶
Prefer string union types for small sets. Use enums only when the backend sends matching values and they're used across many files:
// For small, fixed sets
type Priority = "LOW" | "MEDIUM" | "HIGH";
// For backend-matching enums used across many files
enum LeadStatus {
NEW = "NEW",
WARM = "WARM",
HOT = "HOT",
CLOSED = "CLOSED",
}
11. Error Handling¶
AppError Class¶
All API errors thrown by the HTTP client are instances of AppError:
class AppError extends Error {
message: string; // Human-readable — show to user
statusCode: number; // HTTP status (400, 401, 403, 422, 500…)
detail: unknown; // Backend's structured detail — use for field-level errors
data: unknown; // Full raw response body
}
Error Handling in Mutations¶
useMutation({
mutationFn: createLead,
onError: (err) => {
if (!(err instanceof AppError)) throw err;
// 1. Always show the top-level message
toast.error(err.message);
// 2. For 422 validation errors — map to form fields
if (err.statusCode === 422 && Array.isArray(err.detail)) {
err.detail.forEach((issue: { loc: string[]; msg: string }) => {
const field = issue.loc.at(-1);
if (field) form.setError(field, { message: issue.msg });
});
}
},
});
Error Handling Rules¶
- Never silently swallow errors — always show a toast or field-level message.
- Show
err.detailwhen actionable — don't show only "Something went wrong" when the backend gives you field-level errors. - For form submissions: map
422errors to individual input fields. - For mutations: use the server-state library's error callback (
onError), not try/catch in the component. <ErrorBoundary>wraps the app tree for unhandled render errors — async errors are handled by the query library.
Error Boundary¶
Every app should have a root <ErrorBoundary> that:
- Catches unhandled render errors
- Shows a user-friendly fallback UI
- Provides a "retry" action
- Logs errors to a monitoring service (Sentry, LogRocket, etc.) in production
12. Form Handling¶
Recommended Approach¶
Use a form library (React Hook Form, Formik, VeeValidate) with schema validation (Zod, Yup):
// lib/leads/schemas.ts
import { z } from "zod";
export const createLeadSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email").optional(),
phone: z.string().min(10, "Phone must be at least 10 digits"),
priority: z.enum(["LOW", "MEDIUM", "HIGH"]),
});
export type CreateLeadFormData = z.infer<typeof createLeadSchema>;
Form Rules¶
- Define validation schemas alongside domain types (in the domain folder).
- Use the schema as the single source of truth for both frontend validation and TypeScript types.
- Map backend
422errors to form field errors (see Error Handling section). - Keep form state local — forms don't belong in Zustand.
- Separate form submission logic into mutation hooks.
13. Performance¶
Lazy Loading¶
Heavy interactive components (tables, charts, editors) should be lazy-loaded:
// components/shared/lazy.tsx
import dynamic from "next/dynamic";
export const LazyDataTable = dynamic(() => import("./data-table"), {
ssr: false,
loading: () => <div className="h-64 w-full animate-pulse rounded-2xl bg-muted/20" />,
});
Rules:
- All lazy imports use
ssr: falsefor browser-only components. - Every lazy import defines a skeleton fallback — never a blank space.
- Register all heavy components in a central
shared/lazy.tsxfile.
General Performance Rules¶
- Use the query library's
staleTimeto avoid unnecessary refetches. - Use
React.memosparingly — only when profiling shows a re-render problem. - Virtualize long lists (> 100 rows) with
@tanstack/react-virtualor equivalent. - Images use the framework's image component (or
<img>with explicitwidth/height). - Avoid barrel re-exports that pull in the entire domain — export only what's needed.
- Use
Suspenseboundaries to show loading states for lazy components. - Debounce search inputs (300ms+) to avoid excessive API calls.
14. Testing Strategy¶
Test Pyramid¶
| Layer | Tool | What to test |
|---|---|---|
| Unit tests | Vitest / Jest | Transformers, utility functions, query key factories, schemas |
| Component tests | Testing Library | Shared components render correctly, handle props, fire events |
| Integration tests | Testing Library + MSW | Hooks + components working together with mocked API responses |
| E2E tests | Playwright / Cypress | Critical user flows (login, create lead, navigation) |
What to Test Per Domain¶
For each domain folder (lib/<domain>/), test:
- Transformers —
transformLead(backendData)returns correct frontend shape. - Query hooks — Using MSW to mock API, verify correct key structure and data flow.
- Schemas — Valid data passes, invalid data fails with correct error messages.
What NOT to Test¶
- Don't test implementation details (internal state of React Query cache).
- Don't test third-party library behavior.
- Don't write tests that break when styling changes.
- Don't test
apiClientinternals — test the domain hooks that use it.
15. Module Configuration Pattern¶
Every role/module folder has a config.ts file:
// (dashboard)/admin/config.ts
import { LayoutDashboard, Users, FileText } from "lucide-react";
// 1. Nav items — consumed by DashboardShell
export const adminNavItems: NavItem[] = [
{ name: "Overview", href: "/admin", icon: LayoutDashboard },
{ name: "Users", href: "/admin/users", icon: Users },
{ name: "Leads", href: "/admin/leads", icon: FileText },
];
// 2. Route constants — never hardcode URL strings in components
export const ADMIN_ROUTES = {
overview: "/admin",
users: "/admin/users",
userDetail: (id: string) => `/admin/users/${id}`,
leads: "/admin/leads",
leadDetail: (id: string) => `/admin/leads/${id}`,
} as const;
// 3. Theme constants (optional)
export const ADMIN_THEME = { primary: "emerald" };
Rules:
config.tscontains zero JSX and zero hooks — it's pure data.- Import route strings from
ROUTESconstants — never inline/admin/leadsin a component. - Use functions for dynamic routes:
ROUTES.userDetail(id), not string interpolation in components.
16. Provider Composition¶
The root layout stacks all global providers in a specific order:
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
{" "}
{/* calls fetchMe() on mount */}
<ToastProvider>
<ConfirmDialogProvider>
{children}
<Toaster />
</ConfirmDialogProvider>
</ToastProvider>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
Rules:
- Only add providers here if they are truly global across every page.
- Feature-specific providers go in the feature's
layout.tsx, not the root. - Provider order matters:
ErrorBoundaryoutermost,QueryClientbefore anything that uses queries,Authbefore anything that needs user context. - Never nest more than 6-7 providers — if you need more, compose them in a
<Providers>wrapper component.
17. Adding a New Feature (Checklist)¶
Follow these steps in order when adding a new page or feature area:
Step 1: Create the Domain Folder¶
Create src/lib/<domain>/ with these files:
| File | Purpose |
|---|---|
types.ts | Backend response types (Backend* prefix, snake_case) + frontend types (camelCase) |
transformers.ts | Backend* → frontend type conversion functions |
api.ts | Service functions using apiClient, returning frontend types |
hooks.ts | Server-state hooks (useQuery / useMutation) |
store.ts | Client state store — only if this domain needs shared UI state |
constants.ts | Domain-specific constants — only if needed |
index.ts | Barrel: export only what the rest of the app needs |
Step 2: Create the Page¶
Create src/app/(dashboard)/<role>/<feature>/page.tsx
Step 3: Update Config¶
Update the role's config.ts — add a nav item and a route constant.
Step 4: Use Shared Components¶
Import from @/components/shared — do not re-implement data tables, slide-overs, or stat cards.
Step 5: Register Heavy Components¶
If your feature introduces a new heavy component, register it in shared/lazy.tsx.
Step 6: Export New Shared Components¶
If you created a new reusable component, export it through components/shared/index.ts.
The domain folder structure (Step 1) is the canonical template. Every domain folder should look the same. A developer navigating to any domain should immediately know where types, API calls, hooks, and state live.
18. Do's and Don'ts¶
Do¶
- Add
"use client"at the top of any file using hooks, browser APIs, or event handlers - Use Server Components for initial page data that doesn't need client interactivity
- Use a server-state library (React Query / SWR) for interactive, client-side API data
- Use Server Actions for simple form submissions where optimistic UI isn't needed
- Use a client store (Zustand / Pinia) only for UI state with no server equivalent
- Use a query key factory for every domain — structured, predictable invalidation
- Define all design tokens in
globals.css— never in component files - Use semantic CSS tokens (
text-primary,bg-muted) — never raw color values - Surface
err.detailfromAppError— show field errors, not just generic toasts - Select from stores granularly:
useStore((s) => s.specificValue) - Keep service functions thin — call
apiClient, transform, return domain types - Export all shared components through barrel files (split barrels if > 20 exports)
- Define all route strings in
config.tsROUTESconstants - Use
@/path aliases for all imports - Keep domain folders self-contained — types, API, hooks, transformers in one place
- Use
voidwhen calling async functions in event handlers withoutawait - Prefer
useStatefor component-local UI state before reaching for a store - Validate forms with schemas (Zod / Yup) — single source of truth for validation + types
- Debounce search inputs and expensive computations
- Pass framework-specific fetch options through
apiClient(e.g.,{ next: { revalidate } }) - Prefer Server Component data fetching over client-only React Query for initial page loads when using a framework that supports it (SSR/RSC)
Don't¶
- Use client stores (Zustand) to cache API responses, loading flags, or fetch functions
- Write hex colors,
rgba(), or hardcoded values in component files - Swallow errors silently or show only generic messages
- Scatter one domain's files across
api/services/,store/, andapi/types/ - Read or manage auth tokens from JavaScript — cookies are server-managed
- Use relative imports beyond one level (
../../) - Add
anywithout an explaining comment - Add global providers without strong justification
- Hardcode role names as raw strings — use constants
- Put JSX or hooks in
config.ts - Create monolithic stores (> 100 lines with fetch logic) — use React Query
- Mix backend
snake_caseand frontendcamelCasein the same type - Import
fetchdirectly in components — useapiClient - Put form state in global stores — keep it local
- Use Server Actions and React Query mutations for the same operation — pick one
Appendix: Recommended Tech Stack¶
This guide is designed for the following stack, but the principles apply broadly. Substitute libraries as needed — keep the architecture.
| Layer | Library | Purpose | Alternatives |
|---|---|---|---|
| Framework | Next.js (App Router) | File-system routing, SSR, middleware | Nuxt, SvelteKit, Remix, Vite + Router |
| UI Library | React | Component model | Vue, Svelte, Solid |
| Language | TypeScript | Type safety | — |
| Server State | React Query (TanStack) | Data fetching, caching, sync | SWR, Apollo Client, tRPC |
| Client State | Zustand | Lightweight client-side stores | Pinia (Vue), Svelte stores, Jotai, Valtio |
| Styling | Tailwind CSS | Utility-first CSS | CSS Modules, Styled Components, Vanilla Extract |
| Form Handling | React Hook Form + Zod | Forms + validation | Formik + Yup, VeeValidate (Vue) |
| Animations | Framer Motion | Layout & entrance animations | Auto Animate, CSS transitions |
| Icons | Lucide React | Consistent icon set | Heroicons, Phosphor |
| Toasts | Sonner | Toast notifications | React Hot Toast |
| Date Utilities | date-fns | Date formatting | Day.js, Luxon |
| Testing | Vitest + Playwright | Unit + E2E testing | Jest + Cypress |
| API Mocking | MSW | Mock API for dev/test | Mirage.js |
Adapting for other stacks: Substitute the tools but keep the principles: domain colocation, server/client state split, transform at boundary, centralized HTTP client, single source of truth for design tokens, typed schemas for validation.