


I built a functional multi-tenant SaaS application (an EdTech app) with your everyday tech tool and you can do the same.
First, what’s a multi-tenant SaaS application?
Multi-tenant SaaS applications let you serve multiple customers from a single codebase. But to do this, you’ll need to manage secure and tenant-specific access, and this can be challenging when done manually. That’s why I decided to use Permit, a modern authorization tool that simplifies this process.
In this article, I’ll show you how to simplify authorization for your SaaS applications using Permit, with a step-by-step example of building a demo app featuring tenant isolation and role-based access control (RBAC) with Next.js and Appwrite.
What are Next.js and Appwrite, and why do we need them?
Next.js
Next.js is a React-based framework that provides server-side rendering (SSR), static site generation (SSG), API routes, and performance optimizations out of the box.
For this project, I used Next.js because:
- It allows pre-rendering of pages, which improves performance and SEO.
- Its built-in routing makes it easy to manage page transitions and dynamic content.
- It integrates easily with backend services like Appwrite and Permit.io for authentication and authorization.
Appwrite
Appwrite is a backend-as-a-service (BaaS) platform that provides user authentication, databases, storage, and serverless functions. Using a service like Appwrite eliminates the need to build a backend from scratch, so you can focus on frontend development while having access to backend capabilities.
For this project, I used Appwrite:
- To handle user registration, login, and session management.
- To provide a structured NoSQL database to store tenant-specific data.
Using Next.js and Appwrite together allowed me to create a scalable, high-performance multi-tenant SaaS app while keeping the development process efficient.
Introduction to Multi-Tenant SaaS Authorization
A multi-tenant SaaS app is software that serves multiple users or groups of users, called tenants, using a single software instance of the application.
What it means is that in a multi-tenant SaaS architecture, multiple customers (tenants) share the same application infrastructure or use the same application but maintain data isolation.
A practical example of this is a project management tool like Trello.
- It is a single infrastructure that runs on shared servers and has the same codebase for all its users.
- Each company using Trello (e.g., Company A and Company B) is a tenant.
- It isolates data:
- Employees of Company A can only see their projects, tasks, and boards.
- Employees of Company B cannot access or view Company A’s data, and vice versa.
This ensures that while resources are shared, each tenant’s data and activities are private and secure.
In a multi-tenant application, even within a tenant, some users will have higher access to some information, while some members will be restricted to certain resources.
Authorization in such applications must:
- Ensure users can’t access other tenants’ or customers’ data or resources. This is called isolating tenants.
- Ensure users within a tenant can access only resources their roles permit by providing granular access control.
- Handle more users, tenants, and roles without slowing down or degrading performance.
Importance of tenant isolation and granular access control
Tenant isolation keeps data secure by ensuring that each customer’s information stays private. While granular access control ensures users within an organization only get the permissions they need.
Implementing authorization in your SaaS apps can be complex and tricky, but it doesn’t have to be when you have an authorization tool like Permit.
What is Permit, and what are its benefits?
Permit is an easy-to-use authorization tool for managing access in any application, including multi-tenant apps. Using Permit.io in your application allows you to easily define and assign roles with specific permissions for access control within your application. Aside from creating roles within the application, you can also add conditions and rules based on user or resource attributes to specify what each user can and cannot do.
Now that you know most of what you need to know about Permit and its benefits, let’s get into the main deal—building a SaaS application with Next.js and integrating Permit for authorization.
To demonstrate the power of Permit, we’ll be building a multi-tenant Edtech SaaS platform.
Building an EdTech SaaS platform involves several challenges, including user authentication, role-based access control (RBAC), and multi-tenancy. We’ll use Next.js for the frontend, Appwrite for authentication and database management, and Permit for fine-grained authorization.
Tech Stack Overview
System Architecture
The application follows a backend-first approach:
- Backend (Node.js Express)
- Handles API requests and business logic.
- Uses Appwrite for authentication and database management.
- Implements Permit for authorization, defining roles and permissions.
- Ensures every request is validated before data access.
- Frontend (Next.js)
- Connects to the backend to fetch data securely.
- Uses role-based UI rendering, meaning users only see what they’re authorized to access.
- Restricts actions (like creating assignments) based on permissions.
By enforcing authorization at the API level, we ensure that users cannot bypass restrictions, even if they manipulate the frontend.
At the end of this guide, you’ll have a fully functional multi-tenant EdTech SaaS app, where:
- Admins can add and view students.
- Teachers can add and view students, as well as create assignments.
- Students can only view their assigned coursework.
This article provides a step-by-step breakdown of how I implemented Permit to handle authorization to build this project, so follow along and build yours.
Backend Implementation with Permit
To enforce role-based access control (RBAC) and tenant isolation, we need to:
- Set up Permit and define roles, tenants, and policies.
- Integrate Permit in the backend (Node.js Express).
- Protect API routes using middleware that checks permissions before allowing requests.
Let’s go step by step.
1. Setting up Permit
Before writing any code, you need to
- Create an account on Permit.

You will be presented with the onboarding, but once you enter your organization name, you can just skip the setup.
- Create a resource and actions
Navigate to the policy section, where you’ll create a resource and actions that you can perform on that resource.

Once you are done creating your resources, it should look like this:

- Creating a role
After creating the resources, navigate to the Roles page using the Roles tab. You’ll see that some roles have automatically been assigned.

Delete those roles and create new roles. Each role will have specific rules associated with it, about what a user can and cannot do. Create the Admin role first, as it will later serve as a building block for the RBAC conditions. Click the Add Role button at the top and create the roles.

When you are done creating your roles, it should look like this:

Great!
Now that you have created your resources and roles, you can now configure permissions in the policy editor.
- Configuring permissions in the policy editor
Go back to the Policy Editor and this is what the roles will look like now, with each individual resource defined and the actions that you can select. You’re now ready to give permissions to the roles to perform the selected actions on the resource.

When you are done selecting the actions for each role, click the Save changes button at the bottom right of the page.
- Copy API keys
Finally, to use the cloud PDP of Permit, you are going to need the API key of your current environment. For this project you are going to be using the development environment key. Proceed to Settings and click API Keys, scroll down to Environment API keys, click “Reveal Key,” then copy it.

After setting up your Permit dashboard, you can now move on to your backend.
2. Installing dependencies
To get started, you’ll need to have Node.js installed on your computer. After ensuring Node.js is installed on your system, follow these steps:
- Start by creating a new project using the following commands:
mkdir backend cd backendNpm init -y
- Then, install the following packages:
npm install express dotenv permitio cors appwwrite axios jsonwebtoken
- Configure Permit in Express. In your .env file, store your API key:
PERMIT_API_KEY=your-permit-key-you-copied-earlier
3. Setting up Appwrite
- Go to Appwrite and create a new project by inputting a project name and selecting a region. Note down your Project ID and API Endpoint; that’s what you’ll input as the values in your .env file. Your ENV file should be looking like this:
PERMIT_API_KEY=your-permit-key-you-copied-earlier APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 APPWRITE_PROJECT_ID=your-project-id
- Now proceed to databases to create your database, then copy your database ID to paste it into your ENV file.

Your ENV file should now be looking like this:
PERMIT_API_KEY=your-permit-key-you-copied-earlier APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 APPWRITE_PROJECT_ID=your-project-id APPWRITE_DATABASE_ID=your-database-id
Now create the following collections in the Appwrite Database with the following attributes:
- Profiles collection

- Students collection

- Assignments collection

What your ENV file should be looking like at this point:
PERMIT_API_KEY=your-permit-key-you-copied-earlier PERMIT_PROJECT_ID=copy-from-dashboard PERMIT_ENV_ID=copy-from-dashboard APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 APPWRITE_PROJECT_ID=your-project-id APPWRITE_DATABASE_ID=your-database-id APPWRITE_PROFILE_COLLECTION_ID=your-id APPWRITE_ASSIGNMENTS_COLLECTION_ID=your-id APPWRITE_STUDENTS_COLLECTION_ID=your-id JWT_SECRET=generate-this-by-running//openssl rand -base64 16 PORT=8080
4. Create file structure and files
Now create a src folder in the root of the file. Then generate the tsconfig.json file in the root folder and paste the following code into it:
<span>{ </span> <span>"compilerOptions": { </span> <span>"target": "ES6", </span> <span>"module": "commonjs", </span> <span>"outDir": "./dist", </span> <span>"esModuleInterop": true, </span> <span>"forceConsistentCasingInFileNames": true, </span> <span>"strict": true, </span> <span>"skipLibCheck": true, </span> <span>"resolveJsonModule": true, </span> <span>"baseUrl": "./", </span> <span>"paths": { </span> <span>"@/*": ["src/*"] </span> <span>} </span> <span>}, </span> <span>"include": ["src/**/*"], </span> <span>"exclude": ["node_modules", "dist"] </span> <span>}</span>
This tsconfig.json configures the TypeScript compiler to target ES6, use CommonJS modules, and output files to ./dist. It enforces strict type-checking, enables JSON module resolution, sets up path aliases for src, and excludes node_modules and dist from compilation.
Inside of the src folder, create the following folders: api, config, controllers, middleware, models, and utils.
- Utils folder
- Now, create a new permit.ts file in the utils folder project to initialize Permit using the following code:
<span>import { Permit } from 'permitio'; </span><span>import { PERMIT_API_KEY } from '../config/environment'; </span><span>// This line initializes the SDK and connects your Node.js app </span><span>// to the Permit.io PDP container you've set up in the previous step. </span><span>const permit = new Permit({ </span> <span>// your API Key </span> token<span>: PERMIT_API_KEY, // Store your API key in .env </span> <span>// in production, you might need to change this url to fit your deployment </span> pdp<span>: 'https://cloudpdp.api.permit.io', // Default Permit.io PDP URL </span> <span>// if you want the SDK to emit logs, uncomment this: </span> log<span>: { </span> level<span>: "debug", </span> <span>}, </span> <span>// The SDK returns false if you get a timeout / network error </span> <span>// if you want it to throw an error instead, and let you handle this, uncomment this: </span> <span>// throwOnError: true, </span><span>}); </span> <span>export default permit;</span>
This file initializes Permit’s SDK for Node.js, connecting it to the Permit PDP container using an API key stored in the environment. It configures logging for debugging and sets up the SDK to handle errors silently unless explicitly configured to throw them.
- Next, create a file called errorHandler.ts and paste the following code:
<span>// Utility functions (e.g., error handling) </span><span>import { Request, Response, NextFunction } from 'express'; </span> <span>export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => { </span> <span>console.error('Error:', err.message || err); </span> res<span>.status(err.status || 500).json({ </span> error<span>: err.message || 'Internal Server Error', </span> <span>}); </span><span>};</span>
This file defines an Express error-handling middleware that logs errors and sends a JSON response with the error message and status code. It defaults to a 500 status code if no specific status is provided.
- Models folder
- Create a file called profile.ts and paste the following code:
<span>export interface Profile { </span> name<span>: string; </span> email<span>: string; </span> role<span>: 'Admin' | 'Teacher' | 'Student'; </span> userId<span>: string; </span><span>}</span>
This file defines a TypeScript Profile interface with properties for name, email, role, and userId, where role is restricted to specific values: Admin, Teacher, or Student.
- Create assignment.ts file and paste the following code:
<span>import { database, ID } from '../config/appwrite'; </span><span>import { DATABASE_ID, ASSIGNMENTS_COLLECTION_ID } from '../config/environment'; </span> <span>export interface AssignmentData { </span> title<span>: string; </span> subject<span>: string; </span> className<span>: string; </span> teacher<span>: string; </span> dueDate<span>: string; </span> creatorEmail<span>: string; </span><span>} </span> <span>// Create a new assignment </span><span>export async function createAssignmentInDB(data: AssignmentData) { </span> <span>return await database.createDocument( </span> <span>DATABASE_ID, </span> <span>ASSIGNMENTS_COLLECTION_ID, </span> <span>ID.unique(), </span> data <span>); </span><span>} </span> <span>// Fetch all assignments </span><span>export async function fetchAssignmentsFromDB() { </span> <span>const response = await database.listDocuments(DATABASE_ID, ASSIGNMENTS_COLLECTION_ID); </span> <span>return response.documents; </span><span>}</span>
This file provides functions to interact with an Appwrite database for managing assignments. It defines an AssignmentData interface and includes functions to create a new assignment and fetch all assignments from the database.
- Create a student.ts file and paste the following code:
<span>import { database, ID, Permission, Role, Query } from '../config/appwrite'; </span><span>import { DATABASE_ID, STUDENTS_COLLECTION_ID } from '../config/environment'; </span> <span>export interface StudentData { </span> firstName<span>: string; </span> lastName<span>: string; </span> gender<span>: 'girl' | 'boy' | 'Boy' | 'Girl'; </span> className<span>: string; </span> age<span>: number; </span> creatorEmail<span>: string; </span><span>} </span> <span>// Create a new student </span><span>export async function createStudentInDB(data: StudentData) { </span> <span>return await database.createDocument( </span> <span>DATABASE_ID, </span> <span>STUDENTS_COLLECTION_ID, </span> <span>ID.unique(), </span> data<span>, </span> <span>[ </span> Permission<span>.read(Role.any()), // Public read permission </span> <span>] </span> <span>); </span><span>} </span> <span>// Fetch all students </span><span>export async function fetchStudentsFromDB() { </span> <span>const response = await database.listDocuments(DATABASE_ID, STUDENTS_COLLECTION_ID); </span> <span>return response.documents; </span><span>}</span>
This file provides functions to manage student data in an Appwrite database. It defines a StudentData interface and includes functions to create a new student with public read permissions and fetch all students from the database.
- Middleware folder
- Create auth.ts file and paste the following code:
<span>import { Request, Response, NextFunction } from 'express'; </span><span>import jwt from 'jsonwebtoken'; </span> <span>// Extend Request type to include 'user' </span><span>interface AuthenticatedRequest extends Request { </span> user<span>?: { </span> id<span>: string; </span> role<span>: string; </span> <span>}; </span><span>} </span> <span>const authMiddleware = (req: AuthenticatedRequest, res: Response, next: NextFunction): void => { </span> <span>const token = req.headers.authorization?.split(' ')[1]; </span> <span>if (!token) { </span> res<span>.status(401).json({ error: 'Unauthorized. No token provided' }); </span> <span>return </span> <span>} </span> <span>try { </span> <span>const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { id: string; role: string }; </span> req<span>.user = decoded; </span> <span>next(); </span> <span>} catch (error) { </span> res<span>.status(403).json({ error: 'Invalid token' }); </span> <span>return </span> <span>} </span><span>}; </span> <span>export default authMiddleware;</span>
This file defines an Express middleware for JWT-based authentication. It checks for a valid token in the request header, verifies it using a secret key, and attaches the decoded user information (ID and role) to the request object. If the token is missing or invalid, it returns an appropriate error response.
- Create permit.ts and paste the following code:
<span>import permit from '../utils/permit'; </span> <span>export const checkUsertoPermitStudents = async (email: string, action: string, resource: string): Promise<boolean> => { </boolean></span> <span>try { </span> <span>const permitted = await permit.check(email, action, resource); </span> <span>console.log("Permitted", permitted); </span> <span>return permitted; </span> <span>} catch (error) { </span> <span>console.error(<span>`Error syncing user <span>${email}</span> to Permit.io:`</span>, error); </span> <span>return false; </span> <span>} </span><span>}; </span> <span>export const checkUserToPermitAssignment = async (email: string, action: string, resource: string): Promise<boolean> => { </boolean></span> <span>try { </span> <span>const permitted = await permit.check(email, action, resource); </span> <span>console.log("Permitted", permitted); </span> <span>return permitted; </span> <span>} catch (error) { </span> <span>console.error(<span>`Error syncing user <span>${email}</span> to Permit.io:`</span>, error); </span> <span>return false; </span> <span>} </span><span>};</span>
This file defines utility functions, checkUsertoPermitStudents and checkUserToPermitAssignment, to check user permissions in Permit for specific actions and resources. Both functions handle errors gracefully, logging issues and returning false if the permission check fails. They are used to enforce authorization in the application.
- Controllers folder
- Create auth.ts file and paste the following code:
<span>import { account, ID } from '../config/appwrite'; </span><span>import { Request, Response } from 'express'; </span><span>import jwt from 'jsonwebtoken'; </span> <span>const JWT_SECRET = process.env.JWT_SECRET as string; // Ensure this is set in your .env file </span> <span>// Sign-up Controller </span><span>export const signUp = async (req: Request, res: Response) => { </span> <span>const { email, password, name } = req.body; </span> <span>if (!email || !password || !name) { </span> <span>return res.status(400).json({ error: 'Name, email, and password are required.' }); </span> <span>} </span> <span>try { </span> <span>const user = await account.create(ID.unique(), email, password, name); </span> <span>// Generate JWT </span> <span>const token = jwt.sign({ email }, JWT_SECRET, { expiresIn: '8h' }); </span> res<span>.cookie('token', token, { </span> httpOnly<span>: true, </span> sameSite<span>: 'strict', </span> secure<span>: true, </span> <span>}); </span> res<span>.status(201).json({ success: true, user, token }); </span> <span>} catch (error: any) { </span> <span>console.error('Sign-up Error:', error); </span> res<span>.status(500).json({ success: false, message: error.message }); </span> <span>} </span><span>}; </span> <span>// Login Controller </span><span>export const login = async (req: Request, res: Response) => { </span> <span>const { email, password } = req.body; </span> <span>if (!email || !password) { </span> <span>return res.status(400).json({ error: 'Email and password are required.' }); </span> <span>} </span> <span>try { </span> <span>const session = await account.createEmailPasswordSession(email, password); </span> <span>// Generate JWT without role </span> <span>const token = jwt.sign( </span> <span>{ userId: session.userId, email }, // No role included </span> <span>JWT_SECRET, </span> <span>{ expiresIn: '8h' } </span> <span>); </span> res<span>.cookie('token', token, { </span> httpOnly<span>: true, </span> sameSite<span>: 'strict', </span> secure<span>: true, </span> <span>}); </span> res<span>.status(200).json({ success: true, token, session }); </span> <span>} catch (error: any) { </span> <span>console.error('Login Error:', error); </span> res<span>.status(401).json({ success: false, message: error.message }); </span> <span>} </span><span>}; </span> <span>// Logout Controller </span><span>export const logout = async (req: Request, res: Response) => { </span> <span>try { </span> <span>await account.deleteSession('Current Session ID'); </span> res<span>.clearCookie('token'); </span> res<span>.status(200).json({ success: true, message: 'Logged out successfully' }); </span> <span>} catch (error: any) { </span> <span>console.error('Logout Error:', error); </span> res<span>.status(500).json({ success: false, message: error.message }); </span> <span>} </span><span>};</span>
This file defines authentication controllers for sign-up, login, and logout, integrating with Appwrite for user management and JWT for session handling. The signUp and login controllers validate input, create user sessions, and generate JWTs, while the logout controller clears the session and token. All controllers handle errors and return appropriate responses.
- Create assignment.ts file and paste the following code:
<span>import { Request, Response } from 'express'; </span><span>import { createAssignmentInDB, AssignmentData, fetchAssignmentsFromDB } from '../models/assignment'; </span><span>import { checkUserToPermitAssignment } from '../middleware/permit'; </span> <span>// Create a new assignment </span><span>export async function createAssignment(req: Request, res: Response): Promise<void> { </void></span> <span>try { </span> <span>const { title, subject, teacher, className, dueDate, creatorEmail }: AssignmentData = req.body; </span> <span>const isPermitted = await checkUserToPermitAssignment(creatorEmail, "create", "assignments"); </span> <span>if (!isPermitted) { </span> res<span>.status(403).json({ error: 'Not authorized' }); </span> <span>return; </span> <span>} </span> <span>const newAssignment = await createAssignmentInDB({ </span> title<span>, </span> subject<span>, </span> teacher<span>, </span> className<span>, </span> dueDate<span>, </span> creatorEmail <span>}); </span> <span>console.log('New assignment created:', newAssignment); </span> res<span>.status(201).json(newAssignment); </span> <span>} catch (error) { </span> <span>console.error('Error creating assignment:', error); </span> res<span>.status(500).json({ error: (error as any).message }); </span> <span>} </span><span>} </span> <span>// Fetch all assignments </span><span>export async function fetchAssignments(req: Request, res: Response): Promise<void> { </void></span> <span>try { </span> <span>const { email } = req.params; </span> <span>const isPermitted = await checkUserToPermitAssignment(email, "read", "assignments"); </span> <span>if (!isPermitted) { </span> res<span>.status(403).json({ message: 'Not authorized' }); </span> <span>return; </span> <span>} </span> <span>const assignments = await fetchAssignmentsFromDB(); </span> res<span>.status(200).json(assignments); </span> <span>} catch (error) { </span> res<span>.status(500).json({ error: (error as any).message }); </span> <span>} </span><span>}</span>
This file defines controllers for creating and fetching assignments to integrate with a database and Permit for authorization checks. The createAssignment controller validates input, checks permissions, and creates a new assignment, while the fetchAssignments controller retrieves all assignments after verifying access. Both controllers handle errors and return appropriate responses.
- Create a student.ts file and paste the following code:
<span>import { </span> createStudentInDB<span>, </span> fetchStudentsFromDB<span>, </span> StudentData <span>} from '../models/student'; </span><span>import { Request, Response } from 'express'; </span><span>import { checkUsertoPermitStudents } from '../middleware/permit'; </span> <span>export async function createStudent(req: Request, res: Response): Promise<void> { </void></span> <span>try { </span> <span>const { firstName, lastName, gender, className, age, creatorEmail }: StudentData = req.body; </span> <span>if (!['girl', 'boy'].includes(gender)) { </span> res<span>.status(400).json({ error: 'Invalid gender type' }); </span> <span>return; </span> <span>} </span> <span>const isPermitted = await checkUsertoPermitStudents(creatorEmail, "create", "students"); </span> <span>if (!isPermitted) { </span> res<span>.status(403).json({ message: 'Not authorized' }); </span> <span>return; </span> <span>} </span> <span>const newStudent = await createStudentInDB({ </span> firstName<span>, </span> lastName<span>, </span> gender<span>, </span> className<span>, </span> age<span>, </span> creatorEmail <span>}); </span> res<span>.status(201).json(newStudent); </span> <span>} catch (error) { </span> res<span>.status(500).json({ error: (error as any).message }); </span> <span>} </span><span>} </span> <span>// Fetch all students </span><span>export async function fetchStudents(req: Request, res: Response): Promise<void> { </void></span> <span>try { </span> <span>const { email } = req.params; </span> <span>const isPermitted = await checkUsertoPermitStudents(email, "read", "students"); </span> <span>if (!isPermitted) { </span> res<span>.status(403).json({ message: 'Not authorized' }); </span> <span>return; </span> <span>} </span> <span>const students = await fetchStudentsFromDB(); </span> res<span>.status(200).json(students); </span> <span>} catch (error) { </span> res<span>.status(500).json({ error: (error as any).message }); </span> <span>} </span><span>}</span>
This file defines controllers for creating and fetching students, integrating with a database and Permit for authorization checks. The createStudent controller validates input, checks permissions, and creates a new student, while the fetchStudents controller retrieves all students after verifying access. Both controllers handle errors and return appropriate responses.
- Create a profile.ts file and paste the following code:
<span>import { Profile } from '@/models/profile'; </span><span>import axios from 'axios'; </span><span>import { database, ID, Query } from '../config/appwrite'; </span><span>import { Request, Response, NextFunction, RequestHandler } from 'express'; </span><span>import { PERMIT_API_KEY } from '../config/environment'; </span> <span>const profileId = process.env.APPWRITE_PROFILE_COLLECTION_ID as string; // Ensure this is in .env </span><span>const databaseId = process.env.APPWRITE_DATABASE_ID as string; // Ensure this is in .env </span><span>const projectId = process.env.PERMIT_PROJECT_ID as string </span><span>const environmentId = process.env.PERMIT_ENV_ID as string </span> <span>const PERMIT_API_URL = <span>`https://api.permit.io/v2/facts/<span>${projectId}</span>/<span>${environmentId}</span>/users`</span>; </span><span>const PERMIT_AUTH_HEADER = { </span> Authorization<span>: <span>`Bearer <span>${PERMIT_API_KEY}</span>`</span>, </span> <span>"Content-Type": "application/json", </span><span>}; </span> <span>// Create Profile Controller </span><span>export const createProfile: RequestHandler = async (req: Request, res: Response, next: NextFunction): Promise<void> => { </void></span> <span>const { firstName, lastName, email, role, userId } = req.body; </span> <span>console.log(req.body); </span> <span>if (!email || !role || !userId) { </span> res<span>.status(400).json({ error: 'FirstName, lastName, email, role, and userId are required.' }); </span> <span>return; </span> <span>} </span> <span>// Validate role </span> <span>const allowedRoles: Profile['role'][] = ['Admin', 'Teacher', 'Student']; </span> <span>if (!allowedRoles.includes(role)) { </span> res<span>.status(400).json({ error: 'Invalid role. Allowed roles: admin, teacher, student' }); </span> <span>return; </span> <span>} </span> <span>try { </span> <span>const newUser = await database.createDocument( </span> databaseId<span>, </span> profileId<span>, </span> <span>ID.unique(), </span> <span>{ firstName, lastName, email, role, userId } </span> <span>); </span> <span>// Step 2: Sync user to Permit.io </span> <span>const permitPayload = { </span> key<span>: email, </span> email<span>, </span> first_name<span>: firstName, </span> last_name<span>: lastName, </span> role_assignments<span>: [{ role, tenant: "default" }], </span> <span>}; </span> <span>let permitResponse; </span> <span>try { </span> <span>const response = await axios.post(PERMIT_API_URL, permitPayload, { headers: PERMIT_AUTH_HEADER }); </span> permitResponse <span>= response.data; </span> <span>console.log("User synced to Permit.io:", permitResponse); </span> <span>} catch (permitError) { </span> <span>if (axios.isAxiosError(permitError)) { </span> <span>console.error("Failed to sync user to Permit.io:", permitError.response?.data || permitError.message); </span> <span>} else { </span> <span>console.error("Failed to sync user to Permit.io:", permitError); </span> <span>} </span> permitResponse <span>= { error: "Failed to sync with Permit.io" }; </span> <span>} </span> <span>// Step 3: Return both responses </span> res<span>.status(201).json({ </span> message<span>: "User profile created successfully", </span> user<span>: newUser, </span> permit<span>: permitResponse, </span> <span>}); </span> <span>return; </span> <span>} catch (error: any) { </span> res<span>.status(500).json({ success: false, message: error.message }); </span> <span>return; </span> <span>} </span><span>}; </span> <span>// Fetch Profile by Email </span><span>export const getProfileByEmail = async (req: Request, res: Response, next: NextFunction): Promise<void> => { </void></span> <span>const { email } = req.params; </span> <span>if (!email) { </span> res<span>.status(400).json({ error: 'Email is required.' }); </span> <span>return; </span> <span>} </span> <span>try { </span> <span>const profile = await database.listDocuments( </span> databaseId<span>, </span> profileId<span>, </span> <span>[Query.equal("email", email)] </span> <span>); </span> <span>if (profile.documents.length === 0) { </span> res<span>.status(404).json({ error: 'Profile not found' }); </span> <span>return; </span> <span>} </span> res<span>.status(200).json({ success: true, profile: profile.documents[0] }); </span> <span>} catch (error: any) { </span> <span>console.error('Error fetching profile:', error); </span> res<span>.status(500).json({ success: false, message: error.message }); </span> <span>} </span><span>};</span>
This file defines controllers for creating and fetching user profiles, integrating with Appwrite for database operations and Permit for role synchronization. The createProfile controller validates input, creates a profile, and syncs the user to Permit, while the getProfileByEmail controller retrieves a profile by email. Both controllers handle errors and return appropriate responses.
- Config Folder
- Create appwrite.ts file and paste the following code:
<span>import { Client, Account, Databases, Storage, ID, Permission, Role, Query } from 'appwrite'; </span><span>import { APPWRITE_ENDPOINT, APPWRITE_PROJECT_ID, APPWRITE_API_KEY } from './environment'; </span> <span>// Initialize the Appwrite client </span><span>const client = new Client() </span> <span>.setEndpoint(APPWRITE_ENDPOINT) // Appwrite endpoint </span> <span>.setProject(APPWRITE_PROJECT_ID); // Appwrite project ID </span> <span>// Add API key if available (for server-side operations) </span><span>if (APPWRITE_API_KEY) { </span> <span>(client as any).config.key = APPWRITE_API_KEY; // Workaround to set API key </span><span>} </span> <span>// Initialize Appwrite services </span><span>const account = new Account(client); </span><span>const database = new Databases(client); </span><span>const storage = new Storage(client); </span> <span>// Export Appwrite client and services </span><span>export { client, account, database, storage, ID, Permission, Role, Query };</span>
This file initializes and configures the Appwrite client with the project endpoint, ID, and optional API key. It also sets up and exports Appwrite services like Account, Databases, and Storage, along with utility constants like ID, Permission, Role, and Query.
- Create environment.ts file and paste the following code:
<span>import dotenv from 'dotenv'; </span>dotenv<span>.config(); // Load environment variables from .env </span> <span>export const APPWRITE_ENDPOINT = process.env.APPWRITE_ENDPOINT || ''; </span><span>export const PERMIT_API_KEY = process.env.PERMIT_API_KEY || ''; </span><span>export const PERMIT_PROJECT_ID = process.env.PERMIT_PROJECT_ID || ''; </span><span>export const PERMIT_ENV_ID = process.env.PERMIT_ENV_ID || ''; </span><span>export const APPWRITE_PROJECT_ID = process.env.APPWRITE_PROJECT_ID || ''; </span><span>export const DATABASE_ID = process.env.APPWRITE_DATABASE_ID || ''; </span><span>export const STUDENTS_COLLECTION_ID = process.env.APPWRITE_STUDENTS_COLLECTION_ID || ''; </span><span>export const ASSIGNMENTS_COLLECTION_ID = process.env.APPWRITE_ASSIGNMENTS_COLLECTION_ID || ''; </span> <span>export const PROFILE_COLLECTION_ID = process.env.APPWRITE_PROFILE_COLLECTION_ID || '';</span>
This file loads environment variables from a .env file and exports them as constants for use in the application, such as Appwrite and Permit configurations, database IDs, and collection IDs. Default values are provided as fallbacks if the environment variables are not set.
- API folder
- Create student.ts and paste the following code:
<span>import express from 'express'; </span><span>import { createStudent, fetchStudents } from '../controllers/student'; </span><span>import authMiddleware from '../middleware/auth'; </span> <span>const router = express.Router(); </span> <span>// Define student-related endpoints </span>router<span>.post('/students', authMiddleware, createStudent); // Create a new student </span>router<span>.get('/students/:email', authMiddleware, fetchStudents); // Fetch all students </span><span>export default router; // Export the router instance</span>
This file sets up an Express router with endpoints for managing student data. It includes routes for creating a new student and fetching students, both protected by an authentication middleware (authMiddleware). The router is then exported for use in the application.
- Create auth.ts file and paste the following code:
<span>// src/routes/authRoutes.ts </span><span>import express from 'express'; </span><span>import { signUp, login, logout } from '../controllers/auth'; </span> <span>const router = express.Router(); </span> <span>// Define auth-related endpoints </span>router<span>.post('/signup', (req, res, next) => { // Signup route </span> <span>signUp(req, res).then(() => { </span> <span>next(); </span> <span>}).catch((err) => { </span> <span>next(err); </span> <span>}); </span><span>}); </span>router<span>.post('/login', (req, res, next) => { // Login route </span> <span>login(req, res).then(() => { </span> <span>next(); </span> <span>}).catch((err) => { </span> <span>next(err); </span> <span>}); </span><span>}); </span>router<span>.post('/logout', logout); // Logout route </span><span>export default router; // Export the router instance</span>
This file sets up an Express router with endpoints for authentication-related actions, including user signup, login, and logout. The signup and login routes handle asynchronous operations with error handling, while the logout route is straightforward. The router is exported for use in the application.
- Create assignment.ts file and paste the following code:
<span>import express from "express" </span><span>import { createAssignment, fetchAssignments } from "../controllers/assignment" </span><span>import authMiddleware from "../middleware/auth" </span> <span>const router = express.Router() </span> router<span>.post("/create", authMiddleware, createAssignment) </span>router<span>.get("/:email", authMiddleware, fetchAssignments) </span><span>export default router</span>
This file sets up an Express router with endpoints for managing assignments. It includes routes for creating an assignment and fetching assignments, both protected by an authentication middleware (authMiddleware). The router is exported for use in the application.
- Create profile.ts file and paste the following code:
<span>import express from 'express'; </span><span>import { createProfile, getProfileByEmail } from '../controllers/profile'; </span><span>import authMiddleware from '../middleware/auth'; </span> <span>const router = express.Router(); </span> <span>// Route for creating a profile </span>router<span>.post('/profile', authMiddleware, createProfile); </span> <span>// Route for getting a profile by email </span>router<span>.get('/profile/:email', authMiddleware, getProfileByEmail); </span><span>export default router;</span>
This file sets up an Express router with endpoints for managing user profiles. It includes routes for creating a profile and fetching a profile by email, both protected by an authentication middleware (authMiddleware). The router is exported for use in the application.
- Create index.ts file and paste the following code:
<span>import express, { Request, Response } from 'express'; </span><span>import dotenv from 'dotenv'; </span><span>import cors from 'cors'; // CORS middleware </span><span>import authRoutes from './auth'; // Import auth routes </span><span>import profileRoutes from './profile'; </span><span>import studentRoutes from './student'; </span><span>import assignmentRoutes from './assignment'; </span><span>import { errorHandler } from '../utils/errorHandler'; // Custom error handler middleware </span> dotenv<span>.config(); // Load environment variables from .env file </span> <span>const app = express(); </span><span>const PORT = process.env.PORT || 8080; </span> <span>// Middleware </span>app<span>.use(cors()); // Handle CORS </span>app<span>.use(express.json()); /// Parse incoming JSON requests </span> <span>// Routes </span>app<span>.use('/api/auth', authRoutes); // Authentication routes </span>app<span>.use('/api', profileRoutes); // Profile routes mounted </span>app<span>.use('/api', studentRoutes); // Student routes mounted </span>app<span>.use('/api/assignments', assignmentRoutes); // Assignment routes mounted </span> <span>// Global Error Handling Middleware </span>app<span>.use(errorHandler); // Handle errors globally </span> <span>// Default Route </span>app<span>.get('/', (req: Request, res: Response) => { </span> res<span>.send('Appwrite Express API'); </span><span>}); </span> <span>// Start Server </span>app<span>.listen(PORT, () => { </span> <span>console.log(<span>`Server is running on port <span>${PORT}</span>`</span>); </span><span>}); </span><span>export default app;</span>
This file sets up an Express server, configuring middleware like CORS and JSON parsing, and mounts routes for authentication, profiles, students, and assignments. It includes a global error handler and a default route to confirm the server is running. The server listens on a specified port, logs its status, and exports the app instance for further use.
- Finally, to run this project, change a part of package.json and install the following packages below so when you run npm run dev, it works.
- Install packages:
npm install concurrently ts-node nodemon --save-dev
- By updating the scripts in the package.json, when you start the server, the typescript files are compiled to JavaScript in a new folder that is automatically created called dist
"scripts": { "dev": "concurrently \"tsc --watch\" \"nodemon -q --watch src --ext ts --exec ts-node src/api/index.ts\"", "build": "tsc", "start": "node ./dist/api/index.js" },
Now run npm run dev to start your server. When you see this message, it means that you have successfully implemented the backend.

Congratulations, your backend is ready for requests.
Now that our backend is set up, move on to frontend integration, where you’ll:
- Secure API requests from Next.js
- Dynamically show/hide UI elements based on user permissions.
Reason for creating an extensive backend service using Appwrite
Appwrite is often described as a backend-as-a-service (BaaS) solution, meaning it provides ready-made backend functionality like authentication, database management, and storage without requiring developers to build a traditional backend.
However, for this project, I needed more flexibility and control over how data was processed, secured, and structured, which led me to create an extensive custom backend using Node.js and Express while still leveraging Appwrite’s services.
Instead of relying solely on Appwrite’s built-in API calls from the frontend, I designed a Node.js backend that acted as an intermediary between the frontend and Appwrite. This allowed me to:
- Implement fine-grained access control with Permit.io before forwarding requests to Appwrite.
- Structure API endpoints for multi-tenancy to ensure tenant-specific data isolation.
- Create custom business logic, such as processing role-based actions before committing them to the Appwrite database.
- Maintain a centralized API layer, making it easier to enforce security policies, log activities, and scale the application.
Appwrite provided the core authentication and database functionality of this application, but this additional backend layer enhanced security, flexibility, and maintainability, to ensure strict access control before any action reached Appwrite.
Conclusion
That’s it for part one of this article series. In part 2, we’ll handle the frontend integration by setting up API calls with authorization, initializing and installing necessary dependencies, writing out the component file codes, and handling state management & routes.
The above is the detailed content of Building a Multi-Tenant SaaS Application with Next.js (Backend Integration). For more information, please follow other related articles on the PHP Chinese website!

Different JavaScript engines have different effects when parsing and executing JavaScript code, because the implementation principles and optimization strategies of each engine differ. 1. Lexical analysis: convert source code into lexical unit. 2. Grammar analysis: Generate an abstract syntax tree. 3. Optimization and compilation: Generate machine code through the JIT compiler. 4. Execute: Run the machine code. V8 engine optimizes through instant compilation and hidden class, SpiderMonkey uses a type inference system, resulting in different performance performance on the same code.

JavaScript's applications in the real world include server-side programming, mobile application development and Internet of Things control: 1. Server-side programming is realized through Node.js, suitable for high concurrent request processing. 2. Mobile application development is carried out through ReactNative and supports cross-platform deployment. 3. Used for IoT device control through Johnny-Five library, suitable for hardware interaction.

I built a functional multi-tenant SaaS application (an EdTech app) with your everyday tech tool and you can do the same. First, what’s a multi-tenant SaaS application? Multi-tenant SaaS applications let you serve multiple customers from a sing

This article demonstrates frontend integration with a backend secured by Permit, building a functional EdTech SaaS application using Next.js. The frontend fetches user permissions to control UI visibility and ensures API requests adhere to role-base

JavaScript is the core language of modern web development and is widely used for its diversity and flexibility. 1) Front-end development: build dynamic web pages and single-page applications through DOM operations and modern frameworks (such as React, Vue.js, Angular). 2) Server-side development: Node.js uses a non-blocking I/O model to handle high concurrency and real-time applications. 3) Mobile and desktop application development: cross-platform development is realized through ReactNative and Electron to improve development efficiency.

The latest trends in JavaScript include the rise of TypeScript, the popularity of modern frameworks and libraries, and the application of WebAssembly. Future prospects cover more powerful type systems, the development of server-side JavaScript, the expansion of artificial intelligence and machine learning, and the potential of IoT and edge computing.

JavaScript is the cornerstone of modern web development, and its main functions include event-driven programming, dynamic content generation and asynchronous programming. 1) Event-driven programming allows web pages to change dynamically according to user operations. 2) Dynamic content generation allows page content to be adjusted according to conditions. 3) Asynchronous programming ensures that the user interface is not blocked. JavaScript is widely used in web interaction, single-page application and server-side development, greatly improving the flexibility of user experience and cross-platform development.

Python is more suitable for data science and machine learning, while JavaScript is more suitable for front-end and full-stack development. 1. Python is known for its concise syntax and rich library ecosystem, and is suitable for data analysis and web development. 2. JavaScript is the core of front-end development. Node.js supports server-side programming and is suitable for full-stack development.


Hot AI Tools

Undresser.AI Undress
AI-powered app for creating realistic nude photos

AI Clothes Remover
Online AI tool for removing clothes from photos.

Undress AI Tool
Undress images for free

Clothoff.io
AI clothes remover

AI Hentai Generator
Generate AI Hentai for free.

Hot Article

Hot Tools

MinGW - Minimalist GNU for Windows
This project is in the process of being migrated to osdn.net/projects/mingw, you can continue to follow us there. MinGW: A native Windows port of the GNU Compiler Collection (GCC), freely distributable import libraries and header files for building native Windows applications; includes extensions to the MSVC runtime to support C99 functionality. All MinGW software can run on 64-bit Windows platforms.

DVWA
Damn Vulnerable Web App (DVWA) is a PHP/MySQL web application that is very vulnerable. Its main goals are to be an aid for security professionals to test their skills and tools in a legal environment, to help web developers better understand the process of securing web applications, and to help teachers/students teach/learn in a classroom environment Web application security. The goal of DVWA is to practice some of the most common web vulnerabilities through a simple and straightforward interface, with varying degrees of difficulty. Please note that this software

EditPlus Chinese cracked version
Small size, syntax highlighting, does not support code prompt function

SublimeText3 Linux new version
SublimeText3 Linux latest version

SublimeText3 Chinese version
Chinese version, very easy to use