Home / Uncategorized / How to Build a Multi-Tenant SaaS Application with Next.js (Frontend Integration) — SitePoint

How to Build a Multi-Tenant SaaS Application with Next.js (Frontend Integration) — SitePoint

In the first part of this article series, we implemented the backend with Appwrite, installed some dependencies, and set up Permit to handle authorization and role-based access control. 

Now let’s look at how we can integrate the frontend with the backend for a fully functional EdTech SaaS application.

Frontend Integration: Implementing Authorization in Next.js

Now that you have backend authorization in place using Permit, integrate it into your Next.js frontend. The frontend should:

  • Fetch user permissions from the backend to control what users can see and do.
  • Ensure API requests respect role-based access control (RBAC).
  • Hide UI elements for unauthorized users (e.g., prevent students from seeing “Create Assignment”).

1. Setting up API calls with authorization

Since only the backend enforces permissions, your frontend never decides access directly—instead, it:

  1. Sends requests to the backend
  2. Waits for the backend’s authorization response
  3. Displays data or UI elements accordingly

To get started, you’ll need to have Node.js installed on your computer.

Then, follow these steps, follow the steps below:

npx create-next-app@latest frontend
cd frontend

2. Initialize shadcn

What you’ll observe after the creation of your Nextjs project is that Tailwind CSS v4 is installed for you right out of the box, which means you don’t need to do anything else. Because we are making use of a component library, we are going to install Shadcn UI

To do that we need to run the init command to create a components.json file in the root of the folder:

After initialization, you can start adding components to your project:

npx shadcn@latest add button card dialog input label table select tabs

If asked, if you should use force because of the Nextjs 15 version compatibility with shadcn, hit enter to continue.

3. Install needed packages

Install the following packages:

npm i lucide-react zustand
npm i --save-dev axios

Now that we have installed all we need to build our application, we can start creating our other components and routes.

To maintain UI consistency throughout the application, paste this code into your global.css file (paste it below your tailwindcss import):

@layer base {
  :root {
    --background: 75 29% 95%;           
    --foreground: 0 0% 9%;              

    --card: 0 0% 100%;                  
    --card-foreground: 0 0% 9%;         

    --popover: 0 0% 99%;                
    --popover-foreground: 0 0% 9%;      

    --primary: 0 0% 0%;                 
    --primary-foreground: 60 100% 100%; 

    --secondary: 75 31% 95%;            
    --secondary-foreground: 0 0% 9%;    

    --muted: 69 30% 95%;                
    --muted-foreground: 0 0% 45%;       

    --accent: 252 29% 97%;              
    --accent-foreground: 0 0% 9%;       

    --destructive: 0 84.2% 60.2%;       
    --destructive-foreground: 0 0% 98%; 

    --border: 189 0% 45%;               
    --input: 155 0% 45%;                
    --ring: 0 0% 0%;                    

    --radius: 0.5rem;
  }
}

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
  }
}
body {
  font-family: Arial, Helvetica, sans-serif;
}

@layer base {
  * {
    @apply border-border outline-ring/50;
  }
  body {
    @apply bg-background text-foreground;
  }
}

4. Component files

Create the following component files and paste their corresponding code:

  • AddAssignmentDialog.tsx file:
"use client"

import type React from "react"

import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"
import { Assignment } from "@/types"

interface AddAssignmentDialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  onAddAssignment: (data: Assignment) => void
  creatorEmail: string
}

export function AddAssignmentDialog({ open, onOpenChange, onAddAssignment, creatorEmail }: AddAssignmentDialogProps) {
  const [title, setTitle] = useState("")
  const [subject, setSubject] = useState("")
  const [teacher, setTeacher] = useState("")
  const [className, setClassName] = useState("")
  const [dueDate, setDueDate] = useState("")

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    
    const newAssignment = { title, subject, teacher, className, dueDate, creatorEmail }
    onAddAssignment(newAssignment)
    console.log("New assignment:", { title, subject, class: className, dueDate, creatorEmail })
    onOpenChange(false)
  }

  return (
    Dialog open={open} onOpenChange={onOpenChange}>
      DialogContent>
        DialogHeader>
          DialogTitle>Add New Assignment/DialogTitle>
          DialogDescription>
            Enter the details of the new assignment here. Click save when you're done.
          /DialogDescription>
        /DialogHeader>
        form onSubmit={handleSubmit}>
          div className="grid gap-4 py-4">
            div className="grid grid-cols-4 items-center gap-4">
              Label htmlFor="title" className="text-right">
                Title
              /Label>
              Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} className="col-span-3" />
            /div>
            div className="grid grid-cols-4 items-center gap-4">
              Label htmlFor="subject" className="text-right">
                Subject
              /Label>
              Input id="subject" value={subject} onChange={(e) => setSubject(e.target.value)} className="col-span-3" />
            /div>
            div className="grid grid-cols-4 items-center gap-4">
              Label htmlFor="teacher" className="text-right">
                Teacher
              /Label>
              Input id="teacher" value={teacher} onChange={(e) => setTeacher(e.target.value)} className="col-span-3" />
            /div>
            div className="grid grid-cols-4 items-center gap-4">
              Label htmlFor="class" className="text-right">
                Class
              /Label>
              Input
                id="class"
                value={className}
                onChange={(e) => setClassName(e.target.value)}
                className="col-span-3"
              />
            /div>
            div className="grid grid-cols-4 items-center gap-4">
              Label htmlFor="dueDate" className="text-right">
                Due Date
              /Label>
              Input
                id="dueDate"
                type="date"
                value={dueDate}
                onChange={(e) => setDueDate(e.target.value)}
                className="col-span-3"
              />
            /div>
          /div>
          DialogFooter>
            Button type="submit">Save changes/Button>
          /DialogFooter>
        /form>
      /DialogContent>
    /Dialog>
  )
}

This file defines a React component, AddAssignmentDialog, which renders a dialog form for adding new assignments. It manages form state using useState and submits the assignment data to a parent component via the onAddAssignment prop. The dialog includes input fields for title, subject, teacher, class, and due date, and closes upon submission.

  • AddStudentDialog.tsx file:
'use client'

import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Student } from '@/types'

interface AddStudentDialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  onAddStudent: (data: Student) => void
  loading: boolean
  creatorEmail: string
}

export function AddStudentDialog({ open, onOpenChange, onAddStudent, loading, creatorEmail }: AddStudentDialogProps) {
  const [firstName, setFirstName] = useState('')
  const [lastName, setLastName] = useState('')
  const [className, setClassName] = useState('')
  const [gender, setGender] = useState('')
  const [age, setAge] = useState("")

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    
    onAddStudent({
      firstName,
      lastName,
      className,
      gender,
      age: Number(age),
      creatorEmail
    })
    console.log('New student:', { firstName, lastName, className, gender, age })
    onOpenChange(false)
  }

  return (
    Dialog open={open} onOpenChange={onOpenChange}>
      DialogContent>
        DialogHeader>
          DialogTitle>Add New Student/DialogTitle>
          DialogDescription>
            Enter the details of the new student here. Click save when you're done.
          /DialogDescription>
        /DialogHeader>
        form onSubmit={handleSubmit}>
          div className="grid gap-4 py-4">
            div className="grid grid-cols-4 items-center gap-4">
              Label htmlFor="firstName" className="text-right">
                First Name
              /Label>
              Input
                id="firstName"
                value={firstName}
                onChange={(e) => setFirstName(e.target.value)}
                className="col-span-3"
              />
            /div>
            div className="grid grid-cols-4 items-center gap-4">
              Label htmlFor="lastName" className="text-right">
                Last Name
              /Label>
              Input
                id="lastName"
                value={lastName}
                onChange={(e) => setLastName(e.target.value)}
                className="col-span-3"
              />
            /div>
            div className="grid grid-cols-4 items-center gap-4">
              Label htmlFor="class" className="text-right">
                Class
              /Label>
              Input
                id="class"
                value={className}
                onChange={(e) => setClassName(e.target.value)}
                className="col-span-3"
              />
            /div>
            div className="grid grid-cols-4 items-center gap-4">
              Label htmlFor="gender" className="text-right">
                Gender
              /Label>
              Select onValueChange={setGender} value={gender}>
                SelectTrigger className="col-span-3">
                  SelectValue placeholder="Select gender" />
                /SelectTrigger>
                SelectContent>
                  SelectItem value="boy">Boy/SelectItem>
                  SelectItem value="girl">Girl/SelectItem>
                /SelectContent>
              /Select>
            /div>
            div className="grid grid-cols-4 items-center gap-4">
              Label htmlFor="age" className="text-right">
                age
              /Label>
              Input
                id="age"
                type="number"
                step="0.1"
                value={age}
                min={"4"}
                max={"99"}
                placeholder='enter a valid age'
                onChange={(e) => setAge(e.target.value)}
                className="col-span-3"
              />
            /div>
          /div>
          DialogFooter>
            Button disabled={loading} type="submit">{loading ? "Saving..." : "Save Changes"}/Button>
          /DialogFooter>
        /form>
      /DialogContent>
    /Dialog>
  )
}

This file defines a React component, AddStudentDialog, which renders a dialog form for adding new students. It manages form state using useState and submits the student data to a parent component via the onAddStudent prop. The dialog includes input fields for first name, last name, class, gender (with a dropdown), and age, and handles loading states during submission.

  • AssignmentsTable.tsx file:
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { AssignmentsTable } from "@/types"

export function AssignmentsTables({ assignments }: { assignments: AssignmentsTable[] }) {
  console.log("Assignments", assignments)

  return (
    Table>
      TableCaption>A list of recent assignments./TableCaption>
      TableHeader>
        TableRow>
          TableHead>Title/TableHead>
          TableHead>Subject/TableHead>
          TableHead>Class/TableHead>
          TableHead>Teacher/TableHead>
          TableHead>Due Date/TableHead>
        /TableRow>
      /TableHeader>
      TableBody>
        {assignments.map((assignment) => (
          TableRow key={assignment.$id}>
            TableCell>{assignment.title}/TableCell>
            TableCell>{assignment.subject}/TableCell>
            TableCell>{assignment.className}/TableCell>
            TableCell>{assignment.teacher}/TableCell>
            TableCell>{assignment.dueDate}/TableCell>
          /TableRow>
        ))}
      /TableBody>
    /Table>
  )
}

This file defines a React component, AssignmentsTables, which renders a table to display a list of assignments. It takes an array of assignments as props and maps through them to populate the table rows with details like title, subject, class, teacher, and due date. The table includes a caption and headers for better readability.

import type React from "react"

interface AuthLayoutProps {
    children: React.ReactNode
    title: string
    description?: string
}

export function AuthLayout({ children, title, description }: AuthLayoutProps) {
    return (
        div className="min-h-screen grid lg:grid-cols-2">
            {}
            div className="flex items-center justify-center p-8">
                div className="mx-auto w-full max-w-sm space-y-6">
                    div className="space-y-2 text-center">
                        h1 className="text-3xl font-bold tracking-tight">{title}/h1>
                        {description && p className="text-sm text-muted-foreground">{description}/p>}
                    /div>
                    {children}
                /div>
            /div>

            {}
            div className="hidden lg:block relative bg-black">
                div className="absolute inset-0 bg-[url('https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-xOOAKcDxPyvxlDygdNGtUvjEA6QHBO.png')] bg-cover bg-center opacity-50" />
                div className="relative h-full flex items-center justify-center text-white p-12">
                    div className="space-y-6 max-w-lg">
                        h2 className="text-4xl font-bold">Keep Your Children's Success/h2>
                        p className="text-lg text-gray-200">
                            Connect with teachers, track progress, and stay involved in your child's education journey.
                        /p>
                    /div>
                /div>
            /div>
        /div>
    )
}

This file defines a React component, AuthLayout, which provides a layout for authentication pages. It includes a left side for forms (with a title and optional description) and a right side with a background image and motivational text. The layout is responsive, hiding the image on smaller screens.

import { Book, BarChart, MessageCircle } from "lucide-react"

const features = [
  {
    name: "Comprehensive Dashboard",
    description: "View student's overall academic performance, including average grades and progress over time.",
    icon: BarChart,
  },
  {
    name: "Easy Communication",
    description: "Direct messaging system between school administrators and teachers for quick and efficient communication.",
    icon: MessageCircle,
  },
  {
    name: "Academic Tracking",
    description:
      "Monitor assignments, upcoming tests, and project deadlines to help your students stay on top of their studies.",
    icon: Book,
  },
]

export function Features() {
  return (
    div className="py-12 bg-white" id="features">
      div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        div className="lg:text-center">
          h2 className="text-base text-primary font-semibold tracking-wide uppercase">Features/h2>
          p className="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl">
            Everything you need to stay connected
          /p>
          p className="mt-4 max-w-2xl text-xl text-gray-500 lg:mx-auto">
            Our platform offers a range of features designed to enhance communication between school administrators and teachers.
          /p>
        /div>

        div className="mt-10">
          dl className="space-y-10 md:space-y-0 md:grid md:grid-cols-3 md:gap-x-8 md:gap-y-10">
            {features.map((feature) => (
              div key={feature.name} className="relative">
                dt>
                  div className="absolute flex items-center justify-center h-12 w-12 rounded-md bg-primary text-white">
                    feature.icon className="h-6 w-6" aria-hidden="true" />
                  /div>
                  p className="ml-16 text-lg leading-6 font-medium text-gray-900">{feature.name}/p>
                /dt>
                dd className="mt-2 ml-16 text-base text-gray-500">{feature.description}/dd>
              /div>
            ))}
          /dl>
        /div>
      /div>
    /div>
  )
}

This file defines a React component, Features, which showcases key platform features in a visually appealing layout. It includes a title, description, and a grid of feature cards, each with an icon, name, and detailed description. The component is designed to highlight the platform’s capabilities for school administrators and teachers.

This file defines a React component, Footer, which displays a simple footer with social media icons (Facebook and Twitter) and a copyright notice. The footer is centered and responsive, with social links on the right and the copyright text on the left for larger screens.

This file defines a React component, Hero, which creates a visually engaging hero section for a website. It includes a bold headline, a descriptive paragraph, and two call-to-action buttons (“Get started” and “Learn more”). The layout features a responsive design with a background shape and an image on the right side for larger screens.

This file defines a React component, MobileMenu, which creates a responsive mobile navigation menu. It toggles visibility with a button and includes links to features, about, and contact sections, as well as login and sign-up buttons. The menu is styled with a clean, modern design and closes when clicking the close icon.

This file defines a React component, Navbar, which creates a responsive navigation bar with links to features, about, and contact sections. It includes login and sign-up buttons for larger screens and integrates a MobileMenu component for smaller screens. The navbar is styled with a shadow and a centered layout.

  • NotAuthorizedDialog.tsx file:

This file defines a React component, NotAuthorizedDialog, which displays a dialog when a user is not authorized to perform an action. It includes a title and description prompting the user to contact an administrator, and its visibility is controlled via the open and onOpenChange props.

This file defines a React component, StudentsTables, which renders a table to display a list of students. It takes an array of students as props and maps through them to populate the table rows with details like first name, last name, class, gender, and age. The table includes a caption and headers for better readability.

Refer to the GitHub code for the respective code of the components mentioned above.

State management and types

Now for the next step, we’ll be creating the state and types we’ll be using throughout the application. Create the store and types folders in the root of the project folder.

  • Inside the store folder, create the following files and paste the corresponding code:
import { create } from "zustand"
import { persist } from "zustand/middleware"

interface User {
  $id: string
  firstName: string
  lastName: string
  email: string
}

interface AuthState {
  user: User | null
  setUser: (user: User | null) => void
  token: string | null;
  setToken: (token: string | null) => void;
  logout: () => void
}

export const useAuthStore = create()(
  persist(
    (set) => ({
      user: null,
      setUser: (user) => set({ user }),
      token: null,
      setToken: (token) => set({ token }),
      logout: () => set({ user: null }),
    }),
    {
      name: "auth-storage", // Persist state in localStorage
    }
  )
)

This file defines a Zustand store, useAuthStore, for managing authentication state. It includes user and token states, along with methods to set the user, set the token, and log out. The state is persisted in localStorage using the persist middleware.

import { create } from "zustand";
import { persist } from "zustand/middleware";

interface Profile {
  firstName: string;
  lastName: string;
  email: string;
  role: string;
  userId: string;
  $id: string;
  $createdAt: string;
}

interface ProfileStore {
  profile: Profile | null;
  setProfile: (profile: Profile) => void;
  clearProfile: () => void;
}

export const useProfileStore = createProfileStore>()(
  persist(
    (set) => ({
      profile: null,
      setProfile: (profile) => set({ profile }),
      clearProfile: () => set({ profile: null }),
    }),
    {
      name: "profile-storage", 
    }
  )
);

This file defines a Zustand store, useProfileStore, for managing user profile data. It includes a profile state and methods to set and clear the profile. The state is persisted in localStorage using the persist middleware.

  • Inside the types folder, create the following file and paste the following code in the index.ts file:
export interface Assignment {
  title: string;
  subject: string;
  className: string;
  teacher: string;
  dueDate: string;
  creatorEmail: string;
}

export interface AssignmentsTable extends Assignment {
  $id: string;
  }

export interface Student {
  firstName: string;
  lastName: string;
  gender: string;
  className: string;
  age: number;
  creatorEmail: string;
}

export interface StudentsTable extends Student {
  $id: string;
}

This file defines TypeScript interfaces for Assignment, AssignmentsTable, Student, and StudentsTable. It extends the base Assignment and Student interfaces with additional properties like $id for database records, ensuring consistent typing across the application.

Routes

Now we get to see how the components and store we just created are being used in the application.

Replace the code in the app/page.tsx file with the code below:

import { Navbar } from "@/components/Navbar"
import { Hero } from "@/components/Hero"
import { Features } from "@/components/Features"
import { Footer } from "@/components/Footer"

export default function Home() {
  return (
    div className="min-h-screen flex flex-col">
      Navbar />
      main className="flex-grow">
        Hero />
        Features />
      /main>
      Footer />
    /div>
  )
}

This file defines the main home page component, which structures the layout using Navbar, Hero, Features, and Footer components. It ensures a responsive design with a flex layout and full-page height.

Create the following folders in the app folder and paste this code in their respective page.tsx files:

  • Create a signup folder and paste this code in its page.tsx file:
"use client"

import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { AuthLayout } from "@/components/auth-layout"
import { useAuthStore } from "@/store/auth" 

export default function SignupPage() {
  const router = useRouter()
  const { setUser, setToken } = useAuthStore() 
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useStatestring | null>(null)

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setIsLoading(true);
    setError(null);

    const formData = new FormData(e.currentTarget as HTMLFormElement);
    const userData = {
        name: `${formData.get("firstName")} ${formData.get("lastName")}`,
        email: formData.get("email"),
        password: formData.get("password"),
    };

    try {
        const response = await fetch("https://edtech-saas-backend.vercel.app/api/auth/signup", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(userData),
        });

        const result = await response.json();

        if (!response.ok || !result.success) {
            throw new Error("Signup failed. Please try again.");
        }

        console.log("Signup successful:", result);

        
        const [firstName, ...lastNameParts] = result.user.name.split(" ");
        const lastName = lastNameParts.join(" ") || ""; 

        
        setUser({
            $id: result.user.$id,
            firstName,
            lastName,
            email: result.user.email,
        });
        setToken(result.token);
        console.log("User:", result.user);
        console.log("Token:", result.token)
        router.push("/role-selection");
    } catch (err) {
        const error = err as Error;
        setError(error.message || "An error occurred");
        console.error("Error:", error);
    } finally {
        setIsLoading(false);
    }
}

  return (
    AuthLayout title="Create an account" description="Enter your details to get started">
      form onSubmit={onSubmit} className="space-y-4">
        div className="grid gap-4 grid-cols-2">
          div className="space-y-2">
            Label htmlFor="firstName">First name/Label>
            Input name="firstName" id="firstName" placeholder="John" disabled={isLoading} required />
          /div>
          div className="space-y-2">
            Label htmlFor="lastName">Last name/Label>
            Input name="lastName" id="lastName" placeholder="Doe" disabled={isLoading} required />
          /div>
        /div>
        div className="space-y-2">
          Label htmlFor="email">Email/Label>
          Input name="email" id="email" placeholder="[email protected]" type="email" autoComplete="email" disabled={isLoading} required />
        /div>
        div className="space-y-2">
          Label htmlFor="password">Password/Label>
          Input name="password" id="password" type="password" disabled={isLoading} required />
        /div>
        {error && p className="text-red-500 text-sm">{error}/p>}
        Button className="w-full" type="submit" disabled={isLoading}>
          {isLoading ? "Creating account..." : "Create account"}
        /Button>
      /form>
      div className="text-center text-sm">
        Link href="/login" className="underline underline-offset-4 hover:text-primary">
          Already have an account? Sign in
        /Link>
      /div>
    /AuthLayout>
  )
}

This file defines a SignupPage component for user registration, handling form submission with validation and error handling. It uses Zustand to store user data and a token upon successful signup, then redirects to a role selection page. The form includes fields for first name, last name, email, and password, with a link to the login page for existing users.

  • Create a role-selection folder and paste this code in its page.tsx file:
"use client"

import { useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { GraduationCap, Users } from "lucide-react"
import { useAuthStore } from "@/store/auth"
import { useProfileStore } from "@/store/profile"

const roles = [
  {
      id: "Admin",
      title: "Admin",
      description: "Manage teachers, classes, and more",
      icon: GraduationCap,
  },
  {
    id: "Teacher",
    title: "Teacher",
    description: "Access your class dashboard, manage grades, and communicate with students",
    icon: GraduationCap,
  },
  {
    id: "Student",
    title: "Student",
    description: "Monitor your progress and communicate with teachers",
    icon: Users,
  },
]

export default function RoleSelectionPage() {
  const { user, token } = useAuthStore()
  const { setProfile } = useProfileStore()
  console.log("User:", user);
  const router = useRouter()
  const [selectedRole, setSelectedRole] = useStatestring | null>(null)
  console.log("Selected Role:", selectedRole);
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useStatestring | null>(null)

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault()
    if (!selectedRole || !user) return
    setIsLoading(true)
    setError(null)

    const formattedRole =
      selectedRole.charAt(0).toUpperCase() + selectedRole.slice(1).toLowerCase(); 

    const payload = {
      firstName: user?.firstName,
      lastName: user?.lastName,
      email: user?.email,
      role: formattedRole,
      userId: user?.$id,
    }
    console.log("Payload", payload)

    try {
      const response = await fetch("https://edtech-saas-backend.vercel.app/api/profile", {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${token}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify(payload),
      })

      const data = await response.json()
      if (!response.ok) {
        throw new Error(data.message || "Failed to create profile")
      }
      console.log("Profile Data", data)
      setProfile({
        firstName: data?.user?.firstName,
        lastName: data?.user?.lastName,
        email: data?.user?.email,
        role: data?.user?.role,
        userId: data?.user?.userId,
        $id: data?.user?.$id,
        $createdAt: data?.user?.$createdAt,
      })
      router.push("/dashboard")
    } catch (err) {
      const error = err as Error
      setError(error.message)
      console.error("Error:", error)
    } finally {
      setIsLoading(false)
    }
  }

  return (
      div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
        div className="max-w-md w-full space-y-8">
          div className="text-center space-y-2">
            h1 className="text-3xl font-bold">Select your role/h1>
            p className="text-gray-500">Choose your role to access the appropriate dashboard/p>
          /div>
          {error && p className="text-red-500 text-center">{error}/p>}
          form onSubmit={onSubmit} className="space-y-4">
            div className="grid gap-4">
              {roles.map((role) => {
                const Icon = role.icon
                return (
                    Card
                    key={role.id}
                    className={`cursor-pointer transition-colors ${selectedRole === role.id ? "border-black" : ""}`}
                    onClick={() => setSelectedRole(role.title)}
                    >
                      CardContent className="flex items-start gap-4 p-6">
                        div className="rounded-full p-2 bg-gray-100">
                          Icon className="h-6 w-6" />
                        /div>
                        div className="space-y-1">
                          h3 className="font-medium">{role.title}/h3>
                          p className="text-sm text-gray-500">{role.description}/p>
                        /div>
                      /CardContent>
                    /Card>
                )
              })}
            /div>

            Button className="w-full" type="submit" disabled={!selectedRole || isLoading}>
              {isLoading ? "Confirming..." : "Continue"}
            /Button>
          /form>
        /div>
      /div>
  )
}

This file defines a RoleSelectionPage component where users select their role (Admin, Teacher, or Student) after signing up. It handles role selection, submits the data to create a profile, and redirects to the dashboard upon success. The UI includes cards for each role, a confirmation button, and error handling.

  • Create a login folder and paste this code in its page.tsx file:
"use client";

import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AuthLayout } from "@/components/auth-layout";
import { useAuthStore } from "@/store/auth";
import { useProfileStore } from "@/store/profile";

export default function LoginPage() {
  const router = useRouter();
  const [isLoading, setIsLoading] = useState(false);
  const { setUser, setToken } = useAuthStore() 
  const [formData, setFormData] = useState({ email: "", password: "" });
  const [error, setError] = useStatestring | null>(null)

  const handleChange = (e: React.ChangeEventHTMLInputElement>) => {
    setFormData({ ...formData, [e.target.name]: e.target.value });
  };

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setIsLoading(true);
    setError(null);
 
    console.log("FormData", formData);
 
    try {
      
      const authResponse = await fetch("https://edtech-saas-backend.vercel.app/api/auth/login", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(formData),
      });
 
      if (!authResponse.ok) throw new Error("Invalid credentials");
 
      const authData = await authResponse.json();
      console.log("Auth Result:", authData);
 
      const token = authData.token;
      setToken(token);
 
      setUser({
        $id: authData.session.$id,
        firstName: "",
        lastName: "",
        email: authData.session.providerUid,
      });
 
      
      const profileResponse = await fetch(`https://edtech-saas-backend.vercel.app/api/profile/${formData.email}`, {
        method: "GET",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json",
        },
      });
 
      if (!profileResponse.ok) throw new Error("Failed to fetch user profile");
 
      const profileData = await profileResponse.json();
      console.log("Profile Data:", profileData);
 
      if (profileData.profile) {
        
        useProfileStore.getState().setProfile(profileData.profile);
        router.push("/dashboard");
      } else {
        router.push("/role-selection");
      }
    } catch (err) {
      const error = err as Error;
      setError(error.message || "An error occurred");
    } finally {
      setIsLoading(false);
    }
  }
 

  return (
    AuthLayout title="Welcome back" description="Enter your credentials to access your account">
      form onSubmit={onSubmit} className="space-y-4">
        div className="space-y-2">
          Label htmlFor="email">Email/Label>
          Input
            id="email"
            name="email"
            placeholder="[email protected]"
            type="email"
            autoCapitalize="none"
            autoComplete="email"
            autoCorrect="off"
            disabled={isLoading}
            required
            onChange={handleChange}
          />
        /div>
        div className="space-y-2">
          Label htmlFor="password">Password/Label>
          Input
            id="password"
            name="password"
            type="password"
            disabled={isLoading}
            required
            onChange={handleChange}
          />
        /div>
        {error && p className="text-red-500 text-sm">{error}/p>}
        Button className="w-full" type="submit" disabled={isLoading}>
          {isLoading ? "Signing in..." : "Sign in"}
        /Button>
      /form>
      div className="text-center text-sm">
        Link href="/signup" className="underline underline-offset-4 hover:text-primary">
          Don't have an account? Sign up
        /Link>
      /div>
    /AuthLayout>
  );
}

This file defines a LoginPage component for user authentication, handling form submission with email and password. It uses Zustand to store user data and a token, fetches the user’s profile, and redirects to the dashboard or role selection page based on the profile status. The form includes error handling and a link to the signup page for new users.

  • Create a dashboard folder and paste this code in its page.tsx file:
"use client";

import { useState, useEffect } from "react";
import { StudentsTables } from "@/components/StudentsTable";
import { Button } from "@/components/ui/button";
import { NotAuthorizedDialog } from "@/components/NotAuthorizedDialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuthStore } from "@/store/auth";
import { useProfileStore } from "@/store/profile";
import { AddStudentDialog } from "@/components/AddStudentDialog";
import { AddAssignmentDialog } from "@/components/AddAssignmentDialog";
import {Assignment,  AssignmentsTable, Student, StudentsTable } from "@/types";
import { AssignmentsTables } from "@/components/AssignmentsTable";
import axios from "axios";

export default function TeacherDashboard() {
  const { token, logout } = useAuthStore();
  const { profile, clearProfile } = useProfileStore();
  const [isNotAuthorizedDialogOpen, setIsNotAuthorizedDialogOpen] = useState(false);
  const [isAddStudentDialogOpen, setIsAddStudentDialogOpen] = useState(false);
  const [isAddAssignmentDialogOpen, setIsAddAssignmentDialogOpen] = useState(false);
  const [students, setStudents] = useStateStudentsTable[]>([]);
  const [assignments, setAssignments] = useStateAssignmentsTable[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");
 

  const API_URL_STUDENTS = "https://edtech-saas-backend.vercel.app/api/students";
  const API_URL_ASSIGNMENTS = "https://edtech-saas-backend.vercel.app/api/assignments/create";

 

  async function fetchData() {
    setLoading(true);
    setError("");
 
    const headers = {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    };
 
    const email = profile?.email;
    if (!email) {
      setError("Email is required");
      return;
    }
 
    
    const studentsUrl = `https://edtech-saas-backend.vercel.app/api/students/${email}`;
    const assignmentsUrl = `https://edtech-saas-backend.vercel.app/api/assignments/${email}`;
 
    
    try {
      const studentsRes = await axios.get(studentsUrl, { headers });
      console.log("Students Data:", studentsRes.data);
      setStudents(studentsRes.data);
    } catch (err) {
      console.warn("Failed to fetch students data:", err);
      setStudents([]); 
    }
 
    
    try {
      const assignmentsRes = await axios.get(assignmentsUrl, { headers });
      console.log("Assignments Data:", assignmentsRes.data);
      setAssignments(assignmentsRes.data);
    } catch (err) {
      console.error("Error fetching assignments data:", err);
      setError((err as Error).message);
    } finally {
      setLoading(false);
    }
  }
 
 
 
  useEffect(() => {
    if (!token) return;

    fetchData();
  }, [token]);

    const handleAddStudent = async (data: OmitStudent, 'creatorEmail'>) => {
    setLoading(true);
    setError("");
 
    const payload = {
      firstName: data.firstName,
      lastName: data.lastName,
      gender: data.gender,
      className: data.className,
      age: data.age,
      creatorEmail: profile?.email,
    };
    console.log("Students payload:", payload);
 
    try {
      const response = await fetch(API_URL_STUDENTS, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(payload),
      });
 
      const result = await response.json(); 
      console.log("Student Result", result);
 
      if (response.status === 403 && result.message === "Not authorized") {
        setIsAddStudentDialogOpen(false);
        setIsNotAuthorizedDialogOpen(true);
        return; 
      }
 
      if (!response.ok) throw new Error(result.message || "Failed to add student");
 
      setStudents((prevStudents: Student[]) => [...prevStudents, result]); 
      setIsAddStudentDialogOpen(false);
      await fetchData();
    } catch (err) {
      if ((err as Error & { code?: number }).code === 403 && (err as Error).message === "Not authorized") {
        setIsAddStudentDialogOpen(false);
        setIsNotAuthorizedDialogOpen(true);
        return;
      }
      setError((err as Error).message);
      console.error("Error:", err);
    } finally {
      setLoading(false);
    }
  };
 

    const handleAddAssignment = async (data: Assignment) => {
    setLoading(true);
    setError("");
 
    const payload = {
      title: data.title,
      subject: data.subject,
      className: data.className,
      teacher: data.teacher,
      dueDate: data.dueDate,
      creatorEmail: profile?.email,
    };
 
    try {
      const response = await fetch(API_URL_ASSIGNMENTS, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(payload),
      });
 
      const result = await response.json(); 
 
      if (response.status === 403 && result.message === "Not authorized") {
        setIsAddAssignmentDialogOpen(false);
        setIsNotAuthorizedDialogOpen(true);
        return; 
      }
 
      if (!response.ok) throw new Error(result.message || "Failed to add assignment");
 
      setAssignments((prevAssignments: Assignment[]) => [...prevAssignments, result]); 
      setIsAddAssignmentDialogOpen(false);
    } catch (err) {
      if ((err as Error & { code?: number }).code === 403 && (err as Error).message === "Not authorized") {
        setIsAddAssignmentDialogOpen(false);
        setIsNotAuthorizedDialogOpen(true);
        return;
      }
      setError((err as Error).message);
      console.error("Error:", err);
    } finally {
      setLoading(false);
    }
  };

  const handleLogout = () => {
    clearProfile();
    logout();
    window.location.href = "/login";
  };

  return (
    div className="container mx-auto p-4">
      div className="flex items-center justify-between mb-4">
        div>
          h1 className="text-2xl font-bold mb-2">Welcome {profile?.firstName}/h1>
          p className="text-gray-600 mb-6">
            You are logged in as {profile?.role === "Admin" ? "an" : "a"} {profile?.role}.
          /p>
        /div>
        Button variant="default" onClick={handleLogout}>Log out/Button>
      /div>

      {profile?.role === 'Student'
      ?  (
        div>
          AssignmentsTables assignments={assignments} />
        /div>
        )
        : (
          Tabs defaultValue="students" className="w-full">
            TabsList className="grid w-full grid-cols-2">
              TabsTrigger value="students">Students/TabsTrigger>
              TabsTrigger value="assignments">Assignments/TabsTrigger>
            /TabsList>

            TabsContent value="students">
              StudentsTables students={students} />
              Button onClick={() => setIsAddStudentDialogOpen(true)}>Add a Student/Button>
            /TabsContent>

            TabsContent value="assignments">
              AssignmentsTables assignments={assignments} />
              Button onClick={() => setIsAddAssignmentDialogOpen(true)}>Add Assignment/Button>
            /TabsContent>
          /Tabs>
        )}

      {error && p className="text-red-500 mt-4">{error}/p>}

      NotAuthorizedDialog open={isNotAuthorizedDialogOpen} onOpenChange={setIsNotAuthorizedDialogOpen} />
      AddStudentDialog creatorEmail={profile?.email || ""} loading={loading} open={isAddStudentDialogOpen} onOpenChange={setIsAddStudentDialogOpen} onAddStudent={handleAddStudent} />
      AddAssignmentDialog creatorEmail={profile?.email || ""} open={isAddAssignmentDialogOpen} onOpenChange={setIsAddAssignmentDialogOpen} onAddAssignment={handleAddAssignment} />
    /div>
  );
}

This file defines a TeacherDashboard component that displays a dashboard for teachers or admins, allowing them to manage students and assignments. It includes tabs for switching between students and assignments, buttons to add new entries, and handles authorization errors. The component fetches and displays data based on the user’s role, with a logout option and error handling.

After creating all the files and components above and using them as I have shown you, your application should work when you run this command below:

The app will be available at http://localhost:3000/.

Test out the application now by creating a school, signing up and logging in as an admin, teacher or student, and performing some actions.

Building a multi-tenant EdTech SaaS application with Next.js, Appwrite, and Permit provided several insights into authorization, security, and scalability. Here are the key takeaways:

  • Simplified Role-Based Access Control (RBAC): With Permit, defining and enforcing admin, teacher, and student roles was straightforward. Instead of hardcoding permissions, I could dynamically manage them via the Permit UI.
  • Permit’s tenant-aware policies ensured that schools (tenants) remained isolated from one another. This was important for data security in a multi-tenant SaaS app.
  • Instead of writing and managing custom permission logic across dozens of API routes, Permit handled access control in a centralized way to reduce complexity and make future updates easier.
  • Since all authorization checks were enforced at the backend, the frontend only displayed UI elements based on permissions, ensuring a smooth user experience.
  • Implementing custom authentication from scratch could have taken weeks. But using Appwrite for authentication and Permit for authorization, I was able to focus on building core features instead of reinventing access control.

Conclusion

Integrating Permit with Next.js & Appwrite enabled me to simplify authorization in my multi-tenant Edtech SaaS application. By offloading complex permission logic to Permit, I was able to focus on building features, not managing access control manually.

If you’re building a SaaS app with complex permissions & multi-tenancy, Permit is a great tool to use to streamline your workflow.

Access the GitHub repo of the finished project for the backend here and the frontend here.

Leave a Reply

Your email address will not be published. Required fields are marked *