Setting Up a Realtime Editor using Tiptap, NextJS and Planetscale.

How to configure a realtime editor using TipTap, NextJS and Planetscale. Experience real-time collaboration with your team on Hellonext Changelog, a realtime changelog editor.

Setting Up a Realtime Editor using Tiptap, NextJS and Planetscale.
Karthik Kamalakannan

Karthik Kamalakannan

Founder and CEO

In our previous article, we spoke about setting up realtime editor using TipTap and Hocuspocus which is stateless. This means we don’t save the contents to database or load an data from database, in most cases we’ve to be doing this in order to persist the written content. We use realtime editors for collaborating with our team on a changelog, when writing help articles, or even writing a product requirements document on Hellonext.

In this article, I will walkthrough the steps to do the same. Luckily Tiptap with its HocusPocus plugin provides it out of the box and comes quite flexible too.

Provisioning the database

For this demo, I have created a MySQL database using Planetscale and connecting to it with Prisma ORM

Install Prisma and Setup

Run the following commands from the root of our project.

yarn add -D prisma
yarn add @prisma/client
npx prisma init --datasource-provider mysql

Once you run these commands the Prisma will get install and creates a schema file for us under prisma/schema.prisma

// prisma/schema.prisma
generator client {
	provider = "prisma-client-js"
}
 
datasource db {
	provider = "mysql"
	url = env("DATABASE_URL")
}

Now in your .env file (create if its not present) paste your database connection url that you obtain from your database provider DATABASE_URL

DATABASE_URL=mysql://ppppqm2lfof1929wnpqek:pscale_pw_V44YmuQBYrPnlqCnpDmWnjjgj6FJicRwOBb8RG8Vq6O@ap-south.connect.psdb.cloud/realtime-editor?sslaccept=strict

Now, lets create our schema, we need a table called documents in which we store the document id and content. This can be different for each application, just for our demo we’re creating the most basic ones. Update the following in your prisma schema file

generator client {
	provider = "prisma-client-js"
}
 
datasource db {
	provider = "mysql"
	url = env("DATABASE_URL")
}
model Document {
	id Int @id @default(autoincrement())
	tiptap_content String @db.Text
}

After you update, we need to publish the migration to our database, we use prisma db push for it

npx prisma db push && npx prisma generate

Once the changes are migrated, our db is good to go, now we need to create a Prisma client to make the calls. Create a new file under prisma/prismaClient.ts with the following contents

const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
module.exports = prisma

Now we’re ready to setup our web socket server to handle the storage and retrieval or document part.

Server Side Setup

We need to configure the following two functions in our hocuspocus web socket server.

  1. onStoreDocument
  2. onLoadDocument

These two functions primary does the storing and retrieval of data from our desired backend store.

Lets modify our code little, in order to keep things clear I will create a new file called documentHelpers.js in which I will write these to functions

const onStoreDocument = () => {}
const onLoadDocument = () => {}
 
module.exports = {
  onStoreDocument,
  onLoadDocument
}

And will import these two in our main file server/hocuspocus-server.js

const { Hocuspocus } = require('@hocuspocus/server')
const { onStoreDocument, onLoadDocument } = require('./documentHelpers')
 
const server = new Hocuspocus({
  port: 1234,
  onStoreDocument,
  onLoadDocument
})
 
server.listen()

With the basic structure ready, now lets add the functionality for these two functions.

onStoreDocument

The onStoreDocument function will be called every time the document is updated in the client side to store in the data store of our preference.

HocusPocus will give us the document content in binary format which we need to convert to base64 and store in our db, we use js-base64 library for it

yarn add js-base64

In order to make the understanding simpler, the list of things you need to do in onStoreDocument are

  1. Get the document and documentName
  2. Validate the documentName if required
  3. Encode the document to Y js Update format
  4. Converted the encoded binary data to base64
  5. Store the base64 in your database

Here is the sample code for the same with Prisma and MySQL DB in documentHelpers.js

const { fromUint8Array } = require('js-base64')
const Y = require('yjs')
const prisma = require('../prisma/prismaClient')
 
const onStoreDocument = async incomingData => {
  const { documentName, document } = incomingData
  if (!documentName) return Promise.resolve()
  const documentId = parseInt(documentName, 10)
  const state = Y.encodeStateAsUpdate(document)
  const dbDocument = fromUint8Array(state)
  const documentFromDB = await prisma.document.findUnique({
    where: {
      id: documentId
    }
  })
 
  if (!documentFromDB) {
    return prisma.document
      .create({
        data: {
          id: documentId,
          tiptap_content: dbDocument
        }
      })
      .then(() => {
        console.log('Document created')
      })
  }
  return prisma.document
    .update({
      where: {
        id: documentId
      },
      data: {
        tiptap_content: dbDocument
      }
    })
    .then(() => {
      console.log('Document updated')
    })
}
 
const onLoadDocument = () => {}
 
module.exports = {
  onStoreDocument,
  onLoadDocument
}

With this you’ll be able to store the document in your database for every update from all the connected clients. But in order to reduce frequent calling of onStoreDocument, we can use debounce to differ it. To do that just update our server/hocuspocus-server.js with the following configuration

const server = new Hocuspocus({
  // ....
  debounce: 5000
})

We’ve solved one piece of the puzzle, which is storing the data to our store. But the bigger part is to load from the store when a user is trying to load the content in the client side.

onLoadDocument

The onLoadDocument function handles the extraction and sending the document format to our clients when a new editor is connected to a specific document.

Similar to storing part, I will first summarise the step to do before the code,

  1. Get the document name from the incoming data
  2. Find the base64 content in DB and extract it
  3. Convert the base64 content back to unit8Array
  4. Apply an Y js update with incoming document
  5. Return the document

So lets code these steps in onLoadDocument, import toUint8Array function from js-base64 and add the below code to server/documentHelpers.js file

const onLoadDocument = async incomingData => {
  const { documentName, document } = incomingData
  if (!documentName) return Promise.resolve()
  const documentId = parseInt(documentName, 10)
  const documentFromDB = await prisma.document.findUnique({
    where: {
      id: documentId
    }
  })
  if (documentFromDB) {
    const dbDocument = toUint8Array(documentFromDB.tiptap_content || '')
    if (dbDocument) Y.applyUpdate(document, dbDocument)
    return document
  }
  return document
}

And we’re done with the server setup. We now need to do some changes to our client part to do the following

  1. Create dynamic documents based on the URL
  2. Load data from websocket server.

In order to create dyanmic path, I will move the /editor endpoint to /editor/[id] so that we can randomly create new realtime documents

mv src/pages/editor.tsx src/pages/editor/[id].tsx

In the new [id].tsx page get the id from the router query and send it to RichTextEditor as documentName

import dynamic from 'next/dynamic'
import { useRouter } from 'next/router'
import React from 'react'
 
const RichTextEditor = dynamic(
  () => import('../../components/RichTextEditor'),
  {
    ssr: false
  }
)
 
export default function EditorPage() {
  const router = useRouter()
  const { id } = router.query as { id: string }
  return <RichTextEditor documentName={id} />
}

And in the RichTextEditor lets receive the documentName from the props and initiate the provider with it.

import { HocuspocusProvider } from '@hocuspocus/provider'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React from 'react'
import * as Y from 'yjs'
 
interface IPropTypes {
  documentName: string
}
 
export default function RichTextEditor({ documentName }: IPropTypes) {
  const ydoc = new Y.Doc()
 
  const provider = new HocuspocusProvider({
    url: 'ws://127.0.0.1:1234',
    name: documentName,
    document: ydoc,
    forceSyncInterval: 200
  })
 
  const editor = useEditor({
    extensions: [
      StarterKit,
      Collaboration.configure({
        document: ydoc
      }),
 
      CollaborationCursor.configure({
        provider,
        user: { name: 'John Doe', color: '#ffcc00' }
      })
    ],
 
    editorProps: {
      attributes: {
        class:
          'prose dark:prose-invert prose-sm sm:prose-base lg:prose-lg xl:prose-2xl m-5 focus:outline-none'
      }
    }
  })
 
  return <EditorContent editor={editor} />
}

That’s all! Now you have a realtime editor which can store and retrieve data from your own datastore.

In the next article, I’ll talk more about the permission setup for the documents with onAuthenticate function of hocuspocus server.