Building a real-time collaborative document editor can be both fun and challenging. It's like Google Docs where multiple users can work on the same document in real-time.
In this tutorial, I’ll walk you through how I built a live collaborative docs app using Next.js 15, Clerk for authentication, Permit.io for role-based access control (RBAC), and Liveblocks for real-time syncing. I also used Tailwind CSS v4 and Shadcn UI to design a clean UI.
Why focus on RBAC?
In a multi-user SaaS app where users can collaborate in real time, permissions matter. Permission on who can edit, who can only view, and who’s allowed to delete a document entirely. Not everyone should be able to edit or delete every document.
That’s where Permit.io comes in to make managing these permissions simple and scalable.
We’ll use:
1. Next.js 15
Next.js is a powerful React framework for building full-stack web applications. It offers features like server-side rendering (SSR), API routes, static site generation (SSG), and routing out of the box.
Features:
- Handles both frontend and backend logic in one unified project.
- It is ideal for building dynamic, fast, SEO-friendly apps.
- It enables server functions like
user.actions.ts
with the 'use server' directive.
2. Clerk
Clerk is an authentication and user management solution for modern web apps. It provides prebuilt UI components and APIs for sign-up, sign-in, and user profile management. It easily integrates with Next.js for secure user handling and offers session management and roles with minimal config..
3. Permit.io
Permit.io is an authorization-as-a-service platform that helps you manage user access control and permissions in your application.
In this our application, we'll have fine-grained control over who can read/write to rooms or documents. It is also useful for managing user roles dynamically via policies.
4. Liveblocks
Liveblocks enables real-time collaboration features like presence, live cursors, and document editing. It will perform a lot of functions in this app, such as providing a collaborative room-based functionality and letting us add multiplayer experience similar to Google Docs with minimal setup.
5. Tailwind CSS + Shadcn/ui
Tailwind CSS is a utility-first CSS framework that allows you to rapidly build modern, responsive user interfaces using utility classes. shadcn/ui is a customizable collection of accessible UI components built with Tailwind CSS and Radix UI.
This is what we intend to build.
Understanding RBAC and Its Importance
What is RBAC?
Role-Based Access Control (RBAC) is a method of managing permissions based on a user’s role within an application. For a collaborative document app, this makes perfect sense. Instead of setting permissions for each user individually, you assign roles like "creator", "editor", or "viewer", and then define what each role can or cannot do.
RBAC vs ABAC
- RBAC: Permission is tied to the user’s role (e.g., editor, viewer).
- ABAC: Uses attributes (e.g., department, time of access) to grant permission. More flexible, but also more complex.
Typical Roles in a Collaborative Docs App:
- Creator: Full control (edit, share, delete)
- Editor: Can edit and comment
- Viewer: Read-only access
RBAC helps prevent unauthorized access and ensures users see only what they’re meant to see. This provides a smooth and secure real-time experience, improves usability, and simplifies policy management, especially in collaborative, real-time environments like the real-time collaborative app we want to build.
Setting Up the Next.js Application
We’re using the App Router in Next.js 15, and here’s how we bootstrapped the project.
- Start by initializing a new Next.js 15 app:
npx create-next-app@latest collaborative-docs-app
- Navigate to your project:
cd collaborative-docs-app
- Install Shadcn and some components from it:
npm shadcn-ui@latest init
npm shadcn@latest add button dialog input label popover select
This will automatically create a ui folder inside a newly created components folder in the src directory with the 6 installed ShadCN components in your project.
- Install core dependencies:
npm install permit.io @clerk@5.2.4 @clerk/themes@2.1.12
- Install other dependencies:
npm install nanoid@5.0.7 lexical @lexical/react
The nanoid is a library used to generate unique string IDs.
- Install and initialize Tailwind:
npm install tailwindcss@3.4.1 --save -D postcss@8 tailwind-merge tailwindcss-animate
Create a postcss.config.mjs
file in the root of your project and add the following lines of code to the PostCSS configuration.
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
- Finally, let's install JSM editor, which is a lexical editor component.
Run
npm i jsm-editor@0.0.12
and then:npx jsm-editor add editor
. The purpose of the npx command is to initialize the editor to generate the editor folder.
After you have successfully ran the npx command you should get the output below in your terminal.
NB: A lot of the packages used in this application are deprecated, so when installing it, install the exact package version and use the -legacy-peer-deps
flag in the npm install command.
- Project folder structure
Asides the auto generated files, create the rest of the folders and files contained in the folder structure below:
live-docs/
┣ app/
┃ ┣ (auth)/
┃ ┃ ┣ sign-in/
┃ ┃ ┃ ┗ [[...sign-in]]/
┃ ┃ ┃ ┗ page.tsx
┃ ┃ ┗ sign-up/
┃ ┃ ┗ [[...sign-up]]/
┃ ┃ ┃ ┗ page.tsx
┃ ┣ (root)/
┃ ┃ ┣ documents/
┃ ┃ ┃ ┗ [id]/
┃ ┃ ┃ ┗ page.tsx
┃ ┃ ┗ page.tsx
┃ ┣ api/
┃ ┃ ┣ liveblocks-auth/
┃ ┃ ┃ ┗ route.ts
┃ ┃ ┗ permit/
┃ ┃ ┣ role-assign/
┃ ┃ ┃ ┃ ┗ route.ts
┃ ┃ ┣ role-unassign/
┃ ┃ ┃ ┃ ┗ route.ts
┃ ┃ ┗ sync/
┃ ┃ ┃ ┗ route.ts
┃ ┣ Provider.tsx
┃ ┣ favicon.ico
┃ ┣ global-error.tsx
┃ ┣ globals.css
┃ ┗ layout.tsx
┣ components/
┃ ┣ editor/
┃ ┃ ┣ plugins/
┃ ┃ ┃ ┣ FloatingToolbarPlugin.tsx
┃ ┃ ┃ ┣ Theme.ts
┃ ┃ ┃ ┗ ToolbarPlugin.tsx
┃ ┃ ┗ Editor.tsx
┃ ┣ ui/
┃ ┃ ┣ button.tsx
┃ ┃ ┣ dialog.tsx
┃ ┃ ┣ input.tsx
┃ ┃ ┣ label.tsx
┃ ┃ ┣ popover.tsx
┃ ┃ ┗ select.tsx
┃ ┣ ActiveCollaborators.tsx
┃ ┣ AddDocumentBtn.tsx
┃ ┣ CollaborativeRoom.tsx
┃ ┣ Collaborator.tsx
┃ ┣ Comments.tsx
┃ ┣ DeleteModal.tsx
┃ ┣ Header.tsx
┃ ┣ Loader.tsx
┃ ┣ Notifications.tsx
┃ ┣ ShareModal.tsx
┃ ┗ UserTypeSelector.tsx
┣ lib/
┃ ┣ actions/
┃ ┃ ┣ permissions.ts
┃ ┃ ┣ room.actions.ts
┃ ┃ ┗ user.actions.ts
┃ ┣ liveblocks.ts
┃ ┣ permit.ts
┃ ┗ utils.ts
┣ public/
┣ styles/
┣ types/
┃ ┗ index.d.ts
┣ .eslintrc.json
┣ .gitignore
┣ README.md
┣ components.json
┣ instrumentation.ts
┣ liveblocks.config.ts
┣ middleware.ts
┣ next.config.mjs
┣ package-lock.json
┣ package.json
┣ postcss.config.mjs
┣ tailwind.config.ts
┗ tsconfig.json
Refer to the GitHub section of the following components:
globals.css
tailwind.config.ts
types/index.d.ts
lib/utils.ts
components/editor/plugins/FloatingToolbar.tsx
style.css
The code in the mentioned files must be copied and pasted exactly as they are.
- Create a file in the root directory of your project and name it
.env.local
. This file will contain your API keys from Clerk, Liveblocks and Permit.
Paste these in the file:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
LIVEBLOCKS_SECRET_KEY=
PERMIT_API_KEY=
Later on, you'll replace the placeholder keys with the API keys from Permit, Clerk, and Liveblocks.
The home page of the collaborative docs application.
Let's take a look at the app/(root)/page.tsx
file which is the home page.
I'll explain what it does but first, paste these lines of code in the file:
import AddDocumentBtn from '@/components/AddDocumentBtn';
import { DeleteModal } from '@/components/DeleteModal';
import Header from '@/components/Header'
import Notifications from '@/components/Notifications';
import { Button } from '@/components/ui/button'
import { getDocuments } from '@/lib/actions/room.actions';
import { dateConverter } from '@/lib/utils';
import { SignedIn, UserButton } from '@clerk/nextjs'
import { currentUser } from '@clerk/nextjs/server';
import Image from 'next/image';
import Link from 'next/link';
import { redirect } from 'next/navigation';
const Home = async () => {
const clerkUser = await currentUser();
if(!clerkUser) redirect('/sign-in');
const roomDocuments = await getDocuments(clerkUser.emailAddresses[0].emailAddress);
return (
<main className="home-container">
<Header className="sticky left-0 top-0">
<div className="flex items-center gap-2 lg:gap-4">
<Notifications />
<SignedIn>
<UserButton />
</SignedIn>
</div>
</Header>
{roomDocuments.data.length > 0 ? (
<div className="document-list-container">
<div className="document-list-title">
<h3 className="text-28-semibold">All documents</h3>
<AddDocumentBtn
userId={clerkUser.id}
email={clerkUser.emailAddresses[0].emailAddress}
/>
</div>
<ul className="document-ul">
{roomDocuments.data.map(({ id, metadata, createdAt }: any) => (
<li key={id} className="document-list-item">
<Link href={`/documents/${id}`} className="flex flex-1 items-center gap-4">
<div className="hidden rounded-md bg-dark-500 p-2 sm:block">
<Image
src="/assets/icons/doc.svg"
alt="file"
width={40}
height={40}
/>
</div>
<div className="space-y-1">
<p className="line-clamp-1 text-lg">{metadata.title}</p>
<p className="text-sm font-light text-blue-100">Created about {dateConverter(createdAt)}</p>
</div>
</Link>
<DeleteModal roomId={id} />
</li>
))}
</ul>
</div>
): (
<div className="document-list-empty">
<Image
src="/assets/icons/doc.svg"
alt="Document"
width={40}
height={40}
className="mx-auto"
/>
<AddDocumentBtn
userId={clerkUser.id}
email={clerkUser.emailAddresses[0].emailAddress}
/>
</div>
)}
</main>
)
}
export default Home
As stated previously, this is the home page of the application. After users sign in, they're taken to this page to either create a new document, collaborate on a document they've been added to, see notifications, see their account details, or sign out.
It ensures that a user is signed in via Clerk (currentUser
), fetching the user’s documents (getDocuments
), rendering a list of documents or a message if there are none, and providing buttons for adding and deleting documents.
- A lib folder will also be created inside the src directory. This lib folder will contain a utils.ts file to store reusable helper functions or small logic chunks that are used across multiple parts of your app.
In the /lib/utils.ts
file, paste these lines of code:
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const parseStringify = (value: any) => JSON.parse(JSON.stringify(value));
export const getAccessType = (userType: UserType) => {
switch (userType) {
case 'creator':
return ['room:write'];
case 'editor':
return ['room:write'];
case 'viewer':
return ['room:read', 'room:presence:write'];
default:
return ['room:read', 'room:presence:write'];
}
};
export const dateConverter = (timestamp: string): string => {
const timestampNum = Math.round(new Date(timestamp).getTime() / 1000);
const date: Date = new Date(timestampNum * 1000);
const now: Date = new Date();
const diff: number = now.getTime() - date.getTime();
const diffInSeconds: number = diff / 1000;
const diffInMinutes: number = diffInSeconds / 60;
const diffInHours: number = diffInMinutes / 60;
const diffInDays: number = diffInHours / 24;
switch (true) {
case diffInDays > 7:
return `${Math.floor(diffInDays / 7)} weeks ago`;
case diffInDays >= 1 && diffInDays <= 7:
return `${Math.floor(diffInDays)} days ago`;
case diffInHours >= 1:
return `${Math.floor(diffInHours)} hours ago`;
case diffInMinutes >= 1:
return `${Math.floor(diffInMinutes)} minutes ago`;
default:
return 'Just now';
}
};
// Function to generate a random color in hex format, excluding specified colors
export function getRandomColor() {
const avoidColors = ['#000000', '#FFFFFF', '#8B4513']; // Black, White, Brown in hex format
let randomColor;
do {
// Generate random RGB values
const r = Math.floor(Math.random() * 256); // Random number between 0-255
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
// Convert RGB to hex format
randomColor = `#${r.toString(16)}${g.toString(16)}${b.toString(16)}`;
} while (avoidColors.includes(randomColor));
return randomColor;
}
export const brightColors = [
'#2E8B57', // Darker Neon Green
....
];
export function getUserColor(userId: string) {
let sum = 0;
for (let i = 0; i < userId.length; i++) {
sum += userId.charCodeAt(i);
}
const colorIndex = sum % brightColors.length;
return brightColors[colorIndex];
}
This file functions as a general-purpose utility helper. In this case, it includes some helper functions, like parseStringify(...)
used to deep clone and sanitize objects, especially useful when dealing with data serialization issues, like when returning data from APIs or actions.
Other helpers include getAccessType(...)
, which will determine permission levels based on user roles like "creator," "editor," or "viewer." The dateConverter(...)
function will translate a given timestamp into a readable relative time like "2 days ago."
To support visual identity features, the file includes a getRandomColor()
generator and a predefined array of brightColors, plus getUserColor(...)
, which consistently assigns a color from the list to a user based on their ID. These utilities are designed to keep code clean and reusable across components and server logic.
- Next up is the
types/index.d.ts
file which is basically a typeScript type declarations for key entities. We defines interfaces for Document, User, Collaborator, etc.
/* eslint-disable no-unused-vars */
declare type SearchParamProps = {
params: { [key: string]: string };
searchParams: { [key: string]: string | string[] | undefined };
};
declare type AccessType = ["room:write"] | ["room:read", "room:presence:write"];
declare type RoomAccesses = Record<string, AccessType>;
declare type UserType = "creator" | "editor" | "viewer";
declare type RoomMetadata = {
creatorId: string;
email: string;
title: string;
};
declare type CreateDocumentParams = {
userId: string;
email: string;
};
declare type User = {
id: string;
name: string;
email: string;
avatar: string;
color: string;
userType?: UserType;
};
declare type ShareDocumentParams = {
roomId: string;
email: string;
userType: UserType;
updatedBy: User;
};
declare type UserTypeSelectorParams = {
userType: string;
setUserType: React.Dispatch<React.SetStateAction<UserType>>;
onClickHandler?: (value: string) => void;
};
declare type ShareDocumentDialogProps = {
roomId: string;
collaborators: User[];
creatorId: string;
currentUserType: UserType;
};
declare type HeaderProps = {
children: React.ReactNode;
className?: string;
};
declare type CollaboratorProps = {
roomId: string;
email: string;
creatorId: string;
collaborator: User;
user: User;
};
declare type CollaborativeRoomProps = {
roomId: string;
roomMetadata: RoomMetadata;
users: User[];
currentUserType: UserType;
};
declare type AddDocumentBtnProps = {
userId: string;
email: string;
};
declare type DeleteModalProps = { roomId: string };
declare type ThreadWrapperProps = { thread: ThreadData<BaseMetadata> };
These interfaces defined ensures type safety across components and actions.
Implementing Authentication & User Management
We used Clerk for seamless authentication. It handles:
- User sign-up & sign-in
- Session management
- Profile display
- Protecting routes
Setting up Authentication
Create a project
- Go to Clerk and create a new project. Set up your Clerk API keys in the dashboard and add the project keys in your
.env.local
file located in the main directory of your project.
- Update
middleware.ts
file. Paste these lines of code:
import { clerkMiddleware } from "@clerk/nextjs/server";
export default clerkMiddleware();
export const config = {
matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
};
This clerkMiddleware helper enables authentication and is where you'll configure your protected routes.
Sign-up and Sign-in pages using Clerk
- In the
app/(auth)/sign-up/[[...sign-up]]/page.tsx
file, paste these lines of code:
import { SignUp } from '@clerk/nextjs'
const SignUpPage = () => {
return (
<main className="auth-page">
<SignUp />
</main>
)
}
export default SignUpPage
- Similar to the sign up page, in the
app/(auth)/sign-in/[[...sign-in]]/page.tsx
file, paste these lines of code:
import { SignIn } from '@clerk/nextjs'
const SignInPage = () => {
return (
<main className="auth-page">
<SignIn />
</main>
)
}
export default SignInPage
After a user signs in to the application, they'll be navigated to the home page.
Provider file
In the app/Provider.tsx
file in the app directory, paste these lines of code:
'use client';
import Loader from '@/components/Loader';
import { getClerkUsers, getDocumentUsers } from '@/lib/actions/user.actions';
import { useUser } from '@clerk/nextjs';
import { ClientSideSuspense, LiveblocksProvider } from '@liveblocks/react/suspense';
import { ReactNode } from 'react';
const Provider = ({ children }: { children: ReactNode}) => {
const { user: clerkUser } = useUser();
return (
<LiveblocksProvider
authEndpoint="/api/liveblocks-auth"
resolveUsers={async ({ userIds }) => {
const users = await getClerkUsers({ userIds});
return users;
}}
resolveMentionSuggestions={async ({ text, roomId }) => {
const roomUsers = await getDocumentUsers({
roomId,
currentUser: clerkUser?.emailAddresses[0].emailAddress!,
text,
})
return roomUsers;
}}
>
<ClientSideSuspense fallback={<Loader />}>
{children}
</ClientSideSuspense>
</LiveblocksProvider>
)
}
export default Provider
Now, wrap the app with Clerk’s provider in app/layout.tsx
:
import { Inter as FontSans } from "next/font/google"
import { Toaster } from "@/components/ui/sonner"
import { cn } from "@/lib/utils"
import './globals.css'
import { Metadata } from "next"
import { ClerkProvider } from "@clerk/nextjs"
import { dark } from "@clerk/themes"
import Provider from "./Provider"
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
})
export const metadata: Metadata = {
title: 'LiveDocs',
description: 'Your go-to collaborative editor',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider
appearance={{
baseTheme: dark,
variables: {
colorPrimary: "#3371FF" ,
fontSize: '16px'
},
}}
>
<html lang="en" suppressHydrationWarning>
<body
className={cn(
"min-h-screen font-sans antialiased",
fontSans.variable
)}
>
<Provider>
{children}
</Provider>
<Toaster />
</body>
</html>
</ClerkProvider>
)
}
This ClerkProvider
component provides Clerk's authentication context to your app.
Handle user-related operations
The lib/actions/user.actions.ts
file will handle actions related to user data using Clerk and Liveblocks. The getClerkUsers(...)
function takes an array of email addresses, fetches corresponding user details from Clerk (like name, avatar, and email), and returns a sorted list matching the input order.
It uses parseStringify(...)
to ensure the response is clean and serializable for use in UI components or further processing.
Paste these lines of code in it:
'use server';
import { clerkClient } from "@clerk/nextjs/server";
import { parseStringify } from "../utils";
import { liveblocks } from "../liveblocks";
export const getClerkUsers = async ({ userIds }: { userIds: string[]}) => {
try {
const { data } = await clerkClient.users.getUserList({
emailAddress: userIds,
});
const users = data.map((user) => ({
id: user.id,
name: `${user.firstName} ${user.lastName}`,
email: user.emailAddresses[0].emailAddress,
avatar: user.imageUrl,
}));
const sortedUsers = userIds.map((email) => users.find((user) => user.email === email));
return parseStringify(sortedUsers);
} catch (error) {
console.log(`Error fetching users: ${error}`);
}
}
export const getDocumentUsers = async ({ roomId, currentUser, text }: { roomId: string, currentUser: string, text: string }) => {
try {
const room = await liveblocks.getRoom(roomId);
const users = Object.keys(room.usersAccesses).filter((email) => email !== currentUser);
if(text.length) {
const lowerCaseText = text.toLowerCase();
const filteredUsers = users.filter((email: string) => email.toLowerCase().includes(lowerCaseText))
return parseStringify(filteredUsers);
}
return parseStringify(users);
} catch (error) {
console.log(`Error fetching document users: ${error}`);
}
}
The second function, getDocumentUsers(...)
, queries Liveblocks to get users with access to a collaborative document (called a room). It filters out the current user and supports an optional search text input to narrow down the results (e.g., when inviting or tagging users).
Integrating Permit for Role-Based Access Control
Permit handles all our authorization logic — roles, permissions, and policies.
Step 1: Set Up Permit
- Go to Permit.io
- Create a project and API key (which will be added to your
.env.local
file) - Define resources (like
document
) and roles (creator
,editor
,viewer
)
Step 2: Create Roles and Permissions
Create roles and permissions like this:
Creator: Can read, write, and delete
Editor: Can read and write but can't delete
Viewer: Can only read but can't write or delete
Step 3: Code Integration
- First, add Permit client config in
lib/permit.ts
:
import { Permit } from 'permitio';
const PERMIT_API_KEY = process.env.PERMIT_API_KEY as string;
// This line initializes the SDK and connects your Node.js app
// to the Permit.io PDP container you've set up in the previous step.
const permit = new Permit({
// your API Key
token: PERMIT_API_KEY, // Store your API key in .env
// in production, you might need to change this url to fit your deployment
pdp: 'https://cloudpdp.api.permit.io', // Default Permit.io PDP URL
// if you want the SDK to emit logs, uncomment this:
log: {
level: "debug",
},
// The SDK returns false if you get a timeout / network error
// if you want it to throw an error instead, and let you handle this, uncomment this:
throwOnError: true,
});
export default permit;
- Define routes to assign and unassign roles
In the api/permit/role-assign/route.ts
file, paste the following lines of code:
import { NextResponse } from 'next/server';
import permit from "../../../../lib/permit"
const allowedRoles: string[] = ['editor', 'viewer'];
export async function POST(request: Request) {
const { userId, role } = await request.json();
if (!userId || !role) {
return NextResponse.json(
{ error: 'userId and role are required.' },
{ status: 400 }
);
}
if (!allowedRoles.includes(role)) {
return NextResponse.json(
{ error: 'Invalid role. Allowed roles: editor, viewer' },
{ status: 400 }
);
}
try {
// Assign role using SDK
await permit.api.roleAssignments.assign({
user: userId,
role,
tenant: "default",
});
return NextResponse.json({
message: "Role assigned successfully",
data: { userId, role }
}, { status: 200 });
} catch (error) {
console.error("Failed to assign role in Permit.io:", error);
// Handle role not assigned case
if (typeof error === "object" && error !== null && "status" in error && (error as any).status === 404) {
return NextResponse.json(
{
success: false,
message: "Role not assigned to user",
error: (error as any).message
},
{ status: 404 }
);
}
return NextResponse.json(
{
success: false,
message: "Failed to assign role in Permit.io",
error: typeof error === "object" && error !== null && "message" in error ? (error as any).message : String(error)
},
{ status: typeof error === "object" && error !== null && "status" in error ? (error as any).status : 500 }
);
}
}
This assigns a role to users who are added by the creator to the document, either as editors or as viewers.
Next, in the api/permit/role-unassign/route.ts
file, paste the following lines of code:
import { NextResponse } from 'next/server';
import permit from "../../../../lib/permit"
export async function DELETE(request: Request) {
const { userId, role } = await request.json();
if (!userId || !role) {
return NextResponse.json(
{ error: 'userId and role are required.' },
{ status: 400 }
);
}
try {
// Unassign role using SDK
await permit.api.roleAssignments.unassign({
user: userId,
role,
tenant: "default",
});
return NextResponse.json({
message: "Role unassigned successfully",
data: { userId, role }
}, { status: 200 });
} catch (error) {
console.error("Failed to unassign role in Permit.io:", error);
// Handle role not assigned case
if (typeof error === "object" && error !== null && "status" in error && (error as any).status === 404) {
return NextResponse.json(
{
success: false,
message: "Role not assigned to user",
error: (error as any).message
},
{ status: 404 }
);
}
return NextResponse.json(
{
success: false,
message: "Failed to unassign role in Permit.io",
error: typeof error === "object" && error !== null && "message" in error ? (error as any).message : String(error)
},
{ status: typeof error === "object" && error !== null && "status" in error ? (error as any).status : 500 }
);
}
}
This unassigns a role that has been assigned to a user.
- Sync user info with Permit via
api/permit/sync/route.ts
like this:
import { NextResponse } from 'next/server';
import permit from "../../../../lib/permit"
const allowedRoles: string[] = ['editor', 'viewer'];
export async function POST(request: Request) {
const { firstName, lastName, email, role, userId } = await request.json();
if (!email || !role || !userId) {
return NextResponse.json(
{ error: 'Email, role, and userId are required.' },
{ status: 400 }
);
}
if (!allowedRoles.includes(role)) {
return NextResponse.json(
{ error: 'Invalid role. Allowed roles: editor, viewer' },
{ status: 400 }
);
}
try {
// First check if user exists using SDK
// Create new user and assign role in one operation
await permit.api.users.sync({
key: email,
email,
first_name: firstName,
last_name: lastName,
role_assignments: [{ role, tenant: "default" }]
});
return NextResponse.json({
message: "User created and role assigned successfully",
data: { email, role }
}, { status: 201 });
} catch (error) {
console.error("Failed to sync user to Permit.io:", error);
return NextResponse.json(
{
success: false,
message: "Failed to sync with Permit.io",
error: typeof error === "object" && error !== null && "message" in error ? (error as any).message : String(error)
},
{ status: typeof error === "object" && error !== null && "status" in error ? (error as any).status : 500 }
);
}
}
Integrating Liveblocks for Real-Time Collaboration
We used Liveblocks to power document syncing and collaboration. Each document becomes a Liveblocks room.
The key features include:
- Real-time syncing of text edits
- Collaborative cursors and presence
- Tagging document collaborators
Set up Liveblocks account
- Sign up to Liveblocks
- Create a project
- A page will open up which will prompt you to choose the application you want to build, in this case, it's the Text editor. Choose Liveblocks lexical and then Next.js.
Installation steps:
- First, initialize Liveblocks and Lexical with the following commands:
npm install @liveblocks/client@2.3.0 @liveblocks/react@2.3.0 @liveblocks/react-ui@2.3.0 @liveblocks/react-lexical@2.3.0 lexical@0.16.1 @lexical/react@0.16.1
npx create-liveblocks-app@latest --init --framework react
The second command is used to initialize a Liveblocks config file. Now, paste these lines of code into the generated liveblocks.config.ts
file:
// Define Liveblocks types for your application
// https://liveblocks.io/docs/api-reference/liveblocks-react#Typing-your-data
declare global {
interface Liveblocks {
// Each user's Presence, for useMyPresence, useOthers, etc.
Presence: {
// Example, real-time cursor coordinates
// cursor: { x: number; y: number };
};
// The Storage tree for the room, for useMutation, useStorage, etc.
Storage: {
// Example, a conflict-free list
// animals: LiveList<string>;
};
// Custom user info set when authenticating with a secret key
UserMeta: {
id: string;
info: {
id: string;
name: string;
email: string;
avatar: string;
color: string;
};
};
// Custom events, for useBroadcastEvent, useEventListener
RoomEvent: {};
// Example has two events, using a union
// | { type: "PLAY" }
// | { type: "REACTION"; emoji: "🔥" };
// Custom metadata set on threads, for useThreads, useCreateThread, etc.
ThreadMetadata: {
// Example, attaching coordinates to a thread
// x: number;
// y: number;
};
// Custom room info set with resolveRoomsInfo, for useRoomInfo
RoomInfo: {
// Example, rooms with a title and url
// title: string;
// url: string;
};
}
}
export {};
- Then install Liveblocks node using the
npm install @liveblocks/node@2.3.0
command. Copy the liveblocks auth api in the code. This package will be helpful in setting up an authentication for liveblocks.
Core integration steps:
- First, we'll set up Liveblocks config in
lib/liveblocks.ts
file:
import { Liveblocks } from "@liveblocks/node";
export const liveblocks = new Liveblocks({
secret: process.env.LIVEBLOCKS_SECRET_KEY as string,
});
- Next up is the API route for Liveblocks auth in the
app/api/liveblocks-auth/route.ts
file:
import { liveblocks } from "@/lib/liveblocks";
import { getUserColor } from "@/lib/utils";
import { currentUser } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
export async function POST(request: Request) {
const clerkUser = await currentUser();
if(!clerkUser) redirect('/sign-in');
const { id, firstName, lastName, emailAddresses, imageUrl } = clerkUser;
// Get the current user from your database
const user = {
id,
info: {
id,
name: `${firstName} ${lastName}`,
email: emailAddresses[0].emailAddress,
avatar: imageUrl,
color: getUserColor(id),
}
}
// Identify the user and return the result
const { status, body } = await liveblocks.identifyUser(
{
userId: user.info.email,
groupIds: [],
},
{ userInfo: user.info },
);
return new Response(body, { status });
}
- The
app/(root)/documents/[id]/page.tsx
file will be the document editor page for a specific document ID. Paste the following lines of code:
import CollaborativeRoom from "@/components/CollaborativeRoom"
import { getDocument, verifyUserPermission } from "@/lib/actions/room.actions";
import { getClerkUsers } from "@/lib/actions/user.actions";
import { currentUser } from "@clerk/nextjs/server"
import { redirect } from "next/navigation";
const Document = async ({ params: { id } }: SearchParamProps) => {
const clerkUser = await currentUser();
if (!clerkUser) redirect('/sign-in');
const userEmail = clerkUser.emailAddresses[0]?.emailAddress;
try {
const canView = await verifyUserPermission(userEmail, 'read');
if (!canView) {
redirect(`/error?reason=no-read-access`);
}
const room = await getDocument({
roomId: id,
userId: userEmail,
});
if (!room) {
redirect(`/error?reason=room-not-found`);
}
const canEdit = await verifyUserPermission(userEmail, 'edit');
const currentUserType = canEdit ? 'editor' : 'viewer';
const userIds = Object.keys(room.usersAccesses || {});
const users = await getClerkUsers({ userIds });
const usersData = users
.filter((user: User | null): user is User => !!user && !!user.email)
.map((user: User) => ({
...user,
userType: room.usersAccesses?.[user.email]?.includes('room:write')
? 'editor'
: 'viewer'
}));
return (
<main className="flex w-full flex-col items-center">
<CollaborativeRoom
roomId={id}
roomMetadata={room.metadata}
users={usersData}
currentUserType={currentUserType}
/>
</main>
);
} catch (error) {
console.error("Error loading document:", error);
redirect(`/error?reason=${encodeURIComponent('load-failed')}`);
}
};
export default Document;
The above file fetches document metadata (title, permissions, collaborators). It uses <CollaborativeRoom />
to manage real-time editing via Liveblocks.
- The
components/ActiveCollaborators.tsx
file contains code that shows who’s currently editing. It uses Liveblocks presence API to display avatars/names.
import { useOthers } from '@liveblocks/react/suspense'
import Image from 'next/image';
const ActiveCollaborators = () => {
const others = useOthers();
const collaborators = others.map((other) => other.info);
return (
<ul className="collaborators-list">
{collaborators.map(({ id, avatar, name, color }) => (
<li key={id}>
<Image
src={avatar}
alt={name}
width={100}
height={100}
className='inline-block size-8 rounded-full ring-2 ring-dark-100'
style={{border: `3px solid ${color}`}}
/>
</li>
))}
</ul>
)
}
export default ActiveCollaborators
- The
components/AddDocumentBtn.tsx
file contains the button UI "Start a blank document" to create a new document.
'use client';
import { createDocument } from '@/lib/actions/room.actions';
import { Button } from './ui/button'
import Image from 'next/image'
import { useRouter } from 'next/navigation';
const AddDocumentBtn = ({ userId, email }: AddDocumentBtnProps) => {
const router = useRouter();
const addDocumentHandler = async () => {
try {
const room = await createDocument({ userId, email });
if(room) router.push(`/documents/${room.id}`);
} catch (error) {
console.log(error)
}
}
return (
<Button type="submit" onClick={addDocumentHandler} className="gradient-blue flex gap-1 shadow-md">
<Image
src="/assets/icons/add.svg" alt="add" width={24} height={24}
/>
<p className="hidden sm:block">Start a blank document</p>
</Button>
)
}
export default AddDocumentBtn
On click, it creates a new document and navigates to the new document’s page.
- Wrap the editor environment in the
components/CollaborativeRoom.tsx
file, like this:
'use client';
import { ClientSideSuspense, RoomProvider } from '@liveblocks/react/suspense'
import { Editor } from '@/components/editor/Editor'
import Header from '@/components/Header'
import { SignedIn, SignedOut, SignInButton, UserButton } from '@clerk/nextjs'
import ActiveCollaborators from './ActiveCollaborators';
import { useEffect, useRef, useState } from 'react';
import { Input } from './ui/input';
import Image from 'next/image';
import { updateDocument } from '@/lib/actions/room.actions';
import Loader from './Loader';
import ShareModal from './ShareModal';
const CollaborativeRoom = ({ roomId, roomMetadata, users, currentUserType }: CollaborativeRoomProps) => {
const [documentTitle, setDocumentTitle] = useState(roomMetadata.title);
const [editing, setEditing] = useState(false);
const [loading, setLoading] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(null);
const updateTitleHandler = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if(e.key === 'Enter') {
setLoading(true);
try {
if(documentTitle !== roomMetadata.title) {
const updatedDocument = await updateDocument(roomId, documentTitle);
if(updatedDocument) {
setEditing(false);
}
}
} catch (error) {
console.error(error);
}
setLoading(false);
}
}
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if(containerRef.current && !containerRef.current.contains(e.target as Node)) {
setEditing(false);
updateDocument(roomId, documentTitle);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
}
}, [roomId, documentTitle])
useEffect(() => {
if(editing && inputRef.current) {
inputRef.current.focus();
}
}, [editing])
return (
<RoomProvider id={roomId}>
<ClientSideSuspense fallback={<Loader />}>
<div className="collaborative-room">
<Header>
<div ref={containerRef} className="flex w-fit items-center justify-center gap-2">
{editing && !loading ? (
<Input
type="text"
value={documentTitle}
ref={inputRef}
placeholder="Enter title"
onChange={(e) => setDocumentTitle(e.target.value)}
onKeyDown={updateTitleHandler}
disable={!editing}
className="document-title-input"
/>
) : (
<>
<p className="document-title">{documentTitle}</p>
</>
)}
{currentUserType === 'editor' && !editing && (
<Image
src="/assets/icons/edit.svg"
alt="edit"
width={24}
height={24}
onClick={() => setEditing(true)}
className="pointer"
/>
)}
{currentUserType !== 'editor' && !editing && (
<p className="view-only-tag">View only</p>
)}
{loading && <p className="text-sm text-gray-400">saving...</p>}
</div>
<div className="flex w-full flex-1 justify-end gap-2 sm:gap-3">
<ActiveCollaborators />
<ShareModal
roomId={roomId}
collaborators={users}
creatorId={roomMetadata.creatorId}
currentUserType={currentUserType}
/>
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</div>
</Header>
<Editor roomId={roomId} currentUserType={currentUserType} />
</div>
</ClientSideSuspense>
</RoomProvider>
)
}
export default CollaborativeRoom
This file initializes a Liveblocks room with the document ID. It connects editor state to Liveblocks storage and presence and handles the sharing functionality based on user's role.
- The
components/Collaborator.tsx
code represents a single collaborator.
import Image from 'next/image';
import React, { useState } from 'react'
import UserTypeSelector from './UserTypeSelector';
import { Button } from './ui/button';
import { removeCollaborator, updateDocumentAccess } from '@/lib/actions/room.actions';
const Collaborator = ({ roomId, creatorId, collaborator, email, user }: CollaboratorProps) => {
const [userType, setUserType] = useState(collaborator.userType || 'viewer');
const [loading, setLoading] = useState(false);
const shareDocumentHandler = async (type: string) => {
setLoading(true);
await updateDocumentAccess({
roomId,
email,
userType: type as UserType,
updatedBy: user
});
setLoading(false);
}
const removeCollaboratorHandler = async (email: string) => {
setLoading(true);
await removeCollaborator({ roomId, email });
setLoading(false);
}
return (
<li className="flex items-center justify-between gap-2 py-3">
<div className="flex gap-2">
<Image
src={collaborator.avatar}
alt={collaborator.name}
width={36}
height={36}
className="size-9 rounded-full"
/>
<div>
<p className="line-clamp-1 text-sm font-semibold leading-4 text-white">
{collaborator.name}
<span className="text-10-regular pl-2 text-blue-100">
{loading && 'updating...'}
</span>
</p>
<p className="text-sm font-light text-blue-100">
{collaborator.email}
</p>
</div>
</div>
{creatorId === collaborator.id ? (
<p className="text-sm text-blue-100">Creator</p>
): (
<div className="flex items-center">
<UserTypeSelector
userType={userType as UserType}
setUserType={setUserType || 'viewer'}
onClickHandler={shareDocumentHandler}
/>
<Button type="button" onClick={() => removeCollaboratorHandler(collaborator.email)}>
Remove
</Button>
</div>
)}
</li>
)
}
export default Collaborator
It renders collaborator avatar, name, email, current selection or user type.
Enforcing RBAC in the Application
Once roles and policies are in place, we can enforce them both:
1. Centralize permission-checking logic
The lib/actions/permissions.ts
file is typically where you’ll call Permit to check permissions. Paste these lines of code:
import axios from 'axios';
import permit from "../permit"; // Adjust the import path as necessary
const PERMIT_API_BASE = "https://api.permit.io/v2/facts";
const PERMIT_PROJ_ID = "your_permit_project_id";
const PERMIT_ENV_ID = "your_permit_env_id";
const PERMIT_API_KEY = process.env.PERMIT_API_KEY as string;
const permitApi = axios.create({
baseURL: PERMIT_API_BASE,
headers: {
Authorization: `Bearer ${PERMIT_API_KEY}`,
"Content-Type": "application/json",
},
});
// Cache for user existence checks
const userExistenceCache = new Map<string, boolean>();
export const checkUserExistsInPermit = async (email: string): Promise<boolean> => {
// Check cache first
if (userExistenceCache.has(email)) {
return userExistenceCache.get(email)!;
}
try {
const response = await permit.api.users.get(email);
userExistenceCache.set(email, true);
return true;
} catch (error) {
if (typeof error === "object" && error !== null && "status" in error && (error as any).status === 404) {
userExistenceCache.set(email, false);
return false;
}
console.error('Permit user check failed:', error);
// Assume user exists to prevent duplicate creation
return true;
}
};
export const syncUserWithPermit = async (email: string, role: 'editor' | 'viewer' | 'creator') => {
try {
const userExists = await checkUserExistsInPermit(email);
if (userExists) return;
// User doesn't exist, create them
await permitApi.post(`/${PERMIT_PROJ_ID}/${PERMIT_ENV_ID}/users`, {
key: email,
email,
role_assignments: [{ role, tenant: "default" }],
});
userExistenceCache.set(email, true);
} catch (error) {
console.error("Permit sync error:", error);
throw error;
}
};
export const assignRoleWithPermit = async (email: string, role: 'editor' | 'viewer' | 'creator') => {
try {
// First check if user exists as a fallback
const userExists = await checkUserExistsInPermit(email);
if (!userExists) {
console.log(`User ${email} not found, attempting to sync first...`);
await syncUserWithPermit(email, role);
await new Promise(resolve => setTimeout(resolve, 3000));
}
// Assign the role
await permitApi.post(`/${PERMIT_PROJ_ID}/${PERMIT_ENV_ID}/users/${email}/roles`, {
role,
tenant: "default"
});
console.log(`Successfully assigned ${role} role to ${email}`);
} catch (error) {
console.error(`Failed to assign ${role} role to ${email}:`, error);
throw error;
}
};
export const unassignRoleWithPermit = async (email: string, role: 'editor' | 'viewer') => {
try {
await permitApi.delete(`/${PERMIT_PROJ_ID}/${PERMIT_ENV_ID}/users/${email}/roles`, {
data: {
role,
tenant: "default"
}
});
} catch (error) {
console.error("Permit role unassignment error:", error);
throw error;
}
};
// Add this new function for checking permissions
export const checkUserPermission = async (email: string, action: string): Promise<boolean> => {
try {
const resource = "Document"; // Fixed resource type as per your requirement
const permitted = await permit.check(email, action, resource);
console.log(`Permission check for ${email} to ${action} on ${resource}:`, permitted);
return permitted;
} catch (error) {
console.error(`Error checking permission for ${email}:`, error);
return true; // Fallback to allow access
}
};
It is called in both server-side routes and client-side components for RBAC enforcement.
2. Manage document-level operations
We'll include functions to create, fetch, update, delete documents in the lib/actions/room-actions.ts
file, like this:
'use server';
import { nanoid } from 'nanoid'
import { liveblocks } from '../liveblocks';
import { revalidatePath } from 'next/cache';
import { getAccessType, parseStringify } from '../utils';
import { redirect } from 'next/navigation';
import {
assignRoleWithPermit,
checkUserPermission,
syncUserWithPermit,
unassignRoleWithPermit
} from './permissions';
export const createDocument = async ({ userId, email }: CreateDocumentParams) => {
const roomId = nanoid();
try {
// Sync user with Permit as editor
await syncUserWithPermit(email, 'editor').catch(() => {
console.log('Permit sync failed, proceeding with document creation');
});
const metadata = {
creatorId: userId,
email,
title: 'Untitled'
}
const usersAccesses: RoomAccesses = {
[email]: ['room:write']
}
const room = await liveblocks.createRoom(roomId, {
metadata,
usersAccesses,
defaultAccesses: []
});
revalidatePath('/');
return parseStringify(room);
} catch (error) {
console.log(`Error happened while creating a room: ${error}`);
throw error;
}
}
// Update the getDocument function to check permissions
export const getDocument = async ({ roomId, userId }: { roomId: string; userId: string }) => {
try {
// First check Permit permissions
const canView = await verifyUserPermission(userId, 'read');
if (!canView) {
throw new Error('You do not have permission to view this document');
}
const room = await liveblocks.getRoom(roomId);
return parseStringify(room);
} catch (error) {
console.log(`Error happened while getting a room: ${error}`);
throw error;
}
};
export const updateDocument = async (roomId: string, title: string) => {
try {
const updatedRoom = await liveblocks.updateRoom(roomId, {
metadata: {
title
}
})
revalidatePath(`/documents/${roomId}`);
return parseStringify(updatedRoom);
} catch (error) {
console.log(`Error happened while updating a room: ${error}`);
}
}
export const getDocuments = async (email: string ) => {
try {
const rooms = await liveblocks.getRooms({ userId: email });
return parseStringify(rooms);
} catch (error) {
console.log(`Error happened while getting rooms: ${error}`);
}
}
export const updateDocumentAccess = async ({ roomId, email, userType, updatedBy }: ShareDocumentParams) => {
try {
// Verify current user has permission to edit
const canEdit = await verifyUserPermission(updatedBy.email, 'edit');
if (!canEdit) {
throw new Error('You do not have permission to modify access');
}
const usersAccesses: RoomAccesses = {
[email]: getAccessType(userType) as AccessType,
}
const room = await liveblocks.updateRoom(roomId, {
usersAccesses
});
if (room) {
const notificationId = nanoid();
await liveblocks.triggerInboxNotification({
userId: email,
kind: '$documentAccess',
subjectId: notificationId,
activityData: {
userType,
title: `You have been granted ${userType} access to the document by ${updatedBy.name}`,
updatedBy: updatedBy.name,
avatar: updatedBy.avatar,
email: updatedBy.email
},
roomId
});
}
revalidatePath(`/documents/${roomId}`);
return parseStringify(room);
} catch (error) {
console.error(`Error updating document access:`, error);
throw error; // Let the UI component handle the error display
}
}
export const removeCollaborator = async ({ roomId, email }: {roomId: string, email: string}) => {
try {
const room = await liveblocks.getRoom(roomId)
if(room.metadata.email === email) {
throw new Error('You cannot remove yourself from the document');
}
// Unassign all roles in Permit
await unassignRoleWithPermit(email, 'editor');
await unassignRoleWithPermit(email, 'viewer');
const updatedRoom = await liveblocks.updateRoom(roomId, {
usersAccesses: {
[email]: null
}
})
revalidatePath(`/documents/${roomId}`);
return parseStringify(updatedRoom);
} catch (error) {
console.log(`Error happened while removing a collaborator: ${error}`);
throw error;
}
}
export const deleteDocument = async (roomId: string) => {
try {
await liveblocks.deleteRoom(roomId);
revalidatePath('/');
redirect('/');
} catch (error) {
console.log(`Error happened while deleting a room: ${error}`);
}
}
// Update the verifyUserPermission function
export const verifyUserPermission = async (email: string, requiredAction: 'edit' | 'read'): Promise<boolean> => {
try {
const hasPermission = await checkUserPermission(email, requiredAction);
if (!hasPermission) {
console.warn(`User ${email} lacks permission to ${requiredAction} document`);
return false;
}
return true;
} catch (error) {
console.error(`Permission verification failed for ${email}:`, error);
// Remove the redirect here - let the calling function handle it
return false;
}
};
It interacts with the database and checks for permissions before performing actions.
3. Manages document comments
The components/Comments.tsx
file manages document comments, displays comments linked to ranges or selections, and allows editors/creators to add, edit or delete comments.
import { cn } from '@/lib/utils';
import { useIsThreadActive } from '@liveblocks/react-lexical';
import { Composer, Thread } from '@liveblocks/react-ui';
import { useThreads } from '@liveblocks/react/suspense';
import React from 'react'
const ThreadWrapper = ({ thread }: ThreadWrapperProps) => {
const isActive = useIsThreadActive(thread.id);
return (
<Thread
thread={thread}
data-state={isActive ? 'active' : null}
className={cn('comment-thread border',
isActive && '!border-blue-500 shadow-md',
thread.resolved && 'opacity-40'
)}
/>
)
}
const Comments = () => {
const { threads } = useThreads();
return (
<div className="comments-container">
<Composer className="comment-composer" />
{threads.map((thread) => (
<ThreadWrapper key={thread.id} thread={thread} />
))}
</div>
)
}
export default Comments
You can tag a collaborator in a comment so they get it as an in-app notification and attend to it promptly. Other things you can do include editing, deleting, resolving a comment, and responding to a comment in thread so as not to break the line of communication. These features are availiable thanks to Liveblocks.
4. Handle document deletion
The components/DeleteModal.tsx
file contains the delete dialog. It displays a modal asking creators to confirm deletion.
It calls delete API from the lib/actions/room.actions.ts
file.
"use client";
import Image from "next/image";
import { useState } from "react";
import { deleteDocument } from "@/lib/actions/room.actions";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "./ui/button";
export const DeleteModal = ({ roomId }: DeleteModalProps) => {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const deleteDocumentHandler = async () => {
setLoading(true);
try {
await deleteDocument(roomId);
setOpen(false);
} catch (error) {
console.log("Error notif:", error);
}
setLoading(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="min-w-9 rounded-xl bg-transparent p-2 transition-all">
<Image
src="/assets/icons/delete.svg"
alt="delete"
width={20}
height={20}
className="mt-1"
/>
</Button>
</DialogTrigger>
<DialogContent className="shad-dialog">
<DialogHeader>
<Image
src="/assets/icons/delete-modal.svg"
alt="delete"
width={48}
height={48}
className="mb-4"
/>
<DialogTitle>Delete document</DialogTitle>
<DialogDescription>
Are you sure you want to delete this document? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-5">
<DialogClose asChild className="w-full bg-dark-400 text-white">
Cancel
</DialogClose>
<Button
variant="destructive"
onClick={deleteDocumentHandler}
className="gradient-red w-full"
>
{loading ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
5. In-app notifications
The components/Notifications.tsx
file shows in-app notifications, like when a user is granted access permission or a user is mentioned. Paste these following lines of code in the file:
'use client'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { InboxNotification, InboxNotificationList, LiveblocksUIConfig } from "@liveblocks/react-ui"
import { useInboxNotifications, useUnreadInboxNotificationsCount } from "@liveblocks/react/suspense"
import Image from "next/image"
import { ReactNode } from "react"
const Notifications = () => {
const { inboxNotifications } = useInboxNotifications();
const { count } = useUnreadInboxNotificationsCount();
const unreadNotifications = inboxNotifications.filter((notification) => !notification.readAt);
return (
<Popover>
<PopoverTrigger className="relative flex size-10 items-center justify-center rounded-lg">
<Image
src="/assets/icons/bell.svg"
alt="inbox"
width={24}
height={24}
/>
{count > 0 && (
<div className="absolute right-2 top-2 z-20 size-2 rounded-full bg-blue-500" />
)}
</PopoverTrigger>
<PopoverContent align="end" className="shad-popover">
<LiveblocksUIConfig
overrides={{
INBOX_NOTIFICATION_TEXT_MENTION: (user: ReactNode) => (
<>{user} mentioned you.</>
)
}}
>
<InboxNotificationList>
{unreadNotifications.length <= 0 && (
<p className="py-2 text-center text-dark-500">No new notifications</p>
)}
{unreadNotifications.length > 0 && unreadNotifications.map((notification) => (
<InboxNotification
key={notification.id}
inboxNotification={notification}
className="bg-dark-200 text-white"
href={`/documents/${notification.roomId}`}
showActions={false}
kinds={{
thread: (props) => (
<InboxNotification.Thread {...props}
showActions={false}
showRoomName={false}
/>
),
textMention: (props) => (
<InboxNotification.TextMention {...props}
showRoomName={false}
/>
),
$documentAccess: (props) => (
<InboxNotification.Custom {...props} title={props.inboxNotification.activities[0].data.title} aside={<InboxNotification.Icon className="bg-transparent">
<Image
src={props.inboxNotification.activities[0].data.avatar as string || ''}
width={36}
height={36}
alt="avatar"
className="rounded-full"
/>
</InboxNotification.Icon>}>
{props.children}
</InboxNotification.Custom>
)
}}
/>
))}
</InboxNotificationList>
</LiveblocksUIConfig>
</PopoverContent>
</Popover>
)
}
export default Notifications
6. Handle document sharing logic
In the components/ShareModal.tsx
file, paste these lines of code:
'use client'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { useSelf } from '@liveblocks/react/suspense';
import React, { useState } from 'react'
import { Button } from "./ui/button";
import Image from "next/image";
import { Label } from "./ui/label";
import { Input } from "./ui/input";
import UserTypeSelector from "./UserTypeSelector";
import Collaborator from "./Collaborator";
import { updateDocumentAccess } from "@/lib/actions/room.actions";
import { toast } from "sonner"
const ShareModal = ({ roomId, collaborators, creatorId, currentUserType }: ShareDocumentDialogProps) => {
const user = useSelf();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [assigningRole, setAssigningRole] = useState(false);
const [roleAssigned, setRoleAssigned] = useState(false);
const [email, setEmail] = useState('');
const [userType, setUserType] = useState<UserType>('viewer');
const handleAssignRole = async () => {
if (!email) {
toast.error('Please enter an email address first');
return;
}
setAssigningRole(true);
try {
const response = await fetch('/api/permit/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: email,
role: userType,
email,
})
});
if (!response.ok) {
throw new Error('Failed to assign role');
}
toast.success(`Role assigned successfully`);
setRoleAssigned(true);
} catch (error) {
console.error('Error assigning role:', error);
toast.error('Failed to assign role');
} finally {
setAssigningRole(false);
}
};
const shareDocumentHandler = async () => {
setLoading(true);
try {
const result = await updateDocumentAccess({
roomId,
email,
userType,
updatedBy: user.info,
});
toast.success('Access updated successfully');
setRoleAssigned(false);
} catch (error) {
console.error('Error sharing document:', error);
toast.error(error instanceof Error ? error.message : 'Failed to update access');
} finally {
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>
<Button className="gradient-blue flex h-9 gap-1 px-4" disabled={currentUserType !== 'editor'}>
<Image
src="/assets/icons/share.svg"
alt="share"
width={20}
height={20}
className="min-w-4 md:size-5"
/>
<p className="mr-1 hidden sm:block">Share</p>
</Button>
</DialogTrigger>
<DialogContent className="shad-dialog">
<DialogHeader>
<DialogTitle>Manage document access</DialogTitle>
<DialogDescription>
Assign roles and share this document with collaborators
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="email" className="text-blue-100">
Email address
</Label>
<Input
id="email"
placeholder="Enter email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-2 text-black"
/>
</div>
<div>
<Label className="text-blue-100">Access level</Label>
<UserTypeSelector
userType={userType}
setUserType={setUserType}
/>
</div>
<div className="flex gap-3">
<Button
onClick={handleAssignRole}
className="flex-1 bg-dark-400 hover:bg-dark-300"
disabled={assigningRole || !email}
>
{assigningRole ? 'Assigning...' : 'Assign Role'}
</Button>
<Button
onClick={shareDocumentHandler}
className="flex-1 gradient-blue"
disabled={loading || !roleAssigned}
>
{loading ? 'Sending...' : 'Invite'}
</Button>
</div>
</div>
<div className="mt-6 space-y-2">
<h3 className="text-sm font-medium text-blue-100">Current collaborators</h3>
<ul className="flex flex-col">
{collaborators.map((collaborator) => (
<Collaborator
key={collaborator.id}
roomId={roomId}
creatorId={creatorId}
email={collaborator.email}
collaborator={collaborator}
user={user.info}
/>
))}
</ul>
</div>
</DialogContent>
</Dialog>
)
}
export default ShareModal
It contains the UI for sharing document and assigning roles. It allows creator/editors to invite other users by email or user ID and assign roles (viewer or editor) to invitees via UserTypeSelector
.
7. Selecting user's role
Finally, the components/UserTypeSelector.tsx
file is a dropdown to choose role type (viewer and editor) which is used inside the ShareModal
. It updates state for role assignment.
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const UserTypeSelector = ({ userType, setUserType, onClickHandler }: UserTypeSelectorParams) => {
const accessChangeHandler = (type: UserType) => {
setUserType(type);
onClickHandler && onClickHandler(type);
}
return (
<Select value={userType} onValueChange={(type: UserType) => accessChangeHandler(type)}>
<SelectTrigger className="shad-select">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-none bg-dark-200">
<SelectItem value="viewer" className="shad-select-item">can view</SelectItem>
<SelectItem value="editor" className="shad-select-item">can edit</SelectItem>
</SelectContent>
</Select>
)
}
export default UserTypeSelector
For the other components, check out the code in the GitHub repo.
Here's a demo of the collaborative docs application after everything is set.
Conclusion
We’ve covered a lot.
Here’s a recap of what we did. We built a functional, collaborative SaaS app with:
- Built a real-time collaborative document app
- Used Clerk for secure and seamless authentication
- Integrated Liveblocks for live editing & syncing
- Implemented RBAC using Permit.io
Check out the GitHub repo for the complete codebase and the live application.
By implementing RBAC, we ensured that users only see and do what they’re allowed to, making our app secure, scalable, and user-friendly.
If you're building a multi-user app, Permit + Clerk + Liveblocks with Next.js is a great combo you should definitely try.
There are various ways authorization can be implemented in other real-world applications such as implementing multi-tenant RBAC in Nuxt.js, implementing multi-tenancy RBAC in MongoDB, and using JWTs for authorization.
The simplicity of RBAC shines here. I’ve struggled with ABAC’s complexity in past projects—this convinced me to prioritize RBAC for my next collaborative tool. Thanks!