React admin dashboard patterns — TanStack Router, MUI Material, MVVM ViewModels, RBAC permissions, Keycloak OIDC, React Query CRUD hooks, feature modules, Vite config
Scanned 9/5/2026
Install to Claude Code
npx -y skills add me-cedric/agent-compass --skill react-admin-dashboard-patterns --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of React Admin Dashboard Patterns?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/me-cedric-react-admin-dashboard-patterns)More formats (shields.io, HTML) on the badges page.
---
name: react-admin-dashboard-patterns
description: React admin dashboard patterns — TanStack Router, MUI Material, MVVM ViewModels, RBAC permissions, Keycloak OIDC, React Query CRUD hooks, feature modules, Vite config
version: 1.0.0
filePattern: "**/routes/**/*.tsx,**/features/**/*.ts,**/features/**/*.tsx,**/vite.config.*,**/*ViewModel*"
bashPattern: "vite|vitest|tanstack|mui"
risk_level: medium
writes_files: false
requires_tools: []
---
# 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
```tsx
// 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
```tsx
// routes/_authenticated.tsx
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ location }) => {
await requireAuth({ location });
},
component: () => <Outlet />,
});
```
### Vite Plugin
```ts
// 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)
```ts
// 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)
```ts
// 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 `User` object from `oidc-client-ts` (full OIDC user manager)
### Auth Status Enum
```ts
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)
```ts
// 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
```tsx
// 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
```ts
// 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)
```ts
// 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)
```ts
export const useGetRoles = () => {
return useQuery({
queryKey: ['roles'],
queryFn: () => getRoles(),
});
};
```
### Mutation (write)
```ts
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
```ts
// 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
```ts
// 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)
```ts
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
```ts
// 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
```ts
// 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-i18next` with `useTranslation()` 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)
```ts
// 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_STATUS` enum
- Axios API client with interceptor-based auth
- Types from `@scope/shared-types`
- i18next translations with `page.<feature>.<key>` convention
- `@/` path alias → `src/`
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!