Mobile client (dev)
The NexSchool mobile app is an Expo / React Native client (client/) for teachers, students, and school admins — tenant-scoped, role-adaptive, iOS + Android.
App structure
Expo ~54 with Expo Router 6 (file-based routing), React Native 0.81, React 19, TypeScript, TanStack React Query 5 for server state, Zod 4 for validation, and i18next for localization. Token and settings persistence uses Expo Async Storage; file/photo uploads use Expo Document Picker and Image Picker; push handling uses Expo Notifications.
Two top-level trees:
client/app/— Expo Router routes, split into(auth)/(public) and(protected)/(authenticated) route groups.client/modules/— feature modules (attendance,students,teachers,classes,subjects,timetable,finance,transport,hostel,profile,notifications,devices,announcements,student-leaves,teacher-leaves,search,audit,dashboard,permissions, …).client/common/— cross-cutting code:services/api.ts(HTTP wrapper),constants/(api.tsbase URL,navigation.tstab definitions),components/,theme/,forms/, sharedhooks/andtypes/.
Module pattern
Each feature module follows the same layout, so a new module is predictable:
modules/<name>/
├── screens/ <FeatureName>Screen.tsx
├── hooks/ use<Feature>.ts (React Query hooks)
├── services/ <feature>Service.ts (apiGet/apiPost calls)
├── types.ts
└── components/ module-specific componentsScreens are thin: a service calls the API, a hook wraps it in useQuery/useMutation, and the screen renders the hook’s data. Admin light-CRUD screens use shared RHF form primitives in common/forms/ (FormField, FormSelect, FormDatePicker, FormTextArea, FormSection) built on react-hook-form + @hookform/resolvers.
Design system
The app uses the nexchool “Academic Fluidity” design system. Theme tokens live in client/common/theme/ (tokens.ts, palettes.ts light/dark, typography.ts — 11 Inter roles, elevation.ts, motion.ts) and are consumed through useTheme(). Never hard-code hex, fontSize, or fontFamily — a scoped no-restricted-syntax ESLint rule bans raw literals in modules/** and app/**. Blessed primitives in common/components/ include <Text variant color>, <AppIcon>, <PressScale>, Button, Input, ScreenContainer, EmptyState, Skeleton, plus selection/date primitives (BottomSheet/FieldTrigger, custom themed DatePicker/DateRangePicker, SelectSheet/MultiSelectSheet, FilterChips).
Navigation
Three navigation types compose the app:
- Bottom tab bar (
BottomTabBar) — 4 tabs: Home / Schedule / Notifications / Profile, with a pill-shaped active state. - Sidebar drawer (
AppDrawer) — slide-from-left drawer with a profile card and an RBAC-filtered module list grouped into labeled sections (People / Academics / Operations / Communication / Admin); empty sections hide after role/flag filtering. Footer: Settings / Help / Sign out. - Stack navigation — detail screens pushed on top (e.g.
students/[id],finance/invoices/[id]).
The protected route tree renders inside client/common/components/chrome/AppShell, which composes AppHeader (sticky: hamburger, school name, academic-year chip for admin/teacher, notification bell, global-search icon for admin/teacher), BottomTabBar, AppDrawer, and AcademicYearSheet. MainLayout.tsx mounts <AppShell> around the current router Slot. Because AppShell / AppHeader own the top inset, screen bodies must not add their own SafeAreaView (a second one causes a phantom top gap).
Dynamic tab & drawer visibility
Navigation entries are gated by both user permissions and plan features, defined in common/constants/navigation.ts:
export const ALL_TABS = [
{
name: 'students',
label: 'Students',
icon: 'people',
requiredPermissions: ['student.read'],
requiredPlanFeature: 'student_management',
},
// ...
]
export function getVisibleTabs(
permissions: string[],
enabledFeatures: string[]
): Tab[] {
return ALL_TABS.filter(tab => {
const hasPermission = tab.requiredPermissions.some(p => permissions.includes(p))
const hasFeature = !tab.requiredPlanFeature ||
enabledFeatures.includes(tab.requiredPlanFeature)
return hasPermission && hasFeature
})
}day_of_week in timetable routes is 0-indexed (0=Mon .. 6=Sun).
API client & auth
All HTTP goes through client/common/services/api.ts, which exposes typed helpers: apiGet, apiPost, apiPostForm, apiPut, apiDelete. Every request auto-injects auth and tenant headers, and the response envelope is unwrapped to return json.data.
const BASE_URL = process.env.EXPO_PUBLIC_API_URL
async function getHeaders() {
const token = await AsyncStorage.getItem('access_token')
const tenantId = await AsyncStorage.getItem('tenant_id')
const refreshToken = await AsyncStorage.getItem('refresh_token')
return {
'Authorization': `Bearer ${token}`,
'X-Tenant-ID': tenantId,
'X-Refresh-Token': refreshToken,
'Content-Type': 'application/json',
}
}
async function handleResponse<T>(response: Response): Promise<T> {
if (response.status === 401) {
const newToken = await refreshAccessToken()
if (!newToken) {
clearAuthStorage()
router.replace('/login')
throw new Error('Session expired')
}
}
const json = await response.json()
if (!json.success) throw new Error(json.message)
return json.data as T
}Notes:
- Tenant isolation on mobile is the
X-Tenant-IDheader, not a query-key wrapper (the tenant-in-query-key wrapper is an admin-web / panel concern only). PlainuseQuerywith a module key is the norm here. apiPostFormdeletesContent-Typeso the platform sets the multipart boundary — used for file/photo uploads.- A
401triggers a token refresh and retry; if refresh fails, auth storage is cleared and the user is routed to/login.
Auth flow
Token and session state persist in Expo Async Storage; tokens are read on every request via getHeaders().
App launch (app/_layout.tsx)
└─ Check AsyncStorage for access_token
├─ No token → (auth)/login
└─ Has token → (protected)/home
Login:
apiPost('/api/auth/login', { email, password, tenant_id })
└─ Store access_token, refresh_token, tenant_id, user,
permissions, enabled_features
└─ Navigate to (protected)/home
Protected routes:
Each request auto-injects Authorization + X-Tenant-ID
401 → refresh token → retry
Refresh fails → clear storage → loginThe (auth)/ group hosts login, register, forgot-password, reset-password, verify-email. Change-password lives under the protected profile tree (profile/change-password.tsx → POST /api/auth/password/change).
Role-adaptive UI
The same routes render different content per role — most top-level screens are role dispatchers:
app/(protected)/home.tsxdispatches toAdminHome,TeacherHome, orStudentHome(modules/home/components/), each fed by a different dashboard endpoint (GET /api/dashboard/for admin; teacher/student home data endpoints).profile.tsxdispatches student →MyProfileScreen, admin/teacher →StaffProfileScreen, unknown role → empty state.finance/index.tsx,timetable,my-leaves,my-attendance, and others dispatch by role similarly.
Two enforcement layers keep this honest:
- Server-side scoping — endpoints return only what the caller may see (e.g.
GET /api/subjects/minereturns all subjects for admin, taught subjects for a teacher, own-class subjects for a student;GET /api/searchscopes each result group by permission). The client just renders the response; it does not attempt client-side row filtering it lacks permissions to compute. - Client-side gating — drawer/tab entries filter on
requiredPermissions+requiredPlanFeature; individual screens self-guard withhasPermission(...)andisFeatureEnabled(...). Permission string constants are mirrored inmodules/permissions/constants/permissions.ts.
Screens render only fields actually present on the API response — cards and rows with no value are omitted rather than fabricated, and mockup sections without a real data source are intentionally not built.
Push notifications
Push handling uses Expo Notifications, with device registration in modules/devices/. The in-app inbox lives at app/(protected)/notifications/index.tsx (modules/notifications/):
- A
SectionListbuckets entries into Today / Yesterday / Earlier vianotificationGrouping.bucketByDate(empty buckets omitted). - Two chip rows in
NotificationFilterChips: status (All / Unread) and category (All / Announcements / Fees / Leaves / System) derived from the notificationtypeprefix viacategoryOf(). NotificationRowwraps unread rows in areact-native-gesture-handlerSwipeablewith a right “Mark read” action (read rows render plain; leading 8px unread dot). This requires<GestureHandlerRootView>as the outermost wrapper inapp/_layout.tsx.- The header bell shows an unread badge via
useUnreadNotificationsBadge.
Announcements (modules/announcements/) are a related but separate surface: admins compose/schedule/recall; all roles read a published/recalled inbox. Attachments upload via expo-document-picker to S3 endpoints and open through presigned URLs (Linking.openURL).
Releases (Expo OTA / store)
Production builds use EAS (Expo Application Services):
eas build --platform ios
eas build --platform android
# Local build (requires Xcode / Android Studio)
npx expo run:ios
npx expo run:androidRunning locally:
cd client
npm install
npm start # Expo dev server — press 'i' (iOS) / 'a' (Android), or scan QR (Expo Go)Point EXPO_PUBLIC_API_URL at your running API:
- Android emulator:
http://10.0.2.2/api - iOS simulator:
http://localhost/api - Physical device:
http://192.168.x.x/api(your LAN IP)