Learn how to build a secure, real-time collaborative document editor with Next.js, Appwrite, Liveblocks, and Permit.io using ReBAC for flexible and secure relationship-based access control.
\ Features include:
Real-time collaboration with presence awareness.
Secure storage and login management.
Scalable permissions for complex access needs.
\
This guide offers a strong foundation for building advanced collaborative tools. To make it production-ready, you’ll need to implement additional steps like error handling and conflict resolution based on your requirements.
\
Collaborative tools such as Figma, Google Docs, and Notion are essential for teams spread across different locations or time zones. These platforms simplify working together, but they also introduce challenges like ensuring the right people have the right access without compromising sensitive information. That’s where proper access control comes into play.
\ In this guide, we will build a collaborative document editor using Next.js for the front end, Appwrite for authentication and storage, Liveblocks for smooth collaboration, and Permit.io to manage advanced authorization with Relationship-Based Access Control (ReBAC).
PrerequisitesBefore we start, make sure you have these tools installed on your computer
\ You should also know the basics of React and TypeScript, as we’ll be using them in this tutorial.
Tools we will useTo build the real-time collaborative document editor, we will use the following tools. Let’s discuss their purpose and how they work together.
AppwriteAppwrite is an open-source backend-as-a-service platform that offers solutions for authentication, databases, functions, storage, and messaging for your projects using the frameworks and languages you prefer. In this tutorial, we will use its authentication and storage solutions for user login/signup and to store the document content.
LiveblocksLiveblocks is a platform that lets you add collaborative editing, comments, and notifications to your app. It offers a set of tools that you can use to include collaboration features, so you can pick what you need based on your needs.
\ Liveblocks works well with popular frontend frameworks and libraries, making it easy to quickly add real-time collaboration to any application.
Permit.ioBuilding authorization logic from scratch takes a lot of time. Permit.io makes this easier by providing a simple interface to manage permissions separately from your code. This keeps your access rules organized, simplifies management, and reduces the effort needed to maintain your code.
\ In this tutorial, we will use Relationship-Based Access Control (ReBAC), an authorization model that is more flexible than traditional role-based access control. ReBAC allows you to set access rules based on the relationships between users and resources. For our document editor, this means we can easily set up permissions like:
\
Document owners have full control over their documents
Editors are able to change content but not delete the document
Viewers having read-only access
\
Next.js is a popular framework for building server-side rendered web applications quickly and efficiently. Here is the application structure.
\
./src ├── app │ ├── Providers.tsx │ ├── Room.tsx │ ├── api │ │ └── liveblocks-auth │ │ └── route.ts │ ├── layout.tsx │ ├── page.tsx │ └── room │ └── page.tsx ├── components │ ├── Avatars.module.css │ ├── Avatars.tsx │ ├── ConnectToRoom.module.css │ ├── ConnectToRoom.tsx │ ├── Editor.module.css │ ├── Editor.tsx │ ├── ErrorListener.module.css │ ├── ErrorListener.tsx │ ├── Loading.module.css │ ├── Loading.tsx │ ├── Toolbar.module.css │ └── Toolbar.tsx ├── globals.css ├── hooks │ └── useRoomId.ts └── liveblocks.config.ts\ Alright, we talked about the tools we’ll use and looked at the project structure. Now, let’s start setting up the development environment.
Setting Up the Development EnvironmentLet’s start by creating a new Next.js project and installing the needed dependencies.
\
npx create-next-app@latest collaborative-docs --typescript cd collaborative-docs\ For components, we will use shadcn UI, so let’s set it up.
\
npx shadcn@latest init\ This will install shadcn in our project. Now, let’s add the components.
\
npx shadcn@latest add alert alert-dialog avatar button card checkbox command dialog dropdown-menu form input label popover toast tooltip tabs Setup AppwriteFirst, we need to set up Appwrite for authentication and document storage. Visit https://cloud.appwrite.io/, sign up, then set up the organization and select the free plan, which is enough for our needs.
\
\ Click on the “Create Project” button and go to the project creation form.
\
\ Provide the project name “Collaborative Docs” and click on “Next.”
\
\ We will use the default region and then click on “Create.”
\
\ We will be using Appwrite for our web application, so let’s add a Web platform.
\
\ Provide the name “Collaborative Docs” and the hostname “localhost,” then click next.
\
\ We can skip the optional steps and move forward.
\ To authenticate users, we will use the email/password method. Go to the Auth section in the left panel, enable “email/password,” and disable other methods.
\
\ Next, go to the security tab and set the maximum session limit to 1.
\
\ Alright, we have set up the authentication method. Now, let’s create a database and a collection to store the data.
\ Go to the “Databases” section in the left panel and click on “Create Database”.
\
\ Then provide the name “collaborativedocsdb” and create it.
\
\ After creating the database, you will be redirected to the collections page. From there, click on the “Create collection” button.
\
\ Then provide the name “document_collection” and create it.
\
\ After creating the collection, you will be redirected to the “document_collection” page. Then, go to the “Settings” tab, scroll down to permissions and document security, and add permissions for “Users.” Enforce document security so that only the user who created the document can perform the allowed operations.
\
\ Go to the “Attributes” tab and create four attributes:
\
roomId (string, 36, required)
storageData (string, 1073741824)
title (string, 128)
created_by (string, 20, required)
\
At this point, you should have four attributes set up for the document.
\
\ Alright, the platform setup is done. Now, let’s install the Appwrite dependencies.
\
npm install appwrite [email protected]\ Create a .env.development.local file to store keys and secrets as environment variables.
\
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 NEXT_PUBLIC_APPWRITE_PROJECT_ID=your_project_key\ Create a new file lib/appwrite.ts
\
import { Client, Account, Databases } from 'appwrite'; const client = new Client() .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || '') // Your API Endpoint .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || ''); // Your project ID; export const account = new Account(client); export const databases = new Databases(client); export const APPWRITE_CLIENT = { account, databases, };\ Great, the Appwrite setup is done. Now, let’s set up Liveblocks for real-time collaboration.
Setup LiveblocksGo to https://liveblocks.io/ and sign up. You will be taken to the dashboard, where two projects will be created for you.
\
\ Click on “Project development,” then go to the “API Keys” section on the left panel. Copy the Public key and secret key.
\
\ Now add the NEXT_PUBLIC_LIVE_BLOCKS_PUBLIC_API_KEY and NEXT_PUBLIC_LIVE_BLOCKS_SECRET_KEY to .env.development.local
\
NEXT_PUBLIC_LIVE_BLOCKS_PUBLIC_API_KEY=pk_dev_xxxxxx_xxxxxxxx NEXT_PUBLIC_LIVE_BLOCKS_SECRET_KEY=sk_dev_xxxxxx_xxxxxxxx\ Later, we will use this public API key in LiveblocksProvider
\ Let’s install the Liveblocks dependencies and also Tiptap (for building a rich text editor).
\
npm install @liveblocks/client @liveblocks/node @liveblocks/react @liveblocks/yjs yjs @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor y-prosemirror\ Create a new file lib/liveblocks.ts
\
import { Liveblocks } from '@liveblocks/node'; export const LIVEBLOCKS_CLIENT = new Liveblocks({ secret: process.env.NEXT_PUBLIC_LIVE_BLOCKS_SECRET_KEY!, });\ Create a new file liveblocks.config.ts to define types.
\
declare global { interface Liveblocks { Presence: { cursor: { x: number; y: number } | null }; UserMeta: { id: string; info: { name: string; color: string; }; }; } } export {};\ For now, this completes the Liveblocks setup. Let’s move on to the next section to set up the most important component of the application permit.io.
Setup Permit.ioTo get started, you’ll first need to create an account. Sign up on Permit.io and create a new workspace for your team or organization. This will allow you to invite team members and use all the collaboration features Permit.io offers.
\ After creating a workspace, create a project and name it “Collaborative Docs.”
\ Then, create an environment called Development.
\
\ Now click on “open dashboard,” then go to the “policy” section in the left panel, and select the “resources” tab.
\ Click on “Add Resource,” and a form panel will open.
\
\ Provide the following details and save.
Name: Document
Key: document
Actions: create, delete, read, update
ReBAC Roles: owner, editor, viewer
\
We want to set up roles for document resources so that every instance has its own roles like owner, viewer, and editor.
\
\ Navigate to the “Policy Editor” tab and set up the permissions for the roles we created.
\
Owner: should have all permissions
Editor: should have update and read permissions
Viewer: should have read-only permission
\
\ Super easy, right? With just a few steps, our resources and permissions are set. That’s the benefit of using Permit.io — it makes everything easy.
\ Let’s install the permit.io dependencies and set up the client so we can interact with permit.io programmatically. It supports SDKs in many languages, and we will use the Node.js SDK.
\
npm install permitio\ The SDK needs an API key. You can get it by going to Settings → API Keys. Copy the secret and add it as an environment variable NEXT_PUBLIC_PERMIT_API_KEY in .env.development.local.
\
\ To use ReBAC functionality with the Permit.io SDK, we will install the PDP (Policy-Decision-Point) via Docker, as Permit.io currently recommends it. For zero latency, great performance, high availability, and improved security, they are working on making it available directly on the cloud soon.
\ You can install Docker by visiting https://docs.docker.com/get-started/get-docker/ . It’s very easy just install it and start Docker Desktop.
\ Create a docker-compose.yml file in the root directory.
\
version: '3' services: pdp-service: image: permitio/pdp-v2:latest ports: - "7766:7000" environment: - PDP_API_KEY=permit_key_xxxxxxxxx - PDP_DEBUG=True stdin_open: true tty: true\ Then run the command to start the PDP service.
\
docker compose up -d\ Once it’s running, you can visit http://localhost:7766 and you should see the response below.
\
{ "status": "ok" }\ Great, now let’s set up the Permit.io SDK by creating lib/permitio.ts.
\
import { Permit } from 'permitio'; const pdpUrl = process.env.PDP_URL || 'http://localhost:7766'; const apiKey = process.env.NEXT_PUBLIC_PERMIT_API_KEY!; export const PERMITIO_SDK = new Permit({ token: apiKey, pdp: pdpUrl, });\ Awesome, we have set up all the components of our application. In the next section, we will implement authentication for sign-up, login, and logout with Appwrite, as well as authorization using Permit ReBAC with their SDK.
\n Implementing Authentication and AuthorizationWe will manage some global states in our application, so let’s install zustand for state management.
\
npm install zustand\ After installing Zustand, let’s create a store/authStore.ts to manage authentication states easily.
\
import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { ID, Models } from 'appwrite'; const { account } = APPWRITE_CLIENT; const ERROR_TIMEOUT = 8000; interface AuthState { user: Models.User\ Here we are using Zustand create to make the useAuthStore hook. It has methods for login, registration, logout, and checking authentication status while managing user and session data. The session state is saved in local storage to keep session information even after the page reloads.
\ Let’s create our first component components/navbar.tsx and add the following code. It’s a simple component with a brand name and a link or logout button based on the user's authentication status.
\
'use client'; import { useAuthStore } from '@/store/authStore'; import { FileText } from 'lucide-react'; import Link from 'next/link'; import { Button } from './ui/button'; export default function Navbar() { const { user, logout } = useAuthStore(); return (\ Since we need to keep the authentication state and only let logged-in users access the dashboard page (which we will create soon), let’s create a components/auth-wrapper.tsx component.
\
'use client'; import { useAuthStore } from '@/store/authStore'; import { LoaderIcon } from 'lucide-react'; import { redirect, usePathname } from 'next/navigation'; import { useEffect } from 'react'; export default function AuthWrapper({ children, }: { children: React.ReactNode; }) { const pathname = usePathname(); const { user, isLoading, checkAuth } = useAuthStore(); useEffect(() => { checkAuth(); }, [checkAuth]); if (isLoading) { return (\ AuthWrapper will take children as a prop and check if the user is authenticated. If they are, it will render the children. If not, and they are trying to access the dashboard page, it will redirect them to the login page.
\ Then update the root layout component with the following code:
\
import Navbar from '@/components/navbar'; import AuthWrapper from '@/components/auth-wrapper'; import { Toaster } from '@/components/ui/toaster'; ... // existing code export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return (\ Cool, the layout setup is done. Now let’s create a landing page for our application. Create components/landing-page.tsx and add the following code.
\
'use client'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { FileText, Users, Share2, ShieldCheck } from 'lucide-react'; import { useAuthStore } from '@/store/authStore'; export default function LandingPage() { const { user } = useAuthStore(); return (Create, edit, and share documents with ease. Powerful collaboration tools for teams of all sizes.
Create beautiful documents with our powerful rich text editor.
Work together in real-time with your team members.
Share your documents with others quickly and securely.
Control access with Owner, Editor, and Viewer roles.
Join thousands of teams already using CollabDocs to streamline their document workflows.
\ Then update the app/page.tsx with the code below.
\
import LandingPage from '@/components/landing-page'; export default function Home() { return\ Start the development server and visit localhost:3000. You will see a nice landing page for our application.
\
\ Alright, the landing page is done, now we will be creating three pages, login, signup, and dashboard.
\ Create components/login.tsx and components/register.tsx and add the following code.
\
// login.tsx 'use client'; import { useState } from 'react'; import { useAuthStore } from '../store/authStore'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; export default function Login() { const searchParams = useSearchParams(); const nextPath = searchParams.get('next'); const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const { login, error } = useAuthStore(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await login(email, password); if (!error) { router.push(nextPath ?? '/dashboard'); } }; return ({error}
}\ Here we have a simple form with email and password fields. When the user submits, we call the login method from the useAuthStore hook. If the login is successful, we redirect the user to the dashboard page. If there is an error, it will be shown using the error state.
\
// register.tsx 'use client'; import { useState } from 'react'; import { useAuthStore } from '../store/authStore'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; export default function Register() { const searchParams = useSearchParams(); const nextPath = searchParams.get('next'); const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [name, setName] = useState(''); const { register, error } = useAuthStore(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await register(email, password, name); if (!error) { router.push('/dashboard'); } }; return ({error}
}\ The Register component is simple, with fields for name, email, and password. When the user submits, we call the register method from the useAuthStore hook. If registration is successful, the user is redirected to the dashboard page. If there's an error, it will be shown using the error state.
\ Create the login and signup pages and display the respective components.
\
// app/login/page.tsx import Login from '@/components/login'; export default function LoginPage() { return (\
// app/signup/page.tsx import Register from '@/components/register'; export default function SignUpPage() { return (Create a basic dashboard page for now, and we will update it later.
\
// app/dashboard/page.tsx 'use client'; import { useAuthStore } from '@/store/authStore'; export default function DashboardPage() { const { user } = useAuthStore(); return (\ Now start the dev server using the npm run dev command. Try visiting the /dashboard page, and you will be redirected to the login page. This happens because we added AuthWrapper in the root layout, which checks user authentication and redirects accordingly.
\ Try to register with a valid name, email, and password, and you will be redirected to the dashboard page.
\
\ Authentication is set up and working as expected. Now, let’s add server actions for authorization using the Permit.io SDK.
\ As authorization code should run on the server side, let’s createapp/actions.tsand add the following initial code
\
// app/actions.ts 'use server'; import { PERMITIO_SDK } from '@/lib/permitio'; // Permit.io actions interface User { email: string; key: string; } interface ResourceInstance { key: string; resource: string; } interface ResourceInstanceRole { user: string; role: string; resource_instance: string; } export type PermissionType = 'read' | 'create' | 'delete' | 'update'; interface ResourcePermission { user: string; resource_instance: string; permissions: PermissionType[]; }\ When a new user signs up in our application, we need to sync that user with Permit.io. Let’s add our first action, syncUserWithPermit.
\
// app/actions.ts ... /** * * @param user `{email: string, key: string}` */ export async function syncUserWithPermit(user: User) { try { const syncedUser = await PERMITIO_SDK.api.syncUser(user); console.log('User synced with Permit.io', syncedUser.email); } catch (error) { console.error(error); } }\ Each user’s email ID will be unique, so we will use it for both the email and key attributes.
\ Now let’s use syncUserWithPermit in the register method of our AuthStore. This way, when a user is created, they are also synced with Permit.io.
\
// store/authStore.ts ... register: async (email, password, name) => { ... // sync user with Permit.io await syncUserWithPermit({ email: user.email, key: user.email }); }\ Next, add three more actions that we will use later.
\
// app/actions.ts ... async function getPermitioUser(key: string) { try { const user = await PERMITIO_SDK.api.users.getByKey(key); return user; } catch (error) { console.error(error); return null; } } /** * * @param resourceInstance `{key: string, resource: string}` * @returns createdInstance */ export async function createResourceInstance( resourceInstance: ResourceInstance ) { console.log('Creating a resource instance...'); try { const createdInstance = await PERMITIO_SDK.api.resourceInstances.create({ key: resourceInstance.key, tenant: 'default', resource: resourceInstance.resource, }); console.log(`Resource instance created: ${createdInstance.key}`); return createdInstance; } catch (error) { if (error instanceof Error) { console.log(error.message); } else { console.log('An unknown error occurred'); } return null; } } /** * * @param resourceInstanceRole `{user: string, role: string, resource_instance: string}` * @returns assignedRole */ export async function assignResourceInstanceRoleToUser( resourceInstanceRole: ResourceInstanceRole ) { try { const user = await getPermitioUser(resourceInstanceRole.user); if (!user) { await syncUserWithPermit({ email: resourceInstanceRole.user, key: resourceInstanceRole.user, }); } const assignedRole = await PERMITIO_SDK.api.roleAssignments.assign({ user: resourceInstanceRole.user, role: resourceInstanceRole.role, resource_instance: resourceInstanceRole.resource_instance, tenant: 'default', }); console.log(`Role assigned: ${assignedRole.role} to ${assignedRole.user}`); return assignedRole; } catch (error) { if (error instanceof Error) { console.log(error.message); } else { console.log('An unknown error occurred'); } return null; } } /** * * @param resourcePermission `{user: string, resource_instance: string, permission: string}` * @returns permitted */ export async function getResourcePermissions( resourcePermission: ResourcePermission ) { try { const permissions = resourcePermission.permissions; const permissionMap: Record\
\ With just a few lines of code, we have the authorization logic. That’s the power of Permit.io.
\ Great, now let’s create our dashboard page so users can view their documents, create new ones, and search through them.
\
// components/loader.tsx import { LoaderIcon } from 'lucide-react'; import React from 'react'; const Loader = () => { return (You don't have any documents yet. Click on the{' '} New Document button to create one.
)}\ Here we have the fetchDocuments method that gets the documents created by the user. We also have the handleCreateDocument method, which creates a document and then sets up the document resource in Permit.io assigning the creator the role of "owner".
\
\ Create a document titled “What is Relationship-Based Access Control (ReBAC)?” and save it.
\
\ Now let’s create a document page and its components.
\ The ShareDocument component allows users to share documents with other users. It takes two props: documentId and permission. If permission is true, the share button will appear; otherwise, nothing will appear.
\
// components/share-document.tsx 'use client'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { Share2 } from 'lucide-react'; import { toast } from '@/hooks/use-toast'; import { assignResourceInstanceRoleToUser } from '@/app/actions'; interface ShareDocumentProps { documentId: string; permission: boolean; } export function ShareDocument({ documentId, permission }: ShareDocumentProps) { const [email, setEmail] = useState(''); const [role, setRole] = useState('viewer'); const [isOpen, setIsOpen] = useState(false); const [isSharing, setIsSharing] = useState(false); const handleShare = async () => { if (!email) { toast({ title: 'Error', description: 'Please enter an email address.', variant: 'destructive', }); return; } setIsSharing(true); try { await assignResourceInstanceRoleToUser({ user: email, role, resource_instance: `document:${documentId}`, }); await navigator.clipboard.writeText(window.location.href); toast({ title: 'Success', description: `Document shared successfully. Link copied to clipboard.`, }); setIsOpen(false); setEmail(''); setRole('viewer'); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to share the document. Please try again.', variant: 'destructive', }); } finally { setIsSharing(false); } }; if (!permission) { return null; } return ( ); }\ Next is the DeleteDocument component, which allows the user to delete the document if they have permission. If they don't, nothing will be displayed. It takes two props: documentId and permission.
\
// components/delete-document.tsx 'use client'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Trash2 } from 'lucide-react'; import { toast } from '@/hooks/use-toast'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { useRouter } from 'next/navigation'; interface DeleteDocumentProps { documentId: string; permission: boolean; } export function DeleteDocument({ documentId, permission, }: DeleteDocumentProps) { const router = useRouter(); const [isOpen, setIsOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const handleDelete = async () => { setIsDeleting(true); try { await APPWRITE_CLIENT.databases.deleteDocument( 'database_id', 'collection_id', documentId ); toast({ title: 'Success', description: 'Document deleted successfully.', }); setIsOpen(false); router.push('/dashboard'); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to delete the document. Please try again.', variant: 'destructive', }); } finally { setIsDeleting(false); } }; if (!permission) { return null; } return ( ); }\ Right now, it’s open to everyone, meaning anyone can access it, and it doesn’t show the collaborative editor with the content. In the next section, we will create the collaborative editor (using liveblock) and set up permissions using the Permit.io server action we wrote earlier.
Building the Collaborative Document EditorGreat job following along so far! Now comes the exciting part: building the collaborative editor. This will allow different users to work together in real time. Each user will have different permissions, so they can read, update, or delete the document based on their access rights.
\ Let’s begin by adding some CSS to our global.css file.
\
... /* Give a remote user a caret */ .collaboration-cursor__caret { border-left: 1px solid #0d0d0d; border-right: 1px solid #0d0d0d; margin-left: -1px; margin-right: -1px; pointer-events: none; position: relative; word-break: normal; } /* Render the username above the caret */ .collaboration-cursor__label { font-style: normal; font-weight: 600; left: -1px; line-height: normal; position: absolute; user-select: none; white-space: nowrap; font-size: 14px; color: #fff; top: -1.4em; border-radius: 6px; border-bottom-left-radius: 0; padding: 2px 6px; pointer-events: none; }\ Then, create a few components to make our editor easier to use.
\ The Avatars component will display the avatars of online users who are collaborating on the same document. It uses two hooks from Liveblocks: useOthers and useSelf.
\
// components/editor/user-avatars.tsx import { useOthers, useSelf } from '@liveblocks/react/suspense'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '../ui/tooltip'; export function Avatars() { const users = useOthers(); const currentUser = useSelf(); return (\ Next, we will need a toolbar for showing different formatting options in our editor. it will have formatting options like bold, italic, strike, list and so on.
\
// components/editor/icons.tsx import React from 'react'; export const BoldIcon = () => ( ); export const ItalicIcon = () => ( ); export const StrikethroughIcon = () => ( ); export const BlockQuoteIcon = () => ( ); export const HorizontalLineIcon = () => ( ); export const OrderedListIcon = () => ( ); export const BulletListIcon = () => ( ); // components/editor/toolbar.tsx import { Editor } from '@tiptap/react'; import { BoldIcon, ItalicIcon, StrikethroughIcon, BlockQuoteIcon, HorizontalLineIcon, BulletListIcon, OrderedListIcon, } from './icons'; import { cn } from '@/lib/utils'; type Props = { editor: Editor | null; }; type ButtonProps = { editor: Editor; isActive: boolean; ariaLabel: string; Icon: React.FC; onClick: () => void; }; const ToolbarButton = ({ isActive, ariaLabel, Icon, onClick }: ButtonProps) => ( ); export function Toolbar({ editor }: Props) { if (!editor) { return null; } return (\ Now, let’s use Tiptap and Liveblocks together to create our collaborative editor component.
\ Liveblocks uses rooms to let users work together. Each room can have its own permissions and details. For real-time collaboration, we will use the useRoom hook and LiveblocksYjsProvider with Tiptap's Collaboration and CollaborationCursor extensions. It takes one prop, isReadOnly, to decide if users can edit the document or not.
\
// components/editor/collaborative-editor.tsx 'use client'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Collaboration from '@tiptap/extension-collaboration'; import CollaborationCursor from '@tiptap/extension-collaboration-cursor'; import * as Y from 'yjs'; import { LiveblocksYjsProvider } from '@liveblocks/yjs'; import { useRoom, useSelf } from '@liveblocks/react/suspense'; import { useEffect, useState } from 'react'; import { Toolbar } from './toolbar'; import { Avatars } from './user-avatars'; export function CollaborativeEditor({ isReadOnly }: { isReadOnly: boolean }) { const room = useRoom(); const [doc, setDoc] = useState\ As we discussed, Liveblocks uses rooms for collaboration, so we need a way to create a Liveblock session with permissions for the active room. This way, each user in the room can have their unique identity and permissions.
\ Create app/api/liveblock-session/route.ts and add the following code.
\
import { LIVEBLOCKS_CLIENT } from '@/lib/liveblocks'; import { NextRequest } from 'next/server'; function generateRandomHexColor() { const randomColor = Math.floor(Math.random() * 16777215).toString(16); return `#${randomColor.padStart(6, '0')}`; } export async function POST(request: NextRequest) { const { user, roomId, permissions } = await request.json(); const allowedPermission: ('room:read' | 'room:write')[] = []; const session = LIVEBLOCKS_CLIENT.prepareSession(user.$id, { userInfo: { name: user.name, color: generateRandomHexColor(), }, }); if (permissions.read) { allowedPermission.push('room:read'); } if (permissions.update) { allowedPermission.push('room:write'); } session.allow(roomId!, allowedPermission); const { body, status } = await session.authorize(); return new Response(body, { status }); }\ Here, we are creating a POST route to set up a liveblock session. In the request, we get user details, the active roomId, and permissions (from Permit.io, which we’ll discuss soon). We use the Liveblocks client’s prepareSession method to create the session with the user's unique ID and some extra information (used to display the live cursor and user avatars in the editor).
\ We then check if the user has read or update permissions and add the appropriate room permission, either room:read or room:write. Finally, we call the session.allow method with the roomId and permissions list, and then authorize it to generate a unique token.
\
For userInfo, we can only pass the name and color attributes because we have defined only those attributes in liveblocks.config.ts.
\ Alright, now let’s create a LiveblocksWrapper component and use the endpoint we made.
\
// components/editor/liveblocks-wrapper.tsx 'use client'; import { ClientSideSuspense, LiveblocksProvider, RoomProvider, } from '@liveblocks/react/suspense'; import Loader from '../loader'; import { PermissionType } from '@/app/actions'; import { useAuthStore } from '@/store/authStore'; interface LiveblocksWrapperProps { children: React.ReactNode; roomId: string; permissions: Record\ Here, we use the LiveblocksProvider from the Liveblocks package, which takes authEndpoint as a prop. We call the '/api/liveblock-session' endpoint with the user, roomId, and permissions. Next, we use RoomProvider to create a separate space for collaboration. It takes two props: id (roomId) and initialPresence (to show the active user's cursor in the editor).
\ ClientSideSuspense is used to display a fallback until the room is ready.
\ Great, we have the LiveblocksWrapper ready. Now, let’s use it on the document page. But before that, let’s add an authorization check on our document page by getting the permissions using permit.io server actions.
\
// app/document/[id]/page.tsx ... import { getResourcePermissions, PermissionType } from "@/app/actions"; import { Button } from "@/components/ui/button"; import { useAuthStore } from "@/store/authStore"; import { useRouter } from "next/navigation"; export default function DocumentPage({ params }: { params: { id: string } }) { const { user } = useAuthStore(); const router = useRouter(); const [permissions, setPermissions] = useStateYou do not have permission to view this document
\ Here, we are updating the document page by adding a new method called fetchPermissions. This method runs first to check if the current user has permission, specifically read permission, before fetching the document content. If the user doesn't have permission, it shows a message saying they can't view the document.
\ The final code for the document page will include a permission check, and the content will be wrapped with the LiveBlocks provider.
\
'use client'; import { getResourcePermissions, PermissionType } from '@/app/actions'; import { Document } from '@/app/dashboard/page'; import { DeleteDocument } from '@/components/delete-document'; import { CollaborativeEditor } from '@/components/editor/collaborative-editor'; import LiveblocksWrapper from '@/components/editor/liveblocks-wrapper'; import Loader from '@/components/loader'; import { ShareDocument } from '@/components/share-document'; import { Button } from '@/components/ui/button'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { useAuthStore } from '@/store/authStore'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; export default function DocumentPage({ params }: { params: { id: string } }) { const { user } = useAuthStore(); const router = useRouter(); const [permissions, setPermissions] = useStateYou do not have permission to view this document
Document not found
\
\
Component StartWhen the component starts, it first checks if a user is logged in. This makes sure only authorized users can see and use the document.
User Authentication CheckThe component asks for permissions for three main actions: read, update, and delete. Based on the response:
If the component has the right permissions, it calls an API to get the document by its ID.
After getting the document, the component shows the document’s title and includes a collaborative editor. Depending on the permissions:
The component shows extra options based on update and delete permissions:
These options let users share the document with others or delete it if necessary.
\
Testing DemoLet’s test what we have built. First, create three new users using the register function:
\
Owner: [email protected]
Editor: [email protected]
Viewer: [email protected]
\
Then, log in with the Owner’s credentials and create a document titled “Testing ReBAC on document with Permit.io”.
\
\ Go to the page of the created document and add some content.
\
\ Next, click the share button and share the article with the other two users: Editor and Owner, assigning them the editor and owner roles, respectively.
\
Now log in with three different users in three separate windows and watch it in action: live cursor, connected user avatars, and live editing.
\ See, the owner has full access to share, edit, and delete the document. The editor can only edit and share the document, while the reader can only view it, with no ability to share or delete it.
https://youtu.be/z_5AMOzKknQ?embedable=true
ConclusionIn this article, we’ve created a secure, real-time document editor using Next.js, Appwrite, Liveblocks, and Permit.io with ReBAC.
\ This setup allows for":
\
Real-time collaboration with presence awareness
Secure login and document storage
Detailed, relationship-based access control
\
Using ReBAC through Permit.io, we’ve built a flexible permission system that can handle complex access needs. This ensures that document access and editing rights are managed securely and efficiently, even as your app grows.
\ Keep in mind that making a production-ready collaborative editor involves more, like handling errors, making updates quickly, and resolving conflicts. However, this setup gives you a strong base for building advanced collaborative tools with good security.
ResourcesFind all the code files of this project in this GitHub Repo
Learn more about ReBAC by Permit.io
Want to learn more about implementing authorization? Got questions?
Reach out to Permit.io Slack community!
\ That’s all for now. Thank you for reading!
\n
All Rights Reserved. Copyright , Central Coast Communications, Inc.