Blog/Behind the Code/Building a Role-Based Multi-Portal Platform
POST
January 18, 2026
LAST UPDATEDJanuary 18, 2026

Building a Role-Based Multi-Portal Platform

How we architected a multi-portal platform with role-based access for admins, vendors, and customers, sharing a single Next.js codebase with dynamic layouts and permission-driven UI rendering.

Tags

RBACMulti-PortalReactArchitecture
Building a Role-Based Multi-Portal Platform
8 min read

Building a Role-Based Multi-Portal Platform

TL;DR

I architected a multi-portal platform serving four distinct user roles — admin, teacher, student, and parent — from a single Next.js codebase. The system uses shared authentication with role detection, dynamic layout composition, permission guards enforced at both API and UI levels, and role-specific dashboards that render only what each user is authorized to see. No code duplication, no separate deployments, one codebase serving four experiences.

The Challenge

The client needed a learning management platform where different user types interact with fundamentally different interfaces. An admin manages users, configures system settings, and views analytics. A teacher creates courses, grades assignments, and tracks student progress. A student accesses course material, submits work, and views feedback. A parent monitors their child's progress and communicates with teachers.

The obvious approach — build four separate applications — would have been a maintenance disaster. Shared logic like authentication, notifications, and user profiles would be duplicated across four codebases. A bug fix in the notification system would require four separate deployments. And any shared UI component update would need to be synchronized across all four projects.

But a single codebase serving four portals introduces its own challenges. How do you prevent a student from accessing admin routes? How do you render different navigation, dashboards, and layouts based on the user's role without creating a spaghetti of conditional rendering? How do you enforce permissions at the API level so that even if someone bypasses the UI, they cannot access unauthorized data?

The platform also needed to handle edge cases like users with multiple roles (a teacher who is also a parent of a student in the system) and role-specific onboarding flows that guide each user type through their first experience.

The Architecture

Shared Authentication with Role Detection

All four user types authenticate through the same flow. After login, the system determines the user's role and redirects them to the appropriate portal. I used NextAuth.js with a custom session callback that embeds role information in the JWT.

typescript
// auth.config.ts
export const authOptions: NextAuthOptions = {
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role;
        token.permissions = await getPermissionsForRole(user.role);
        token.portalAccess = user.portalAccess; // ['admin', 'teacher'] for multi-role users
      }
      return token;
    },
    async session({ session, token }) {
      session.user.role = token.role as UserRole;
      session.user.permissions = token.permissions as Permission[];
      session.user.portalAccess = token.portalAccess as string[];
      return session;
    },
  },
};

For users with multiple roles, the system stores an array of accessible portals and presents a portal switcher after login. The active role determines the current permission set, and switching portals updates the session context without requiring re-authentication.

Middleware intercepts every request and validates that the user's active role has access to the requested route group:

typescript
// middleware.ts
export function middleware(request: NextRequest) {
  const session = getSessionFromToken(request);
  const pathname = request.nextUrl.pathname;
 
  // Extract portal from route: /admin/*, /teacher/*, /student/*, /parent/*
  const portal = extractPortal(pathname);
 
  if (portal && session) {
    const hasAccess = session.portalAccess.includes(portal);
    if (!hasAccess) {
      return NextResponse.redirect(new URL('/unauthorized', request.url));
    }
  }
 
  if (portal && !session) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ['/(admin|teacher|student|parent)/:path*'],
};

Role-Specific Layouts with Route Groups

Next.js route groups let me define separate layout hierarchies for each portal without duplicating the root layout. Each portal has its own sidebar, navigation, and dashboard structure.

app/
├── (auth)/
│   ├── login/page.tsx
│   └── layout.tsx          # Minimal auth layout
├── (admin)/
│   ├── admin/
│   │   ├── layout.tsx      # Admin sidebar + nav
│   │   ├── dashboard/page.tsx
│   │   ├── users/page.tsx
│   │   └── settings/page.tsx
├── (teacher)/
│   ├── teacher/
│   │   ├── layout.tsx      # Teacher sidebar + nav
│   │   ├── dashboard/page.tsx
│   │   ├── courses/page.tsx
│   │   └── grading/page.tsx
├── (student)/
│   └── student/
│       ├── layout.tsx      # Student sidebar + nav
│       ├── dashboard/page.tsx
│       └── courses/page.tsx
└── (parent)/
    └── parent/
        ├── layout.tsx      # Parent sidebar + nav
        └── dashboard/page.tsx

Each portal layout receives the same base components — Sidebar, TopNav, ContentArea — but configured with role-specific items:

typescript
// Navigation configuration per role
const navigationConfig: Record<UserRole, NavItem[]> = {
  admin: [
    { label: 'Dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
    { label: 'User Management', href: '/admin/users', icon: Users },
    { label: 'System Settings', href: '/admin/settings', icon: Settings },
    { label: 'Analytics', href: '/admin/analytics', icon: BarChart },
  ],
  teacher: [
    { label: 'Dashboard', href: '/teacher/dashboard', icon: LayoutDashboard },
    { label: 'My Courses', href: '/teacher/courses', icon: BookOpen },
    { label: 'Grading', href: '/teacher/grading', icon: ClipboardCheck },
    { label: 'Students', href: '/teacher/students', icon: Users },
  ],
  student: [
    { label: 'Dashboard', href: '/student/dashboard', icon: LayoutDashboard },
    { label: 'Courses', href: '/student/courses', icon: BookOpen },
    { label: 'Assignments', href: '/student/assignments', icon: FileText },
  ],
  parent: [
    { label: 'Dashboard', href: '/parent/dashboard', icon: LayoutDashboard },
    { label: 'Progress', href: '/parent/progress', icon: TrendingUp },
    { label: 'Messages', href: '/parent/messages', icon: MessageSquare },
  ],
};

Permission Guards at the UI Level

Route-level protection catches unauthorized page access, but many pages contain elements that should only be visible to certain permission levels. A teacher can view a course page, but only course creators can see the "Edit Course" button. I built a PermissionGate component that conditionally renders its children based on the current user's permissions.

tsx
interface PermissionGateProps {
  required: Permission | Permission[];
  mode?: 'all' | 'any'; // Must have all permissions or any one
  fallback?: React.ReactNode;
  children: React.ReactNode;
}
 
function PermissionGate({ required, mode = 'all', fallback = null, children }: PermissionGateProps) {
  const { permissions } = useSession();
  const requiredPerms = Array.isArray(required) ? required : [required];
 
  const hasPermission = mode === 'all'
    ? requiredPerms.every((p) => permissions.includes(p))
    : requiredPerms.some((p) => permissions.includes(p));
 
  if (!hasPermission) return <>{fallback}</>;
  return <>{children}</>;
}
 
// Usage
<PermissionGate required="course:edit" fallback={<ViewOnlyBadge />}>
  <EditCourseButton courseId={course.id} />
</PermissionGate>

This component is purely a UI convenience. It prevents buttons and forms from rendering for unauthorized users, but it does not provide security. Every action behind a PermissionGate is independently validated on the backend.

API-Level Permission Enforcement

The backend uses NestJS guards to enforce permissions on every API endpoint. A custom decorator attaches required permissions to route handlers, and a guard intercepts requests to verify authorization.

typescript
// permissions.decorator.ts
export const RequirePermissions = (...permissions: Permission[]) =>
  SetMetadata('permissions', permissions);
 
// permissions.guard.ts
@Injectable()
export class PermissionsGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private permissionService: PermissionService,
  ) {}
 
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const required = this.reflector.get<Permission[]>('permissions', context.getHandler());
    if (!required || required.length === 0) return true;
 
    const request = context.switchToHttp().getRequest();
    const user = request.user;
 
    const userPermissions = await this.permissionService.getPermissions(user.id);
    return required.every((p) => userPermissions.includes(p));
  }
}
 
// courses.controller.ts
@Put(':id')
@RequirePermissions('course:edit')
async updateCourse(@Param('id') id: string, @Body() dto: UpdateCourseDto) {
  return this.coursesService.update(id, dto);
}

The permission service caches the user's permissions in Redis with a TTL. When an admin changes a user's role or permissions, the cache entry is invalidated, forcing a fresh lookup on the next request. This avoids a database query on every API call while ensuring permission changes take effect quickly.

Dynamic Sidebar and Navigation

The sidebar is not just a static list of links — it adapts based on the user's role, their specific permissions within that role, and contextual state like unread notification counts. I built a sidebar configuration system that dynamically assembles navigation items.

typescript
function useSidebarItems(): NavItem[] {
  const { role, permissions } = useSession();
  const baseItems = navigationConfig[role];
 
  // Filter items based on granular permissions
  const filtered = baseItems.filter((item) => {
    if (!item.requiredPermission) return true;
    return permissions.includes(item.requiredPermission);
  });
 
  return filtered;
}

This approach means a teacher without grading permissions (perhaps a teaching assistant) would not see the "Grading" sidebar item at all, even though they are technically in the teacher portal. The sidebar reflects the actual capabilities of the current user, not just their role category.

Key Decisions & Trade-offs

Single codebase vs. separate apps. The single codebase dramatically reduced maintenance burden. Shared components, utilities, and business logic exist in one place. The trade-off is increased build complexity — the entire application must build and deploy together, even if only one portal changed. I mitigated this with careful code splitting so each portal only loads its own code.

Permission-based UI vs. role-based UI. I used granular permissions rather than role checks in the UI. Instead of if (role === 'admin'), components check if (hasPermission('users:manage')). This is more verbose but far more flexible — when the client later wanted a "super teacher" role with some admin capabilities, I only needed to assign additional permissions without changing any UI code.

JWT-embedded permissions vs. per-request lookups. Embedding permissions in the JWT avoids a database or cache lookup on every request, but it means permission changes do not take effect until the token refreshes. I set JWT expiry to 15 minutes and implemented a token refresh mechanism. For critical permission revocations (like deactivating a user), a blocklist check on the token ID ensures immediate effect.

Redis permission cache vs. database-only. The Redis cache adds infrastructure complexity but eliminates database load for permission checks, which happen on every authenticated API request. The cache invalidation on role changes ensures consistency. The trade-off is an additional failure point — if Redis goes down, the system falls back to direct database queries.

Results & Outcomes

The single-codebase approach delivered four distinct portal experiences with significantly less code than separate applications would have required. Shared components like data tables, form builders, and notification panels are written once and styled consistently across all portals.

The permission system proved its flexibility when the client introduced two new roles — "department head" (a teacher with analytics access) and "school admin" (limited admin without system settings). Both roles were created by combining existing permissions in the database without any code changes. The UI automatically adapted because it was built on permission checks, not role checks.

The middleware-based route protection caught several penetration testing attempts where testers tried to access admin routes with student tokens. Every attempt was redirected before reaching any page component, and the API guards independently blocked the corresponding API calls.

The portal switcher for multi-role users received positive feedback. Parents who are also teachers can switch between portals without logging out, maintaining separate contexts for each role. The session tracks both the available portals and the currently active one, making the switch seamless.

What I'd Do Differently

Define the permission matrix with the client upfront. I initially built a simple role-based system and later refactored to granular permissions when the client's requirements evolved. Starting with a permission matrix — even if the initial roles map cleanly to permission sets — would have saved the refactoring effort.

Use a dedicated RBAC library. I built the permission checking logic from scratch, which worked but required careful testing of edge cases. Libraries like CASL provide declarative permission definitions with built-in support for field-level access control and conditions. The custom implementation was unnecessary.

Implement feature flags alongside permissions. Permissions control who can do what, but feature flags control what exists at all. Combining both would have allowed gradual portal rollouts — launching the parent portal to 10% of parents while keeping the feature invisible to others.

Consider micro-frontends for larger teams. The single codebase works well with a small team, but with multiple teams working on different portals, a micro-frontend architecture (using Module Federation or similar) would provide better team autonomy while maintaining a unified user experience.

FAQ

How do you serve multiple portals from one codebase?

We used Next.js route groups and middleware to detect the user's role after authentication. Each role maps to a layout component that renders the appropriate navigation, sidebar, and dashboard. Shared components are reused while role-specific views are lazy-loaded.

How is RBAC implemented on the backend?

Permissions are stored as a matrix of roles and actions in the database. NestJS guards check the user's role against required permissions on every API endpoint. A permission cache in Redis prevents database lookups on every request while refreshing on role changes.

How do you prevent unauthorized UI from rendering?

A PermissionGate component wraps protected UI elements and checks the user's permissions before rendering children. This is a UI convenience only — the backend enforces all permissions independently, so bypassing the frontend gate cannot access protected data.

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.