Blog/Behind the Code/Full-Stack Architecture for a Classroom Management System
POST
August 15, 2025
LAST UPDATEDAugust 15, 2025

Full-Stack Architecture for a Classroom Management System

Architectural decisions behind a full-stack classroom management system built with Next.js and NestJS, covering multi-role access, real-time updates, assignment workflows, and grade tracking.

Tags

Next.jsNestJSPostgreSQLFull Stack
Full-Stack Architecture for a Classroom Management System
9 min read

Full-Stack Architecture for a Classroom Management System

TL;DR

I architected a full-stack classroom management system using Next.js for the frontend and NestJS for the backend, with Drizzle ORM and Neon PostgreSQL as the data layer. The platform serves three distinct user roles — Admin, Teacher, and Student — each with their own portal, permissions, and dashboard. The most interesting architectural decisions involved role-based layout composition, Server-Sent Events for real-time grade notifications, and ISR for dashboard pages that need fresh data without full SSR on every request.

The Challenge

An education startup needed a unified platform where administrators could manage schools, teachers could run their classrooms, and students could submit assignments and track grades. They had been cobbling together Google Classroom for assignments, a spreadsheet for grades, and email for announcements — a fragmented experience that frustrated all three user groups.

The core requirements:

  • Three role-based portals with entirely different navigation, dashboards, and feature sets
  • Admin portal: Manage schools, teachers, class schedules, and system-wide settings
  • Teacher portal: Create assignments, grade submissions, post announcements, view class analytics
  • Student portal: View assignments, submit work, check grades, see announcements
  • Real-time notifications for grade postings and upcoming deadlines
  • Assignment submission workflow with file uploads, inline feedback, and grading
  • Dashboard analytics showing attendance trends, grade distributions, and submission rates
  • Multi-tenant support where each school is an isolated tenant

The technical constraint was a small team (myself and one junior developer), so the architecture needed to be productive for a small team without sacrificing the ability to scale features later.

The Architecture

Role-Based Portal Architecture in Next.js

Rather than building three separate apps, I used Next.js App Router's layout nesting to create distinct portal experiences within a single application. Each role gets its own layout with role-specific navigation, theme accents, and dashboard widgets:

app/
├── (auth)/
│   ├── login/page.tsx
│   └── layout.tsx
├── (portal)/
│   ├── admin/
│   │   ├── layout.tsx        # Admin sidebar, admin nav
│   │   ├── dashboard/page.tsx
│   │   ├── schools/page.tsx
│   │   ├── teachers/page.tsx
│   │   └── settings/page.tsx
│   ├── teacher/
│   │   ├── layout.tsx        # Teacher sidebar, class nav
│   │   ├── dashboard/page.tsx
│   │   ├── assignments/page.tsx
│   │   ├── grades/page.tsx
│   │   └── announcements/page.tsx
│   ├── student/
│   │   ├── layout.tsx        # Student sidebar, course nav
│   │   ├── dashboard/page.tsx
│   │   ├── assignments/page.tsx
│   │   └── grades/page.tsx
│   └── layout.tsx            # Shared portal layout (auth check)

The shared portal layout handles authentication and role verification:

typescript
// app/(portal)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
 
export default async function PortalLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();
 
  if (!session) {
    redirect("/login");
  }
 
  return (
    <SessionProvider session={session}>
      <div className="min-h-screen bg-slate-50">{children}</div>
    </SessionProvider>
  );
}

Each role-specific layout adds its own navigation and guards against unauthorized access:

typescript
// app/(portal)/teacher/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { TeacherSidebar } from "@/components/teacher/TeacherSidebar";
 
export default async function TeacherLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();
 
  if (session?.user.role !== "teacher") {
    redirect(`/${session?.user.role}/dashboard`);
  }
 
  return (
    <div className="flex h-screen">
      <TeacherSidebar
        teacher={session.user}
        classes={await getTeacherClasses(session.user.id)}
      />
      <main className="flex-1 overflow-y-auto p-6">{children}</main>
    </div>
  );
}

This pattern means a student who somehow navigates to /teacher/dashboard gets redirected to their own dashboard automatically. The layout-level guard runs before any page content renders.

NestJS Backend with Domain-Driven Module Boundaries

The NestJS backend is organized into domain modules that map to business capabilities, not technical layers:

src/
├── auth/
│   ├── auth.module.ts
│   ├── auth.controller.ts
│   ├── auth.service.ts
│   ├── guards/
│   │   ├── jwt-auth.guard.ts
│   │   └── roles.guard.ts
│   └── decorators/
│       ├── current-user.decorator.ts
│       └── roles.decorator.ts
├── schools/
│   ├── schools.module.ts
│   ├── schools.controller.ts
│   └── schools.service.ts
├── classrooms/
│   ├── classrooms.module.ts
│   ├── classrooms.controller.ts
│   └── classrooms.service.ts
├── assignments/
│   ├── assignments.module.ts
│   ├── assignments.controller.ts
│   ├── assignments.service.ts
│   └── submissions/
│       ├── submissions.controller.ts
│       └── submissions.service.ts
├── grades/
│   ├── grades.module.ts
│   ├── grades.controller.ts
│   └── grades.service.ts
├── notifications/
│   ├── notifications.module.ts
│   ├── notifications.gateway.ts
│   └── notifications.service.ts
└── database/
    ├── database.module.ts
    └── schema/

Role-based access control uses a custom @Roles() decorator combined with a NestJS guard:

typescript
// auth/decorators/roles.decorator.ts
import { SetMetadata } from "@nestjs/common";
 
export type UserRole = "admin" | "teacher" | "student";
export const ROLES_KEY = "roles";
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
 
// auth/guards/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}
 
  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
      ROLES_KEY,
      [context.getHandler(), context.getClass()]
    );
 
    if (!requiredRoles) return true;
 
    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.includes(user.role);
  }
}

Controllers use the decorator to restrict access:

typescript
// grades/grades.controller.ts
@Controller("grades")
@UseGuards(JwtAuthGuard, RolesGuard)
export class GradesController {
  constructor(
    private gradesService: GradesService,
    private notificationsService: NotificationsService,
  ) {}
 
  @Post()
  @Roles("teacher")
  async submitGrade(@Body() dto: CreateGradeDto, @CurrentUser() teacher: User) {
    const grade = await this.gradesService.create(dto, teacher.id);
 
    // Notify the student in real time
    await this.notificationsService.sendToUser(dto.studentId, {
      type: "grade_posted",
      title: "New Grade Posted",
      message: `${teacher.fullName} graded your ${grade.assignmentTitle} submission`,
      link: `/student/grades/${grade.id}`,
    });
 
    return grade;
  }
 
  @Get("my-grades")
  @Roles("student")
  async getMyGrades(@CurrentUser() student: User) {
    return this.gradesService.findByStudent(student.id);
  }
 
  @Get("class/:classId")
  @Roles("teacher", "admin")
  async getClassGrades(@Param("classId") classId: string) {
    return this.gradesService.findByClass(classId);
  }
}

Drizzle ORM with Neon PostgreSQL

I chose Drizzle ORM for the same reasons as the monorepo project — it's TypeScript-native, has no code generation step, and the schemas are portable. Neon PostgreSQL provides serverless Postgres with branching, which is excellent for preview deployments.

The schema models the multi-tenant structure:

typescript
// database/schema/schools.ts
import { pgTable, uuid, varchar, timestamp } from "drizzle-orm/pg-core";
 
export const schools = pgTable("schools", {
  id: uuid("id").primaryKey().defaultRandom(),
  name: varchar("name", { length: 255 }).notNull(),
  slug: varchar("slug", { length: 100 }).notNull().unique(),
  domain: varchar("domain", { length: 255 }),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});
 
export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  fullName: varchar("full_name", { length: 255 }).notNull(),
  role: varchar("role", { length: 20, enum: ["admin", "teacher", "student"] }).notNull(),
  schoolId: uuid("school_id").references(() => schools.id).notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});
 
export const assignments = pgTable("assignments", {
  id: uuid("id").primaryKey().defaultRandom(),
  title: varchar("title", { length: 255 }).notNull(),
  description: text("description"),
  classroomId: uuid("classroom_id").references(() => classrooms.id).notNull(),
  teacherId: uuid("teacher_id").references(() => users.id).notNull(),
  dueDate: timestamp("due_date").notNull(),
  maxPoints: integer("max_points").notNull().default(100),
  status: varchar("status", { length: 20, enum: ["draft", "published", "closed"] })
    .notNull()
    .default("draft"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});
 
export const submissions = pgTable("submissions", {
  id: uuid("id").primaryKey().defaultRandom(),
  assignmentId: uuid("assignment_id").references(() => assignments.id).notNull(),
  studentId: uuid("student_id").references(() => users.id).notNull(),
  fileUrl: text("file_url").notNull(),
  fileName: varchar("file_name", { length: 255 }).notNull(),
  submittedAt: timestamp("submitted_at").notNull().defaultNow(),
  grade: integer("grade"),
  feedback: text("feedback"),
  gradedAt: timestamp("graded_at"),
  gradedBy: uuid("graded_by").references(() => users.id),
});

Drizzle's relational queries make it straightforward to fetch nested data without writing raw SQL joins:

typescript
// assignments/assignments.service.ts
async getAssignmentWithSubmissions(assignmentId: string) {
  return this.db.query.assignments.findFirst({
    where: eq(assignments.id, assignmentId),
    with: {
      submissions: {
        with: {
          student: {
            columns: { id: true, fullName: true, email: true },
          },
        },
        orderBy: [desc(submissions.submittedAt)],
      },
      teacher: {
        columns: { id: true, fullName: true },
      },
    },
  });
}

Real-Time Notifications with Server-Sent Events

I chose SSE over WebSockets for notifications because the communication is unidirectional — the server pushes events to clients, and clients never need to send data back through the same connection. SSE is simpler to implement, works through HTTP (no upgrade handshake), and reconnects automatically:

typescript
// notifications/notifications.controller.ts
@Controller("notifications")
@UseGuards(JwtAuthGuard)
export class NotificationsController {
  constructor(private notificationsService: NotificationsService) {}
 
  @Get("stream")
  @Sse()
  stream(@CurrentUser() user: User): Observable<MessageEvent> {
    return this.notificationsService.getStreamForUser(user.id);
  }
}
 
// notifications/notifications.service.ts
@Injectable()
export class NotificationsService {
  private userStreams = new Map<string, Subject<MessageEvent>>();
 
  getStreamForUser(userId: string): Observable<MessageEvent> {
    if (!this.userStreams.has(userId)) {
      this.userStreams.set(userId, new Subject<MessageEvent>());
    }
    return this.userStreams.get(userId)!.asObservable();
  }
 
  async sendToUser(userId: string, notification: NotificationPayload) {
    // Persist to database
    await this.db.insert(notifications).values({
      userId,
      ...notification,
    });
 
    // Push to SSE stream if user is connected
    const stream = this.userStreams.get(userId);
    if (stream) {
      stream.next({
        data: JSON.stringify(notification),
        type: "notification",
      } as MessageEvent);
    }
  }
}

On the frontend, a custom hook manages the SSE connection:

typescript
// hooks/useNotifications.ts
export function useNotifications() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
 
  useEffect(() => {
    const eventSource = new EventSource("/api/notifications/stream", {
      withCredentials: true,
    });
 
    eventSource.addEventListener("notification", (event) => {
      const notification = JSON.parse(event.data);
      setNotifications((prev) => [notification, ...prev]);
      // Show toast
      toast(notification.title, {
        description: notification.message,
      });
    });
 
    eventSource.onerror = () => {
      // EventSource automatically reconnects
      console.warn("SSE connection lost, reconnecting...");
    };
 
    return () => eventSource.close();
  }, []);
 
  return notifications;
}

ISR Dashboards

Dashboard pages show aggregated data — submission rates, grade distributions, attendance trends. Computing these on every request would be wasteful since the data doesn't change every second. I used Next.js ISR (Incremental Static Regeneration) to cache dashboard pages and revalidate them periodically:

typescript
// app/(portal)/teacher/dashboard/page.tsx
import { getTeacherDashboardData } from "@/lib/data/teacher";
 
export const revalidate = 300; // Revalidate every 5 minutes
 
export default async function TeacherDashboard() {
  const session = await getSession();
  const data = await getTeacherDashboardData(session!.user.id);
 
  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold">Dashboard</h1>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
        <StatCard title="Pending Submissions" value={data.pendingCount} />
        <StatCard title="Average Grade" value={`${data.averageGrade}%`} />
        <StatCard title="Active Assignments" value={data.activeAssignments} />
      </div>
      <GradeDistributionChart data={data.gradeDistribution} />
      <RecentSubmissions submissions={data.recentSubmissions} />
    </div>
  );
}

For data that needs to be more current (like the notification count in the nav bar), I use client-side fetching with SWR alongside the ISR page data. This hybrid approach gives fast initial page loads with fresh interactive data.

Key Decisions & Trade-offs

Next.js App Router route groups vs. separate apps: Route groups ((admin), (teacher), (student)) keep everything in one deployment while providing distinct layouts. The trade-off is a larger bundle, but Next.js code-splits per route, so students never download teacher-specific code.

SSE vs. WebSockets for notifications: SSE is simpler and sufficient for server-to-client notifications. If the platform later needed real-time collaborative features (like live document editing), WebSockets would be necessary. SSE was the right starting point for the notification use case.

Drizzle ORM vs. Prisma: Drizzle's lack of a generation step and its TypeScript-native schema definitions made it faster to iterate on schema changes. Prisma's migration tooling is more mature, but Drizzle Kit's push command was sufficient for this project's migration needs.

Neon PostgreSQL vs. self-hosted Postgres: Neon's serverless model and database branching feature were perfect for this project. Each preview deployment gets its own database branch, so PR reviewers can test with isolated data. The trade-off is vendor lock-in and slightly higher latency compared to a co-located self-hosted instance.

ISR vs. full SSR vs. client-side fetching for dashboards: ISR hits the sweet spot — dashboards don't need real-time data accuracy (5-minute-old grade averages are fine), but they should load fast. Full SSR would compute aggregations on every request, wasting resources. Client-side fetching would show loading spinners on every navigation.

Results & Outcomes

The platform consolidated three separate tools into a single experience tailored to each user role. Teachers could create assignments, receive submissions, grade them with inline feedback, and have students notified instantly — all without leaving the platform. Administrators had visibility across all classrooms and teachers, with dashboard analytics that updated throughout the day via ISR. The role-based architecture kept the codebase maintainable for a two-person team by sharing components and logic where roles overlapped (notifications, profiles) while isolating role-specific features in their own route groups. The SSE-based notification system provided genuine real-time feedback to students — seeing a grade notification appear without refreshing the page created a noticeably more engaging experience.

What I'd Do Differently

Implement CASL or a similar authorization library from the start. The custom @Roles() guard works for coarse-grained role checks, but as permissions became more granular (e.g., "teacher can only grade submissions for their own classes"), the guard logic grew complex. An authorization library like CASL would provide a declarative permission model that scales better.

Use database-level row security (RLS) for multi-tenancy. I implemented tenant isolation in the application layer, filtering by schoolId in every query. PostgreSQL's Row Level Security would push this to the database, making it impossible to accidentally leak data across tenants even if an application query forgets the filter.

Build the assignment workflow as a state machine. The assignment lifecycle (draft, published, submitted, graded, returned) was managed with conditional logic. A proper state machine library like XState would have made the valid transitions explicit and prevented invalid state changes.

Add optimistic updates for the grading workflow. Teachers grading multiple submissions in sequence experienced a slight delay after each grade submission while the SSE notification was sent and the UI refreshed. Optimistic updates would have made the grading flow feel instant.

FAQ

How do you handle multiple user roles in a classroom app?

We implemented role-based access control at two levels. On the backend, NestJS guards intercept every request and check the authenticated user's role against the roles required by the endpoint. A custom @Roles('teacher') decorator marks which roles can access a controller method, and the RolesGuard reads this metadata via reflection and compares it to the JWT payload. If the role doesn't match, the request is rejected with a 403 before the handler executes. On the frontend, Next.js App Router layouts check the user's role server-side and redirect unauthorized users. Each role has its own route group with a dedicated layout that renders role-specific navigation, dashboard widgets, and feature access. The role is also available client-side through a session context, which conditionally renders UI elements — for example, a "Grade" button on a submission card that only appears for teachers. The permissions matrix is stored in the database and loaded at login, so adding new roles or adjusting permissions doesn't require a code deployment.

How are real-time notifications implemented?

Server-Sent Events push grade updates, assignment deadlines, and announcements to connected clients. When a teacher grades a submission, the grades service calls the notification service, which persists the notification to the database and pushes it to the student's SSE stream if they are currently connected. On the frontend, an EventSource connection is opened when the user logs in and maintained throughout their session. Incoming events trigger both a toast notification and an update to the notification bell count in the navigation bar. SSE was chosen over WebSockets because the communication is primarily server-to-client — students and teachers receive notifications, but they never need to send data back through the notification channel. SSE connections are plain HTTP, so they work through corporate proxies and load balancers without special configuration. The browser also handles automatic reconnection natively — if the connection drops, EventSource reconnects with the last event ID, and the server replays any missed notifications.

How does the assignment submission workflow work?

Students upload assignments through a multi-step form that validates file type and size on the client side before uploading. Accepted files are uploaded directly to S3 using presigned URLs — the frontend requests a presigned URL from the API, uploads the file directly to S3 (avoiding the backend as a bottleneck), and then confirms the upload by sending the S3 key back to the API. The API creates a submission record in PostgreSQL linking the student, the assignment, and the S3 file location. Teachers see a review queue on their dashboard showing all pending submissions for each assignment. Clicking a submission opens an inline preview — PDFs render in the browser, images display directly, and other file types show a download link. Teachers can type feedback into a text area alongside the preview, assign a numerical grade, and submit. The grade and feedback are saved to the submission record, and a real-time notification is pushed to the student via SSE. The student sees the grade and feedback on their grades page, with the option to view the original submission and the teacher's comments side by side.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

Optimizing Core Web Vitals for e-Commerce
Mar 01, 202610 min read
SEO
Performance
Next.js

Optimizing Core Web Vitals for e-Commerce

Our journey to scoring 100 on Google PageSpeed Insights for a major Shopify-backed e-commerce platform.

Building an AI-Powered Interview Feedback System
Feb 22, 20269 min read
AI
LLM
Feedback

Building an AI-Powered Interview Feedback System

How we built an AI-powered system that analyzes mock interview recordings and generates structured feedback on communication, technical accuracy, and problem-solving approach using LLMs.

Migrating from Pages to App Router
Feb 15, 20268 min read
Next.js
Migration
Case Study

Migrating from Pages to App Router

A detailed post-mortem on migrating a massive enterprise dashboard from Next.js Pages Router to the App Router.