Develop a Real-Time Collaborative Document Editor with Next.js, Appwrite, Liveblocks and Permit.io

Develop a Real-Time Collaborative Document Editor with Next.js, Appwrite, Liveblocks and Permit.io

A Step-by-Step Guide Using Next.js, Permit.io (ReBAC), and Open-Source Tools

·

38 min read

Featured on Hashnode

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.

Let’s Start🚀


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 “collaborative_docs_db” 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 node-appwrite@11.1.1

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.

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<Models.Preferences> | null;
  session: Models.Session | null;
  isLoading: boolean;
  error: string | null;
  login: (email: string, password: string) => Promise<void>;
  register: (email: string, password: string, name: string) => Promise<void>;
  logout: () => Promise<void>;
  checkAuth: () => Promise<void>;
}

export const useAuthStore = create<AuthState>()(
  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 (
    <header className="px-4 lg:px-6 h-14 flex items-center border-b shadow-md">
      <Link className="flex items-center justify-center" href="/">
        <FileText className="h-6 w-6 mr-2" />
        <span className="font-bold">CollabDocs</span>
      </Link>
      <nav className="ml-auto flex gap-4 sm:gap-6">
        {user ? (
          <div className="flex gap-4 items-center">
            <span>
              {user.name} ({user.email})
            </span>
            <Button variant="outline" onClick={logout}>
              Logout
            </Button>
          </div>
        ) : (
          <Link
            className="text-sm font-medium hover:underline underline-offset-4"
            href="/#features"
          >
            Features
          </Link>
        )}
      </nav>
    </header>
  );
}

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 (
      <section className="h-screen flex justify-center items-center">
        <LoaderIcon className="w-8 h-8 animate-spin" />
      </section>
    );
  }

  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 (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <AuthWrapper>
          <Navbar />
          <main>{children}</main>
        </AuthWrapper>
        <Toaster />
      </body>
    </html>
  );
}

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 (
    <div className="flex flex-col min-h-screen">
      <main className="flex-1">
        <section className="w-full py-12 md:py-24 lg:py-32 xl:py-48">
          <div className="px-4 md:px-6">
            <div className="flex flex-col items-center space-y-4 text-center">
              <div className="space-y-2">
                <h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-6xl/none">
                  Collaborate on Documents in Real-Time
                </h1>
                <p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400">
                  Create, edit, and share documents with ease. Powerful
                  collaboration tools for teams of all sizes.
                </p>
              </div>
              {user ? (
                <Button asChild>
                  <Link href="/dashboard">Dashboard</Link>
                </Button>
              ) : (
                <div className="space-x-4">
                  <Button asChild>
                    <Link href="/signup">Get Started</Link>
                  </Button>
                  <Button variant="outline" asChild>
                    <Link href="/login">Log In</Link>
                  </Button>
                </div>
              )}
            </div>
          </div>
        </section>
        <section
          className="w-full py-12 md:py-24 lg:py-32 bg-gray-100 dark:bg-gray-800"
          id="features"
        >
          <div className="px-4 md:px-6">
            <h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl text-center mb-8">
              Key Features
            </h2>
            <div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-3">
              <div className="flex flex-col items-center text-center">
                <FileText className="h-12 w-12 mb-4 text-primary" />
                <h3 className="text-lg font-bold">Rich Text Editing</h3>
                <p className="text-sm text-gray-500 dark:text-gray-400">
                  Create beautiful documents with our powerful rich text editor.
                </p>
              </div>
              <div className="flex flex-col items-center text-center">
                <Users className="h-12 w-12 mb-4 text-primary" />
                <h3 className="text-lg font-bold">Real-Time Collaboration</h3>
                <p className="text-sm text-gray-500 dark:text-gray-400">
                  Work together in real-time with your team members.
                </p>
              </div>
              <div className="flex flex-col items-center text-center">
                <Share2 className="h-12 w-12 mb-4 text-primary" />
                <h3 className="text-lg font-bold">Easy Sharing</h3>
                <p className="text-sm text-gray-500 dark:text-gray-400">
                  Share your documents with others quickly and securely.
                </p>
              </div>
              <div className="flex flex-col items-center text-center">
                <ShieldCheck className="h-12 w-12 mb-4 text-primary" />
                <h3 className="text-lg font-bold">Role-Based Access</h3>
                <p className="text-sm text-gray-500 dark:text-gray-400">
                  Control access with Owner, Editor, and Viewer roles.
                </p>
              </div>
            </div>
          </div>
        </section>
        <section className="w-full py-12 md:py-24 lg:py-32">
          <div className="px-4 md:px-6">
            <div className="flex flex-col items-center justify-center space-y-4 text-center">
              <div className="space-y-2">
                <h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
                  Start Collaborating Today
                </h2>
                <p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400">
                  Join thousands of teams already using CollabDocs to streamline
                  their document workflows.
                </p>
              </div>
              <Button asChild size="lg">
                <Link href={user ? '/dashboard' : '/signup'}>
                  Sign Up for Free
                </Link>
              </Button>
            </div>
          </div>
        </section>
      </main>
      <footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t">
        <p className="text-xs text-gray-500 dark:text-gray-400">
          © {new Date().getFullYear()} CollabDocs. All rights reserved.
        </p>
        <nav className="sm:ml-auto flex gap-4 sm:gap-6">
          <Link className="text-xs hover:underline underline-offset-4" href="#">
            Terms of Service
          </Link>
          <Link className="text-xs hover:underline underline-offset-4" href="#">
            Privacy
          </Link>
        </nav>
      </footer>
    </div>
  );
}

Then update the app/page.tsx with the code below.

import LandingPage from '@/components/landing-page';

export default function Home() {
  return <LandingPage />;
}

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 (
    <Card className="w-[350px]">
      <CardHeader>
        <CardTitle>Login</CardTitle>
        <CardDescription>
          Enter your email and password to login.
        </CardDescription>
      </CardHeader>
      <form onSubmit={handleSubmit}>
        <CardContent>
          <div className="grid w-full items-center gap-4">
            <div className="flex flex-col space-y-1.5">
              <Label htmlFor="email">Email</Label>
              <Input
                id="email"
                type="email"
                placeholder="Enter your email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                required
              />
            </div>
            <div className="flex flex-col space-y-1.5">
              <Label htmlFor="password">Password</Label>
              <Input
                id="password"
                type="password"
                placeholder="Enter your password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                required
              />
            </div>
          </div>
        </CardContent>
        <CardFooter className="flex justify-between">
          <Button type="submit">Login</Button>
          <Link href={nextPath ? `/signup?next=${nextPath}` : '/signup'}>
            Sign up
          </Link>
        </CardFooter>
      </form>
      {error && <p className="text-red-500 text-center mt-2 py-2">{error}</p>}
    </Card>
  );
}

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 (
    <Card className="w-[350px]">
      <CardHeader>
        <CardTitle>Register</CardTitle>
        <CardDescription>Create a new account.</CardDescription>
      </CardHeader>
      <form onSubmit={handleSubmit}>
        <CardContent>
          <div className="grid w-full items-center gap-4">
            <div className="flex flex-col space-y-1.5">
              <Label htmlFor="name">Name</Label>
              <Input
                id="name"
                placeholder="Enter your name"
                value={name}
                onChange={(e) => setName(e.target.value)}
                required
              />
            </div>
            <div className="flex flex-col space-y-1.5">
              <Label htmlFor="email">Email</Label>
              <Input
                id="email"
                type="email"
                placeholder="Enter your email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                required
              />
            </div>
            <div className="flex flex-col space-y-1.5">
              <Label htmlFor="password">Password</Label>
              <Input
                id="password"
                type="password"
                placeholder="Enter your password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                required
              />
            </div>
          </div>
        </CardContent>
        <CardFooter className="flex justify-between">
          <Button type="submit">Register</Button>
          <Link href={nextPath ? `/login?next=${nextPath}` : '/login'}>
            Login
          </Link>
        </CardFooter>
      </form>
      {error && <p className="text-red-500 text-center mt-2 py-2">{error}</p>}
    </Card>
  );
}

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 (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <Login />
    </div>
  );
}

// app/signup/page.tsx

import Register from '@/components/register';

export default function SignUpPage() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <Register />;
    </div>
  );
}

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 (
    <div className="flex flex-col items-center justify-center min-h-screen">
      Welcome to the dashboard, {user?.name}!
    </div>
  );
}

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<PermissionType, boolean> = {
      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 (
    <section className="h-screen flex justify-center items-center">
      <LoaderIcon className="w-8 h-8 animate-spin" />
    </section>
  );
};

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<Document[]>([]);
  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<Document>(
        '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<Document>(
        '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 <Loader />;
  }

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">My Documents</h1>
      <div className="flex justify-between items-center mb-6">
        <div className="relative w-64">
          <Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
          <Input
            type="text"
            placeholder="Search documents"
            className="pl-8"
            value={searchTerm}
            onChange={(e) => setSearchTerm(e.target.value)}
          />
        </div>
        <Dialog
          modal
          open={isDialogOpen}
          onOpenChange={(value) =>
            setIsDialogOpen(isCreating ? isCreating : value)
          }
        >
          <DialogTrigger asChild>
            <Button>
              <Plus className="mr-2 h-4 w-4" /> New Document
            </Button>
          </DialogTrigger>
          <DialogContent>
            <DialogHeader>
              <DialogTitle>Create New Document</DialogTitle>
              <DialogDescription>
                Enter a title for your new document.
              </DialogDescription>
            </DialogHeader>
            <div className="grid gap-4 py-4">
              <div className="grid grid-cols-4 items-center gap-4">
                <Label htmlFor="name" className="text-right">
                  Title
                </Label>
                <Input
                  id="name"
                  value={newDocTitle}
                  onChange={(e) => setNewDocTitle(e.target.value)}
                  className="col-span-3"
                />
              </div>
            </div>
            <DialogFooter>
              <Button onClick={handleCreateDocument}>Create</Button>
            </DialogFooter>
          </DialogContent>
        </Dialog>
      </div>
      {documents.length === 0 && (
        <p className="text-center text-gray-500">
          You don&apos;t have any documents yet. Click on the{' '}
          <strong> New Document </strong> button to create one.
        </p>
      )}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {filteredDocuments.map((doc) => (
          <Card key={doc.$id}>
            <CardHeader>
              <CardTitle>{doc.title}</CardTitle>
              <CardDescription>
                Last edited: {new Date(doc.$updatedAt).toDateString()}
              </CardDescription>
            </CardHeader>
            <CardContent>
              <FileText className="h-16 w-16 text-gray-400" />
            </CardContent>
            <CardFooter>
              <Button asChild>
                <Link href={`/document/${doc.$id}`}>Open</Link>
              </Button>
            </CardFooter>
          </Card>
        ))}
      </div>
    </div>
  );
}

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 (
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
      <DialogTrigger asChild>
        <Button variant="outline">
          <Share2 className="mr-2 h-4 w-4" />
          Share
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>Share Document</DialogTitle>
          <DialogDescription>
            Enter the email address of the person you want to share this
            document with and select their role.
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="email" className="text-right">
              Email
            </Label>
            <Input
              id="email"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              className="col-span-3"
              placeholder="user@example.com"
            />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="role" className="text-right">
              Role
            </Label>
            <Select value={role} onValueChange={setRole}>
              <SelectTrigger className="col-span-3">
                <SelectValue placeholder="Select a role" />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="viewer">Viewer</SelectItem>
                <SelectItem value="editor">Editor</SelectItem>
                <SelectItem value="owner">Owner</SelectItem>
              </SelectContent>
            </Select>
          </div>
        </div>
        <DialogFooter>
          <Button onClick={handleShare} disabled={isSharing}>
            {isSharing ? 'Sharing...' : 'Share'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

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 (
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
      <DialogTrigger asChild>
        <Button variant="destructive">
          <Trash2 className="mr-2 h-4 w-4" />
          Delete
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>Delete Document</DialogTitle>
          <DialogDescription>
            Are you sure you want to delete this document? This action cannot be
            undone.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <Button variant="outline" onClick={() => setIsOpen(false)}>
            Cancel
          </Button>
          <Button
            variant="destructive"
            onClick={handleDelete}
            disabled={isDeleting}
          >
            {isDeleting ? 'Deleting...' : 'Delete'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

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 (
    <div className="flex py-[0.75rem]">
      {users.map(({ connectionId, info }) => {
        return (
          <Avatar key={connectionId} name={info.name} color={info.color} />
        );
      })}

      {currentUser && (
        <div className="relative ml-8 first:ml-0">
          <Avatar color={currentUser.info.color} name={currentUser.info.name} />
        </div>
      )}
    </div>
  );
}

export function Avatar({ name, color }: { name: string; color: string }) {
  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger>
          <div className="flex place-content-center relative border-4 border-white rounded-full w-[42px] h-[42px] bg-[#9ca3af] ml-[-0.75rem]">
            <div
              className="w-full h-full rounded-full flex items-center justify-center text-white"
              style={{ background: color }}
            >
              {name.slice(0, 2).toUpperCase()}
            </div>
          </div>
        </TooltipTrigger>
        <TooltipContent>{name}</TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}

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 = () => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 32 32"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
  >
    <path
      d="M18.25 25H9V7H17.5C18.5022 7.00006 19.4834 7.28695 20.3277 7.82679C21.172 8.36662 21.8442 9.13684 22.2649 10.0465C22.6855 10.9561 22.837 11.9671 22.7015 12.96C22.5659 13.953 22.149 14.8864 21.5 15.65C22.3477 16.328 22.9645 17.252 23.2653 18.295C23.5662 19.3379 23.5364 20.4485 23.18 21.4738C22.8236 22.4991 22.1581 23.3887 21.2753 24.0202C20.3924 24.6517 19.3355 24.994 18.25 25ZM12 22H18.23C18.5255 22 18.8181 21.9418 19.091 21.8287C19.364 21.7157 19.6121 21.5499 19.821 21.341C20.0299 21.1321 20.1957 20.884 20.3087 20.611C20.4218 20.3381 20.48 20.0455 20.48 19.75C20.48 19.4545 20.4218 19.1619 20.3087 18.889C20.1957 18.616 20.0299 18.3679 19.821 18.159C19.6121 17.9501 19.364 17.7843 19.091 17.6713C18.8181 17.5582 18.5255 17.5 18.23 17.5H12V22ZM12 14.5H17.5C17.7955 14.5 18.0881 14.4418 18.361 14.3287C18.634 14.2157 18.8821 14.0499 19.091 13.841C19.2999 13.6321 19.4657 13.384 19.5787 13.111C19.6918 12.8381 19.75 12.5455 19.75 12.25C19.75 11.9545 19.6918 11.6619 19.5787 11.389C19.4657 11.116 19.2999 10.8679 19.091 10.659C18.8821 10.4501 18.634 10.2843 18.361 10.1713C18.0881 10.0582 17.7955 10 17.5 10H12V14.5Z"
      fill="currentColor"
    />
  </svg>
);

export const ItalicIcon = () => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 32 32"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
  >
    <path
      d="M25 9V7H12V9H17.14L12.77 23H7V25H20V23H14.86L19.23 9H25Z"
      fill="currentColor"
    />
  </svg>
);

export const StrikethroughIcon = () => (
  <svg
    width="16"
    height="16"
    viewBox="0 0 24 24"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
  >
    <path
      d="M17.1538 14C17.3846 14.5161 17.5 15.0893 17.5 15.7196C17.5 17.0625 16.9762 18.1116 15.9286 18.867C14.8809 19.6223 13.4335 20 11.5862 20C9.94674 20 8.32335 19.6185 6.71592 18.8555V16.6009C8.23538 17.4783 9.7908 17.917 11.3822 17.917C13.9333 17.917 15.2128 17.1846 15.2208 15.7196C15.2208 15.0939 15.0049 14.5598 14.5731 14.1173C14.5339 14.0772 14.4939 14.0381 14.4531 14H3V12H21V14H17.1538ZM13.076 11H7.62908C7.4566 10.8433 7.29616 10.6692 7.14776 10.4778C6.71592 9.92084 6.5 9.24559 6.5 8.45207C6.5 7.21602 6.96583 6.165 7.89749 5.299C8.82916 4.43299 10.2706 4 12.2219 4C13.6934 4 15.1009 4.32808 16.4444 4.98426V7.13591C15.2448 6.44921 13.9293 6.10587 12.4978 6.10587C10.0187 6.10587 8.77917 6.88793 8.77917 8.45207C8.77917 8.87172 8.99709 9.23796 9.43293 9.55079C9.86878 9.86362 10.4066 10.1135 11.0463 10.3004C11.6665 10.4816 12.3431 10.7148 13.076 11H13.076Z"
      fill="currentColor"
    ></path>
  </svg>
);

export const BlockQuoteIcon = () => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width="28"
    height="28"
    viewBox="0 0 28 28"
    fill="none"
  >
    <path
      d="M11.5 13.375H7.81875C7.93491 12.6015 8.21112 11.8607 8.62974 11.1999C9.04837 10.5391 9.6002 9.97295 10.25 9.5375L11.3688 8.7875L10.6812 7.75L9.5625 8.5C8.6208 9.12755 7.84857 9.97785 7.31433 10.9755C6.7801 11.9731 6.50038 13.0871 6.5 14.2188V18.375C6.5 18.7065 6.6317 19.0245 6.86612 19.2589C7.10054 19.4933 7.41848 19.625 7.75 19.625H11.5C11.8315 19.625 12.1495 19.4933 12.3839 19.2589C12.6183 19.0245 12.75 18.7065 12.75 18.375V14.625C12.75 14.2935 12.6183 13.9755 12.3839 13.7411C12.1495 13.5067 11.8315 13.375 11.5 13.375ZM20.25 13.375H16.5688C16.6849 12.6015 16.9611 11.8607 17.3797 11.1999C17.7984 10.5391 18.3502 9.97295 19 9.5375L20.1188 8.7875L19.4375 7.75L18.3125 8.5C17.3708 9.12755 16.5986 9.97785 16.0643 10.9755C15.5301 11.9731 15.2504 13.0871 15.25 14.2188V18.375C15.25 18.7065 15.3817 19.0245 15.6161 19.2589C15.8505 19.4933 16.1685 19.625 16.5 19.625H20.25C20.5815 19.625 20.8995 19.4933 21.1339 19.2589C21.3683 19.0245 21.5 18.7065 21.5 18.375V14.625C21.5 14.2935 21.3683 13.9755 21.1339 13.7411C20.8995 13.5067 20.5815 13.375 20.25 13.375Z"
      fill="currentColor"
    />
  </svg>
);

export const HorizontalLineIcon = () => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width="28"
    height="28"
    viewBox="0 0 28 28"
    fill="none"
  >
    <rect x="6.5" y="13.375" width="15" height="1.25" fill="currentColor" />
  </svg>
);

export const OrderedListIcon = () => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width="28"
    height="28"
    viewBox="0 0 28 28"
    fill="none"
  >
    <path
      d="M14 17.75H22.75V19H14V17.75ZM14 9H22.75V10.25H14V9ZM9 11.5V6.5H7.75V7.125H6.5V8.375H7.75V11.5H6.5V12.75H10.25V11.5H9ZM10.25 21.5H6.5V19C6.5 18.6685 6.6317 18.3505 6.86612 18.1161C7.10054 17.8817 7.41848 17.75 7.75 17.75H9V16.5H6.5V15.25H9C9.33152 15.25 9.64946 15.3817 9.88388 15.6161C10.1183 15.8505 10.25 16.1685 10.25 16.5V17.75C10.25 18.0815 10.1183 18.3995 9.88388 18.6339C9.64946 18.8683 9.33152 19 9 19H7.75V20.25H10.25V21.5Z"
      fill="currentColor"
    />
  </svg>
);

export const BulletListIcon = () => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width="28"
    height="28"
    viewBox="0 0 28 28"
    fill="none"
  >
    <path
      d="M8.375 11.5C9.41053 11.5 10.25 10.6605 10.25 9.625C10.25 8.58947 9.41053 7.75 8.375 7.75C7.33947 7.75 6.5 8.58947 6.5 9.625C6.5 10.6605 7.33947 11.5 8.375 11.5Z"
      fill="currentColor"
    />
    <path
      d="M8.375 20.25C9.41053 20.25 10.25 19.4105 10.25 18.375C10.25 17.3395 9.41053 16.5 8.375 16.5C7.33947 16.5 6.5 17.3395 6.5 18.375C6.5 19.4105 7.33947 20.25 8.375 20.25Z"
      fill="currentColor"
    />
    <path
      d="M14 17.75H22.75V19H14V17.75ZM14 9H22.75V10.25H14V9Z"
      fill="currentColor"
    />
  </svg>
);
// 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) => (
  <button
    className={cn(
      'flex items-center justify-center w-8 h-8 border border-gray-200 rounded',
      {
        'bg-gray-100': isActive,
      }
    )}
    onClick={onClick}
    data-active={isActive ? 'is-active' : undefined}
    aria-label={ariaLabel}
  >
    <Icon />
  </button>
);

export function Toolbar({ editor }: Props) {
  if (!editor) {
    return null;
  }

  return (
    <div className="flex gap-4">
      <ToolbarButton
        editor={editor}
        isActive={editor.isActive('bold')}
        ariaLabel="bold"
        Icon={BoldIcon}
        onClick={() => editor.chain().focus().toggleBold().run()}
      />
      <ToolbarButton
        editor={editor}
        isActive={editor.isActive('italic')}
        ariaLabel="italic"
        Icon={ItalicIcon}
        onClick={() => editor.chain().focus().toggleItalic().run()}
      />
      <ToolbarButton
        editor={editor}
        isActive={editor.isActive('strike')}
        ariaLabel="strikethrough"
        Icon={StrikethroughIcon}
        onClick={() => editor.chain().focus().toggleStrike().run()}
      />
      <ToolbarButton
        editor={editor}
        isActive={editor.isActive('blockquote')}
        ariaLabel="blockquote"
        Icon={BlockQuoteIcon}
        onClick={() => editor.chain().focus().toggleBlockquote().run()}
      />
      <ToolbarButton
        editor={editor}
        isActive={false}
        ariaLabel="horizontal-line"
        Icon={HorizontalLineIcon}
        onClick={() => editor.chain().focus().setHorizontalRule().run()}
      />
      <ToolbarButton
        editor={editor}
        isActive={editor.isActive('bulletList')}
        ariaLabel="bullet-list"
        Icon={BulletListIcon}
        onClick={() => editor.chain().focus().toggleBulletList().run()}
      />
      <ToolbarButton
        editor={editor}
        isActive={editor.isActive('orderedList')}
        ariaLabel="number-list"
        Icon={OrderedListIcon}
        onClick={() => editor.chain().focus().toggleOrderedList().run()}
      />
    </div>
  );
}

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<Y.Doc>();
  const [provider, setProvider] = useState<LiveblocksYjsProvider>();

  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 <TiptapEditor isReadOnly={isReadOnly} doc={doc} provider={provider} />;
}

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 (
    <div className="flex flex-col bg-white w-full h-full">
      <div className="flex justify-between items-center">
        <Toolbar editor={editor} />
        <Avatars />
      </div>
      <EditorContent
        readOnly={isReadOnly}
        editor={editor}
        className="relative h-full"
      />
    </div>
  );
}

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<PermissionType, boolean>;
}

export default function LiveblocksWrapper({
  children,
  roomId,
  permissions,
}: Readonly<LiveblocksWrapperProps>) {
  const { user } = useAuthStore();

  return (
    <LiveblocksProvider
      authEndpoint={async (room) => {
        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();
      }}
    >
      <RoomProvider
        id={roomId}
        initialPresence={{
          cursor: null,
        }}
      >
        <ClientSideSuspense fallback={<Loader />}>
          {children}
        </ClientSideSuspense>
      </RoomProvider>
    </LiveblocksProvider>
  );
}

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 permi.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<Record<PermissionType, boolean>>();

  ...
  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 (
      <section className="h-screen flex justify-center items-center flex-col gap-4">
        <p>You do not have permission to view this document</p>

        <Button
          onClick={() =>
            router.push(
              user ? "/dashboard" : `/login?next=/document/${params.id}`,
            )
          }
        >
          {user ? "Dashboard" : "Login"}
        </Button>
      </section>
    );
  }

  ....
}

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<Record<PermissionType, boolean>>();
  const [isLoading, setIsLoading] = useState(true);
  const [document, setDocument] = useState<Document | null>(null);

  const fetchDocument = async () => {
    try {
      const document = await APPWRITE_CLIENT.databases.getDocument<Document>(
        '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 <Loader />;
  }

  if (!permissions?.read || !user) {
    return (
      <section className="h-screen flex justify-center items-center flex-col gap-4">
        <p>You do not have permission to view this document</p>
        <Button
          onClick={() =>
            router.push(
              user ? '/dashboard' : `/login?next=/document/${params.id}`
            )
          }
        >
          {user ? 'Dashboard' : 'Login'}
        </Button>
      </section>
    );
  }

  if (!document) {
    return (
      <section className="h-screen flex justify-center items-center">
        <p>Document not found</p>
      </section>
    );
  }

  return (
    <LiveblocksWrapper permissions={permissions} roomId={document.roomId}>
      <div className="container mx-auto px-4 py-8">
        <div className="flex justify-between items-center mb-8">
          <h1 className="text-3xl font-bold">{document.title}</h1>
          <div className="flex gap-4">
            <ShareDocument
              permission={permissions.update}
              documentId={params.id}
            />
            <DeleteDocument
              documentId={params.id}
              permission={permissions.delete}
            />
          </div>
        </div>

        <CollaborativeEditor isReadOnly={!permissions.update} />
      </div>
    </LiveblocksWrapper>
  );
}

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

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

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

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

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

6. 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:

  • Owner: owner@gmail.com

  • Editor: editor@gmail.com

  • Viewer: viewer@gmail.com

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.

Conclusion

In 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

  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!


If You ❤️ My Content! Connect Me on Twitter

Check SaaS Tools I Use 👉🏼Access here!

I am open to collaborating on Blog Articles and Guest Posts🫱🏼‍🫲🏼 📅Contact Here

Did you find this article valuable?

Support Astrodevil by becoming a sponsor. Any amount is appreciated!