React Admin Dashboard (Backoffice) Patterns
Project Structure
apps/backoffice/
src/
core/
api/apiClient.ts # Axios instance
constants/ # Typography, config values
features/ # Feature modules (self-contained)
auth/ # Keycloak OIDC auth
bo-user/ # Backoffice user management
role/ # Role & permission management
parking/ # Resource CRUD
news/ # News/announcements CRUD
alerts/ # Global alert management
analytics/ # Dashboard metrics
roads/ # Road management
user/ # End-user management
profile/ # Admin profile
params/ # System parameters
shared/
components/ # Reusable UI (CustomTable, Modal, Chip, etc.)
types/ # Shared TypeScript types (inputs, forms)
integrations/
tanstack-query/devtools.tsx # React Query DevTools
routes/ # TanStack Router file-based routes
_authenticated.tsx # Auth layout (route guard)
_authenticated/
home/
parking/
news/
bo-user/
role/
user/
payments/
roads/
profile/
callback.tsx # OIDC callback handler
index.tsx # Landing/redirect
routeTree.gen.ts # Auto-generated route tree
theme.ts # MUI theme (Material Design 3)
reportWebVitals.ts
__tests__/
setup/vitest.setup.ts
vite.config.ts # Vite + TanStack Router plugin
package.json
Feature Module Structure
features/<feature>/
constants/ # Feature-specific constants
types/ # Feature TypeScript types
services/ # API call functions (pure)
queries/ # React Query hooks (CRUD)
hooks/ # Custom hooks (useXxxMode, etc.)
functions/ # Pure utility functions
ui/
<Feature>IndexScreen.tsx # List/table screen
<Feature>DetailScreen.tsx # Create/edit form screen
components/
<Component>.tsx
use<Component>ViewModel.ts # MVVM ViewModel hook
form/
use<Feature>FormViewModel.ts
TanStack Router (File-Based)
Route Definition
// routes/_authenticated/parking/index.tsx
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_authenticated/parking/')({
beforeLoad: async () => {
const { user } = await requirePermissions({
permissions: ['resources:read'],
});
return {
canCreate: user.permissions.includes('resources:write'),
};
},
component: ParkingsIndexScreen,
});
CRUD Route Pattern
Every entity follows this file structure:
routes/_authenticated/<entity>/
index.tsx # List view (GET)
$entityId.tsx # Edit/detail view (GET by ID)
new.tsx # Create view (POST)
Auth Layout Guard
// routes/_authenticated.tsx
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ location }) => {
await requireAuth({ location });
},
component: () => <Outlet />,
});
Vite Plugin
// vite.config.ts
import { tanstackRouter } from '@tanstack/router-plugin/vite';
export default defineConfig({
plugins: [
tanstackRouter({ target: 'react', autoCodeSplitting: true }),
viteReact(),
svgr(),
],
});
Key: autoCodeSplitting: true generates route tree and lazy-loads each route automatically. Never edit routeTree.gen.ts manually.
Authentication (Keycloak OIDC)
requireAuth (route guard)
// features/auth/functions/requireAuth.ts
import { userManager } from '../config/oidcConfig';
export const requireAuth = async ({ location }: { location: ParsedLocation }) => {
const user = await userManager.getUser();
const isExpired = user?.expired ?? true;
if (!user || isExpired) {
await authService.login(location.href);
throw new Promise(() => {}); // Suspend navigation until redirect
}
return user;
};
Note: throw new Promise(() => {}) is a TanStack Router pattern to suspend route loading while the OIDC redirect happens.
Auth Store (Zustand)
// features/auth/store/useAuthStore.ts
import { create } from 'zustand';
import type { User } from 'oidc-client-ts';
export const useAuthStore = create<AuthStore>((set) => ({
status: AUTH_STATUS.IDLE,
user: null,
setUser: (user) => set({
user,
status: user ? AUTH_STATUS.AUTHENTICATED : AUTH_STATUS.ANONYMOUS,
}),
setStatus: (status) => set({ status }),
}));
Mobile vs Backoffice auth difference:
- Mobile: stores
accessToken+idToken(strings) via Keycloak REST - Backoffice: stores
Userobject fromoidc-client-ts(full OIDC user manager)
Auth Status Enum
export const AUTH_STATUS = {
IDLE: 'idle',
AUTHENTICATED: 'authenticated',
ANONYMOUS: 'anonymous',
REFRESHING: 'refreshing',
} as const;
export type AuthStatusType = (typeof AUTH_STATUS)[keyof typeof AUTH_STATUS];
RBAC Permission System
requirePermissions (route-level)
// features/role/functions/requirePermissions.ts
export const requirePermissions = async ({
permissions,
requireAll = false,
}: RequirePermissionsOptions) => {
const user = await getCurrentUser();
const hasPermission = requireAll
? permissions.every((p) => user.permissions.includes(p))
: permissions.some((p) => user.permissions.includes(p));
if (!hasPermission) {
throw redirect({ to: '/home' });
}
return { user };
};
Pattern: Permissions are resource:action strings (e.g., resources:read, resources:write). Types from @scope/shared-types.
Route-Level Permission Check
// Pass canCreate to component via route context
beforeLoad: async () => {
const { user } = await requirePermissions({ permissions: ['entity:read'] });
return { canCreate: user.permissions.includes('entity:write') };
},
API Client
// core/api/apiClient.ts
import { env } from '@/config/runtime-config';
export const apiClient = axios.create({
baseURL: env.apiUrl,
timeout: 10000,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
});
Auth token injection via interceptors (added during OIDC init).
Service Layer (Pure Functions)
// features/bo-user/services/boUsersService.ts
import type { BoUser } from '@scope/shared-types';
import { apiClient } from '@/core/api/apiClient';
export const getBoUsers = async () => {
const response = await apiClient.get('/users');
return response.data as BoUser[];
};
export const createBoUser = async (userData: Partial<BoUser>) => {
const response = await apiClient.post('/users', userData);
return response.data as BoUser;
};
export const updateBoUser = async (id: string, userData: Partial<BoUser>) => {
const response = await apiClient.patch(`/users/${id}`, userData);
return response.data as BoUser;
};
export const deleteBoUser = async (id: string) => {
return await apiClient.delete(`/users/${id}`);
};
export const exportBoUsers = async (ids: string[]) => {
const params = new URLSearchParams();
ids.forEach((id) => params.append('ids[]', id));
const response = await apiClient.get(`/users/export?${params}`, {
responseType: 'blob',
});
return response.data as Blob;
};
CRUD convention: getAll, getById, create, update, delete, plus domain-specific operations (export, order).
React Query Hooks (CRUD)
Query (read)
export const useGetRoles = () => {
return useQuery({
queryKey: ['roles'],
queryFn: () => getRoles(),
});
};
Mutation (write)
export const useCreateParking = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Resource>) => createParking(data),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['parkings'] });
},
onError: (error) => {
console.error('Failed to create parking:', error);
},
});
};
Convention: One hook per operation, always invalidate related queries on mutation success.
MVVM ViewModel Hooks
Table ViewModel
// features/news/ui/components/useNewsTableViewModel.ts
export const useNewsTableViewModel = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [select, setSelect] = useState<string[]>([]);
const columns: NewsColumn[] = useMemo(() => [
{ id: 'image', label: t('page.news.table.image'), isImage: true, width: 250 },
{ id: 'dates', label: t('page.news.table.dates'), sortable: true },
{ id: 'name', label: t('page.news.table.name'), sortable: true },
], [t]);
const handleNavigate = useCallback((id: string) => {
void navigate({ to: '/news/' + id });
}, [navigate]);
return { columns, select, setSelect, handleNavigate };
};
Form ViewModel
// features/bo-user/ui/components/form/useBoUserDetailsFormViewModel.ts
export const useBoUserDetailsFormViewModel = ({ email, firstName, lastName, updateUser }) => {
const [form, setForm] = useState({ email, firstName, lastName });
const [errors, setErrors] = useState<FieldErrors>({});
const [touchedFields, setTouchedFields] = useState<TouchedFields>({});
const validateSingleField = useCallback((fieldName, value) => {
const rule = formValidationRules[fieldName];
return validateField(value, rule, fieldName);
}, []);
const handleFieldBlur = useCallback((fieldName, value) => {
setTouchedFields((prev) => ({ ...prev, [fieldName]: true }));
const error = validateSingleField(fieldName, value);
setErrors((prev) => ({ ...prev, [fieldName]: error }));
handleSave();
}, [handleSave, validateSingleField]);
// Returns inputData array for generic form rendering
return { inputData, createHandleRemoveValue };
};
Pattern: Form ViewModels build inputData[] arrays with { label, value, fieldName, error, onChange, onBlur } — enabling generic form component rendering.
Analytics ViewModel (Multi-Query)
export const useAnalyticsViewModel = () => {
const { data: userMetrics, isLoading: isLoadingUsers } = useGetUserMetrics();
const { data: parkingMetrics, isLoading: isLoadingParking } = useGetParkingMetrics();
const isLoading = useMemo(
() => isLoadingUsers || isLoadingParking,
[isLoadingUsers, isLoadingParking],
);
return { userMetrics, parkingMetrics, isLoading };
};
Create/Edit Mode Hook
// features/parking/hooks/useParkingMode.ts
export const useParkingMode = () => {
const params = useParams({ strict: false });
const isCreateMode = params.parkingId === undefined;
return {
isCreateMode,
isEditMode: !isCreateMode,
parkingId: isCreateMode ? undefined : params.parkingId,
};
};
MUI Material Design 3 Theme
// theme.ts
const tonalPalettes = {
primary: { 0: '#000000', 10: '#001A43', /* ... */ 100: '#FFFFFF' },
secondary: { /* ... */ },
// ...Material Design 3 tonal palette structure
};
const lightColors = {
primary: { primary: tonalPalettes.primary['40'], onPrimary: '...', primaryContainer: '...' },
surface: { surface: '...', onSurface: '...', surfaceContainer: '...' },
// ...full MD3 color roles
roles: { write: '#B8E8C1', read: '#F9D39A', none: '#F3A8A8' },
datesStatus: { upcoming: '#00bfff', ongoing: '#4caf50', expired: '#9e9e9e' },
};
const theme = createTheme({
palette: {
primary: { main: lightColors.primary.primary },
background: { default: lightColors.surface.surface },
},
customColors: lightColors, // Extended via module augmentation
components: {
MuiSwitch: { /* MD3 switch overrides */ },
MuiTablePagination: { /* custom font */ },
},
});
Pattern: customColors extends MUI theme via declare module '@mui/material/styles' augmentation. Access in components: theme.customColors.roles.write.
i18n
react-i18nextwithuseTranslation()hook- Translation keys:
page.<feature>.<section>.<key> - Columns, labels, buttons all use
t()— never hardcoded strings
Path Aliases
@/ → src/
@scope/shared-types → ../../packages/shared-types/src
Configured in vite.config.ts resolve.alias and tsconfig.json.
Testing (Vitest)
// vite.config.ts
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./__tests__/setup/vitest.setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
},
},
Key Libraries
| Library | Purpose |
|---|---|
@tanstack/react-router |
Type-safe file-based routing |
@tanstack/react-query |
Data fetching + caching |
zustand |
Client state (auth) |
@mui/material |
UI component library |
axios |
HTTP client |
oidc-client-ts |
Keycloak OIDC authentication |
react-i18next |
Internationalization |
vite |
Build tool |
vitest |
Test runner |
Shared Patterns (Mobile + Backoffice)
Both apps share these identical patterns:
- Feature-based directory structure under
src/features/ - MVVM ViewModel hooks (
useXxxViewModel) - Service layer (pure async functions calling
apiClient) - React Query hooks in
queries/(same naming:useGetXxx,useCreateXxx,useUpdateXxx,useDeleteXxx) - Zustand auth store with
AUTH_STATUSenum - Axios API client with interceptor-based auth
- Types from
@scope/shared-types - i18next translations with
page.<feature>.<key>convention @/path alias →src/