Your resource for web content, online publishing
and the distribution of digital products.
«  
  »
S M T W T F S
 
 
 
 
 
 
1
 
2
 
3
 
4
 
5
 
6
 
7
 
8
 
9
 
10
 
11
 
12
 
13
 
14
 
15
 
16
 
17
 
18
 
19
 
20
 
21
 
22
 
23
 
24
 
25
 
26
 
27
 
28
 
29
 
30
 
31
 
 
 
 
 
 

Let's Build a Real-Time Collaborative Document Editor Using My Web App Framework of Choice

DATE POSTED:December 10, 2024
TL;DR

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.

\

Introduction

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).

Prerequisites

Before we start, make sure you have these tools installed on your computer

  • Node.js (v18 or later)
  • Npm (v10 or later)

\ You should also know the basics of React and TypeScript, as we’ll be using them in this tutorial.

Tools we will use

To build the real-time collaborative document editor, we will use the following tools. Let’s discuss their purpose and how they work together.

Appwrite

Appwrite 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.

Liveblocks

Liveblocks 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.io

Building 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

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 Environment

Let’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 Appwrite

First, 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:

\

  1. roomId (string, 36, required)

  2. storageData (string, 1073741824)

  3. title (string, 128)

  4. 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 Liveblocks

Go 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.io

To 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.

\

NEXT_PUBLIC_PERMIT_API_KEY=permit_key_xxxxxxxxxxxxx

\ 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 Authorization

We 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 | null; session: Models.Session | null; isLoading: boolean; error: string | null; login: (email: string, password: string) => Promise; register: (email: string, password: string, name: string) => Promise; logout: () => Promise; checkAuth: () => Promise; } export const useAuthStore = create()( persist( (set) => ({ user: null, isLoading: true, error: null, session: null, login: async (email, password) => { try { set({ isLoading: true, error: null }); await account.createEmailPasswordSession(email, password); const session = await account.getSession('current'); const user = await account.get(); set({ user, isLoading: false, session }); } catch (error) { set({ error: (error as Error).message, isLoading: false }); setTimeout(() => { set({ error: null }); }, ERROR_TIMEOUT); } }, register: async (email, password, name) => { try { set({ isLoading: true, error: null }); await account.create(ID.unique(), email, password, name); await account.createEmailPasswordSession(email, password); const session = await account.getSession('current'); const user = await account.get(); set({ user, isLoading: false, session }); } catch (error) { set({ error: (error as Error).message, isLoading: false }); setTimeout(() => { set({ error: null }); }, ERROR_TIMEOUT); } }, logout: async () => { try { set({ isLoading: true, error: null }); await account.deleteSession('current'); set({ user: null, isLoading: false, session: null }); } catch (error) { set({ error: (error as Error).message, isLoading: false }); setTimeout(() => { set({ error: null }); }, ERROR_TIMEOUT); } }, checkAuth: async () => { try { set({ isLoading: true }); const user = await account.get(); const session = await account.getSession('current'); set({ user, session }); } catch (error) { console.error("Couldn't get user", (error as Error).message); } finally { set({ isLoading: false }); } }, }), { name: 'collabdocs-session', // name of item in the storage (must be unique) partialize: (state) => ({ session: state.session, }), } ) );

\ 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 (
CollabDocs
); }

\ 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 (
); } if (!user && pathname.startsWith('/dashboard')) { redirect('/login'); } return children; }

\ 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 (
{children}
); }

\ 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 (

Collaborate on Documents in Real-Time

Create, edit, and share documents with ease. Powerful collaboration tools for teams of all sizes.

{user ? ( ) : (
)}

Key Features

Rich Text Editing

Create beautiful documents with our powerful rich text editor.

Real-Time Collaboration

Work together in real-time with your team members.

Easy Sharing

Share your documents with others quickly and securely.

Role-Based Access

Control access with Owner, Editor, and Viewer roles.

Start Collaborating Today

Join thousands of teams already using CollabDocs to streamline their document workflows.

© {new Date().getFullYear()} CollabDocs. All rights reserved.

); }

\ 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 ( Login Enter your email and password to login.
setEmail(e.target.value)} required />
setPassword(e.target.value)} required />
Sign up
{error &&

{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 ( Register Create a new account.
setName(e.target.value)} required />
setEmail(e.target.value)} required />
setPassword(e.target.value)} required />
Login
{error &&

{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 (
Welcome to the dashboard, {user?.name}!
); }

\ 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 = { read: false, create: false, delete: false, update: false, }; for await (const permission of permissions) { permissionMap[permission] = await PERMITIO_SDK.check( resourcePermission.user, permission, resourcePermission.resource_instance ); } return permissionMap; } catch (error) { if (error instanceof Error) { console.log(error.message); } else { console.log('An unknown error occurred'); } return { read: false, create: false, delete: false, update: false, }; } }

\

  • createResourceInstance: We use this when a user creates a document. It creates the document instance with a unique key in Permit.io.
  • assignResourceInstanceRoleToUser: This assigns the right role (owner, editor, or viewer) to the user for a specific resource instance.
  • getResourcePermissions: This is a straightforward but effective method we use to obtain the resource permissions.

\ 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 (
); }; export default Loader; // app/dashboard/page.tsx 'use client'; import { useEffect, useState } from 'react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { FileText, Plus, Search } from 'lucide-react'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { ID, Models, Query } from 'appwrite'; import { assignResourceInstanceRoleToUser, createResourceInstance, } from '../actions'; import { useAuthStore } from '@/store/authStore'; import Loader from '@/components/loader'; import { toast } from '@/hooks/use-toast'; export interface Document extends Models.Document { roomId: string; title: string; storageData: string; created_by: string; } export default function Dashboard() { const [documents, setDocuments] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [newDocTitle, setNewDocTitle] = useState(''); const [isDialogOpen, setIsDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isCreating, setIsCreating] = useState(false); const { user } = useAuthStore(); const filteredDocuments = documents.filter((doc) => doc.title.toLowerCase().includes(searchTerm.toLowerCase()) ); const fetchDocuments = async () => { setIsLoading(true); try { const response = await APPWRITE_CLIENT.databases.listDocuments( 'database_id', 'collection_id', [Query.contains('created_by', user?.$id ?? '')] ); setDocuments(response.documents); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to fetch documents. Please try again.', variant: 'destructive', }); } finally { setIsLoading(false); } }; const handleCreateDocument = async () => { setIsCreating(true); try { const documentId = ID.unique(); const response = await APPWRITE_CLIENT.databases.createDocument( 'database_id', 'collection_id', documentId, { title: newDocTitle.trim(), roomId: documentId, created_by: user?.$id ?? '', } ); const createdInstance = await createResourceInstance({ key: documentId, resource: 'document', }); if (!createdInstance) { throw new Error('Failed to create resource instance'); } const assignedRole = await assignResourceInstanceRoleToUser({ resource_instance: `document:${createdInstance.key}`, role: 'owner', user: user?.email ?? '', }); if (!assignedRole) { throw new Error('Failed to assign role'); } setDocuments((prev) => [...prev, response]); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to create document. Please try again.', variant: 'destructive', }); } finally { setIsCreating(false); setIsDialogOpen(false); } }; useEffect(() => { fetchDocuments(); }, []); if (isLoading) { return ; } return (

My Documents

setSearchTerm(e.target.value)} />
setIsDialogOpen(isCreating ? isCreating : value) } > Create New Document Enter a title for your new document.
setNewDocTitle(e.target.value)} className="col-span-3" />
{documents.length === 0 && (

You don't have any documents yet. Click on the{' '} New Document button to create one.

)}
{filteredDocuments.map((doc) => ( {doc.title} Last edited: {new Date(doc.$updatedAt).toDateString()} ))}
); }

\ 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 ( Share Document Enter the email address of the person you want to share this document with and select their role.
setEmail(e.target.value)} className="col-span-3" placeholder="[email protected]" />
); }

\ 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 ( Delete Document Are you sure you want to delete this document? This action cannot be undone. ); }

\ 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 Editor

Great 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 (
{users.map(({ connectionId, info }) => { return ( ); })} {currentUser && (
)}
); } export function Avatar({ name, color }: { name: string; color: string }) { return (
{name.slice(0, 2).toUpperCase()}
{name}
); }

\ 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 (
editor.chain().focus().toggleBold().run()} /> editor.chain().focus().toggleItalic().run()} /> editor.chain().focus().toggleStrike().run()} /> editor.chain().focus().toggleBlockquote().run()} /> editor.chain().focus().setHorizontalRule().run()} /> editor.chain().focus().toggleBulletList().run()} /> editor.chain().focus().toggleOrderedList().run()} />
); }

\ 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(); const [provider, setProvider] = useState(); useEffect(() => { const yDoc = new Y.Doc(); const yProvider = new LiveblocksYjsProvider(room, yDoc); setDoc(yDoc); setProvider(yProvider); return () => { yDoc?.destroy(); yProvider?.destroy(); }; }, [room]); if (!doc || !provider) { return null; } return ; } function TiptapEditor({ doc, provider, isReadOnly, }: { doc: Y.Doc; provider: LiveblocksYjsProvider; isReadOnly: boolean; }) { const userInfo = useSelf((me) => me.info); const editor = useEditor({ editorProps: { attributes: { class: 'flex-grow w-full h-full pt-4 focus:outline-none', }, editable: () => !isReadOnly, }, extensions: [ StarterKit.configure({ history: false, }), Collaboration.configure({ document: doc, }), CollaborationCursor.configure({ provider: provider, user: userInfo, }), ], }); return (
); }

\ 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; } export default function LiveblocksWrapper({ children, roomId, permissions, }: Readonly) { const { user } = useAuthStore(); return ( { const response = await fetch('/api/liveblock-session', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ user: user, roomId: roomId, room, permissions, }), }); return await response.json(); }} > }> {children} ); }

\ 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] = useState>(); ... const fetchPermissions = async () => { setIsLoading(true); const isPermitted = await getResourcePermissions({ permissions: ["read", "update", "delete"], resource_instance: `document:${params.id}`, user: user?.email ?? "", }); setPermissions(isPermitted); if (isPermitted.read) { fetchDocument(); } else { setIsLoading(false); } }; useEffect(() => { fetchPermissions(); }, []); ... if (!permissions?.read || !user) { return (

You 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] = useState>(); const [isLoading, setIsLoading] = useState(true); const [document, setDocument] = useState(null); const fetchDocument = async () => { try { const document = await APPWRITE_CLIENT.databases.getDocument( '66fad2d0001b08997cb9', '66fad37e0033c987cf4d', params.id ); setDocument(document); } catch (error) { console.error(error); } finally { setIsLoading(false); } }; const fetchPermissions = async () => { setIsLoading(true); const isPermitted = await getResourcePermissions({ permissions: ['read', 'update', 'delete'], resource_instance: `document:${params.id}`, user: user?.email ?? '', }); setPermissions(isPermitted); if (isPermitted.read) { fetchDocument(); } else { setIsLoading(false); } }; useEffect(() => { fetchPermissions(); }, []); if (isLoading) { return ; } if (!permissions?.read || !user) { return (

You do not have permission to view this document

); } if (!document) { return (

Document not found

); } return (

{document.title}

); }

\

\

Component Start

When 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 Check
  • If the user is logged in, the component gets the permissions needed for the document.
  • If no user is logged in, it sends them to the login page to stop unauthorized access.
Get Permissions

The component asks for permissions for three main actions: read, update, and delete. Based on the response:

  • If read permission is given, the component tries to get the document.
  • If read permission is not given, it shows a message: “You do not have permission to view this document.”
Get Document

If the component has the right permissions, it calls an API to get the document by its ID.

  • If the document is found, it shows the content.
  • If the document is not found or the ID is wrong, it shows a message: “Document not found.”
Show Document

After getting the document, the component shows the document’s title and includes a collaborative editor. Depending on the permissions:

  • If the user has update permissions, the editor can be used to edit.
  • If the user does not have update permissions, the editor is in read-only mode, letting users see but not change the content.
Show Options

The component shows extra options based on update and delete permissions:

  • Share Option: Available if the user has update permissions.
  • Delete Option: Available if the user has delete permissions.

These options let users share the document with others or delete it if necessary.

\

Testing Demo

Let’s test what we have built. First, create three new users using the register function:

\

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

Conclusion

In this article, we’ve created a secure, real-time document editor using Next.js, AppwriteLiveblocks, and Permit.io with ReBAC.

\ This setup allows for":

\

  1. Real-time collaboration with presence awareness

  2. Secure login and document storage

  3. 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.

Resources

Find 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