DEV Community

Usman Murtaza
Usman Murtaza

Posted on

How I Built Maniesta Campus OS: A Multi-Tenant Student Management SaaS with React and Firebase

 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          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Enter fullscreen mode Exit fullscreen mode

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:

  1. Multi-tenancy โ€” an institute called "Springfield Academy" must never see data belonging to "Riverside College".
  2. 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>,
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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" />;
  }
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

Adding a new admin-only page is now a single line:

<ProtectedRoute allowedRoles={['admin']}>
  <ManageFaculty />
</ProtectedRoute>
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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')
)
Enter fullscreen mode Exit fullscreen mode

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" }
      ]
    }
    // ...
  ]
}
Enter fullscreen mode Exit fullscreen mode

Then:

npx firebase deploy --only firestore:indexes
Enter fullscreen mode Exit fullscreen mode

Now every environment has the same indexes. No more "works on my machine".


What I Learned

  • Design multi-tenancy before writing features. Retrofitting orgId into 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

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.

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




Enter fullscreen mode Exit fullscreen mode

Top comments (0)