Educational institutions run on spreadsheets. Student records in one file, attendance in another, grades somewhere else, and course schedules โ usually โ in someone's head.
I built Maniesta Campus OS to fix that. It is a multi-tenant student management platform where an institute can manage students, courses, attendance, and marks through a single dashboard with role-based access.
๐ Live Demo: maniestacampus.netlify.app
This post walks through the architecture, the multi-tenancy model, the RBAC system, and the parts that were harder than expected.
The Problem
Before writing any code, I mapped what an actual institute needs:
- Admins manage students, courses, faculty, and enrollments
- Teachers mark attendance, upload grades, and see their class rosters
- Students check schedules, grades, and enrolled courses
- Multiple institutes should be able to use the same platform without seeing each other's data
That last requirement is what makes this a SaaS problem rather than just a CRUD app. Real institutional software is multi-tenant by default โ and multi-tenancy is where most of the design effort goes.
Tech Stack Decisions
| Layer | Choice | Why |
|---|---|---|
| Frontend | React 18, React Router 6 | Component-based UI with route-level separation for admin, teacher, student views |
| Styling | Tailwind CSS | Fast iteration across three different dashboard layouts |
| Backend | Firebase (Auth, Firestore, Storage) | No servers to manage, real-time by default, scales to zero cost on free tier |
| Auth | Firebase Authentication | Email/Password + Google sign-in out of the box |
| Database | Cloud Firestore | Document store fits hierarchical campus data; flexible schema per role |
| Charts | Recharts | Lightweight, composable, works well with Firestore data |
| Animations | Framer Motion | Spring-based transitions that feel native |
| Deployment | Netlify | Zero-config CI/CD from Git, edge CDN built in |
No backend server. The client talks directly to Firebase, guarded by Firestore Security Rules. For a project at this scale that is a feature, not a limitation โ it keeps the operational surface area near zero.
Architecture Overview
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ React SPA (Netlify) โ
โ - Auth pages โ
โ - Org-scoped portal โ
โ - Admin console โ
โโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโ
โ HTTPS + Firebase SDK
โโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโ
โ Firebase Cloud โ
โ - Authentication โ
โ - Firestore Database โ
โ - Storage (optional) โ
โ - Security Rules โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Every Firestore document carries an orgId field. Every query filters by orgId. Every security rule checks orgId against the authenticated user's profile. This is the entire multi-tenancy model in one sentence.
The Hard Part: Multi-Tenancy + RBAC
Two things had to work together from day one:
- Multi-tenancy โ an institute called "Springfield Academy" must never see data belonging to "Riverside College".
- Role-based access โ a student should not see admin controls; a teacher should not be able to edit another teacher's course.
I made three decisions upfront that kept the rest of the project sane.
Decision 1 โ Single users collection with a role field
Rather than separate collections for admins, teachers, and students, I used one collection:
// users/{uid}
{
email: 'ali@springfield.edu',
displayName: 'Ali Khan',
role: 'admin', // 'admin' | 'teacher' | 'student'
orgId: 'springfield', // which institute this user belongs to
createdAt: <timestamp>,
}
One auth flow. One user profile fetch. If a student is later promoted to a teacher, I update one field โ no data migration.
Decision 2 โ orgId on every document
Every course, student, mark, and attendance record has an orgId. This makes isolation explicit and enforceable at the database level:
// courses/{courseId}
{
orgId: 'springfield',
code: 'CS-400',
name: 'Software Engineering',
instructor: 'Prof. Fatima Rizvi',
fees: 40000,
totalStudents: 24,
createdAt: <timestamp>,
}
Decision 3 โ Enforce isolation in Security Rules, not just in code
Client-side filters are a convenience. The real guard is the security rule:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function userOrgId() {
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.orgId;
}
match /courses/{courseId} {
allow read: if request.auth != null
&& userOrgId() == resource.data.orgId;
allow write: if request.auth != null
&& userOrgId() == request.data.orgId;
}
match /students/{studentId} {
allow read: if request.auth != null
&& userOrgId() == resource.data.orgId;
allow write: if request.auth != null
&& userOrgId() == request.data.orgId;
}
// ... same pattern for marks, attendance
}
}
Even if a client-side bug accidentally queries the wrong orgId, Firestore refuses to return or write data outside the caller's organization. Multi-tenancy becomes a database guarantee, not just a frontend convention.
Frontend: One App, Three Dashboards
The React shell renders three completely different dashboards based on the user's role:
const Dashboard = () => {
const { user } = useAuth();
switch (user.role) {
case 'admin':
return <AdminDashboard />;
case 'teacher':
return <TeacherDashboard />;
case 'student':
return <StudentDashboard />;
default:
return <Navigate to="/login" />;
}
};
Each dashboard imports its own components. The shared shell โ navbar, sidebar, header, footer โ lives outside and is reused across all three.
A small ProtectedRoute wrapper handles the auth check on the way in:
const ProtectedRoute = ({ allowedRoles, children }) => {
const { user } = useAuth();
if (!user) return <Navigate to="/login" />;
if (allowedRoles && !allowedRoles.includes(user.role)) {
return <Navigate to="/unauthorized" />;
}
return children;
};
Adding a new admin-only page is now a single line:
<ProtectedRoute allowedRoles={['admin']}>
<ManageFaculty />
</ProtectedRoute>
Real-Time Attendance
Attendance is where Firestore shines. Instead of a traditional "save then refresh" flow, attendance is written as individual documents:
// attendance/{attendanceId}
{
orgId: 'springfield',
courseId: 'cs-400',
courseName: 'Software Engineering',
studentId: 'ali-khan',
studentName: 'Ali Khan',
date: '2026-09-26',
status: 'present', // 'present' | 'absent'
recordedBy: 'admin-uid',
timestamp: <server timestamp>,
}
The teacher's view subscribes to today's records filtered by courseId. When a teacher marks someone present, the chart on the admin dashboard updates instantly โ no polling, no refresh button, no manual consolidation step.
The weekly attendance trend on the dashboard is derived by querying the last seven days and counting present vs. absent. It sounds trivial, but the fact that data is already normalized makes the aggregation a 20-line function instead of a data pipeline.
Smart Marksheets
Marks follow the same pattern. Each mark is a document with the obtained score, the total, the computed percentage, and the letter grade:
// marks/{markId}
{
orgId: 'springfield',
studentId: 'ali-khan',
studentName: 'Ali Khan',
courseId: 'cs-400',
courseName: 'Software Engineering',
examType: 'Mid-term',
obtainedMarks: 85,
totalMarks: 100,
percentage: 85,
grade: 'A',
recordedBy: 'admin-uid',
createdAt: <server timestamp>,
}
Grades are computed at write time with a simple scale:
function gradeFromMarks(marks) {
if (marks >= 90) return 'A+';
if (marks >= 80) return 'A';
if (marks >= 70) return 'B';
if (marks >= 60) return 'C';
if (marks >= 50) return 'D';
if (marks >= 40) return 'E';
return 'F';
}
I deliberately kept grading logic on the client for now. If institutes ever need custom scales, the field would move to a per-organization settings document and the function would read it at write time โ but I did not want to solve that problem before a customer actually asked for it.
Firestore Indexes โ the Part Nobody Warns You About
This one cost me an afternoon. Firestore requires a composite index whenever you combine a where filter with an orderBy on different fields:
query(
collection(db, 'marks'),
where('orgId', '==', orgId),
where('courseId', '==', courseId),
orderBy('createdAt', 'desc')
)
Firestore will refuse to run this query until a (orgId, courseId, createdAt) index exists. The error message includes a direct link to create it, but on a fresh project this throws immediately in development and looks like a bug in your code.
The fix is to declare indexes in a version-controlled file so they deploy with the rest of the project:
// firestore.indexes.json
{
"indexes": [
{
"collectionGroup": "marks",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "orgId", "order": "ASCENDING" },
{ "fieldPath": "courseId", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "attendance",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "orgId", "order": "ASCENDING" },
{ "fieldPath": "date", "order": "ASCENDING" }
]
}
// ...
]
}
Then:
npx firebase deploy --only firestore:indexes
Now every environment has the same indexes. No more "works on my machine".
What I Learned
-
Design multi-tenancy before writing features. Retrofitting
orgIdinto a live app is brutal. Getting it right upfront made everything else easier. - Enforce isolation at the database, not the client. Security rules are not optional โ they are the whole point of building a multi-tenant product.
- Firestore composite indexes are a real constraint. They should live in version control alongside the schema, not be created ad-hoc from error messages.
- Component composition in React scales beautifully for dashboards. Three completely different UIs coexisting in one app turned out to be cleaner than I expected.
- No backend is a real architecture, not a shortcut. Removing the server did not remove the design work โ it just moved it to security rules, client-side access patterns, and index declarations.
Try It Out
- ๐ Live Demo: maniestacampus.netlify.app
- ๐ป Source Code: github.com/usmannmurtazaa/maniesta-campus-os
The RBAC behavior is the most interesting part to explore โ sign in with different roles and watch the navigation and available actions change.
What's Next
Some features I am working on:
- Email notifications for password reset and announcements
- Invite-based organization joining
- Student and parent portal with grade history
- File uploads for marksheets and documents
- Subscription plans and billing integration
- Enhanced analytics with exportable reports
About the Author
I am Usman Murtaza, a Full Stack Developer based in Karachi, Pakistan. I build modern web applications with React, Node.js, and Firebase, and I am the creator of the Maniesta ecosystem โ a collection of web products and utilities.
Maniesta Campus OS is one of those products. Others include Maniesta Resume AI, Maniesta Suite, and the Maniesta brand platform itself.
- ๐ Portfolio: usmanmurtaza.netlify.app
- ๐ป GitHub: github.com/Usmannmurtazaa
- ๐ผ LinkedIn: linkedin.com/in/Usmannmurtazaa
- ๐ฆ Twitter/X: @usman_murtazaa
- ๐ Dev.to: dev.to/usmanmurtaza
If you found this useful, follow me here on Dev.to โ I write about full-stack development, React patterns, and building products from scratch.
Built by Usman Murtaza โ see more projects at usmanmurtaza.netlify.app
Top comments (0)