DEV Community

Cover image for Build a Customer.io-Like Collaborative Email Composer Using Next.js, Velt, and Tiptap
Arindam Majumder
Arindam Majumder Subscriber

Posted on • Edited on

Build a Customer.io-Like Collaborative Email Composer Using Next.js, Velt, and Tiptap

Introduction

Emails are central to the success of modern SaaS products. SaaS companies use emails for customer communication, marketing campaigns, and team collaboration.

However, traditional email editors are single-user and lack real-time collaboration. This often leads to miscommunication and delayed actions.

Real-time collaboration is a great addition. With real-time collaboration features such as comments, live presence, and notifications, teams can work asynchronously and still be in sync with each other.

According to industry data from Randstad Digital, teams that use collaborative tools report a 30% faster campaign turnaround and significantly fewer errors compared to those relying on traditional workflows.

In this article, you’ll learn how to build a collaborative email composer using:

  • Next.js for the frontend framework.
  • Tiptap for the rich-text editor foundation.
  • Velt to take advantage of ready-made collaborative features.

What is Velt, and why should you use it?

Velt is a developer platform that provides an SDK with ready-made components, allowing you to integrate powerful real-time and collaborative features into your application with minimal code.

Building real-time features from scratch is complex. It requires managing WebSocket connections, frontend/backend state synchronization, and data persistence. This can be a significant engineering effort that distracts from a product's core functionality.

Velt is designed to handle this complexity, letting your team focus on building the features that make your app unique.

Setting Up the Project

In this section, you’re going to set up your Next.js project.

Use the following command to scaffold a new project using the default configuration:

npx create-next-app@latest --yes
Enter fullscreen mode Exit fullscreen mode

The --yes flag skips the prompts using saved preferences or defaults. The default setup enables TypeScript, TailwindCSS, App Router, Turbopack, with import alias @/*. This default configuration is perfect for our tutorial.

Installing dependencies

For this tutorial, you’ll need the following dependencies:

  • Zustand: for user state management
  • Velt SDK: for real-time collaboration features
  • Tiptap: for the rich-text editor
  • Shadcn: for UI components

Use the following command to install these packages:

npm install @veltdev/react @veltdev/tiptap-velt-comments @tiptap/react @tiptap/starter-kit
Enter fullscreen mode Exit fullscreen mode
  • @veltdev/react: Velt’s main package
  • @veltdev/tiptap-comments: Velt package specific for adding comments in Tiptap editors
  • @tiptap/react: Tiptap’s React package
  • @tiptap/starter-kit: Starter kit for Tiptap editors

Use the following command to initialize Shadcn in your app:

npx shadcn@latest init
Enter fullscreen mode Exit fullscreen mode

You’ll add the necessary UI components as the tutorial progresses.

Getting your Velt API key

Go to Velt’s console and obtain your API key.

Image1

Add your Velt API key to your .env file.

NEXT_PUBLIC_VELT_ID=your-api-key
Enter fullscreen mode Exit fullscreen mode

Note: Velt’s free API key is only compatible with development environments. Upgrade to use in production.

With that, you’re done with setting up your Next.js project. In the next section, you’ll build the email composer user interface.

Building the Email Composer UI with Tiptap

Now that our project is set up, we can start building the email composer's UI. We will use Tiptap as the foundation for the editor. Tiptap is an open-source framework for building customized, headless rich-text editors, which gives us the flexibility we need.

This section will walk through the key code snippets that bring the composer to life, starting with the core EmailComposer.tsx component. For the complete, unabridged code, please refer to the accompanying GitHub repository.

List of the main components

  • EmailComposer
  • EditorArea
  • TiptapEditor
  • BubbleMenu
  • EditorContent
  • Header
  • Toolbar

The main composer: EmailComposer.tsx

The EmailComposer component is the core of the app. It brings together all the other UI components. It manages the overall state of the email, including the subject, recipient, and content.

export const EmailComposer: React.FC = () => {
  const [emailData, setEmailData] = useState<EmailData>({
    subject: "",
    from: "hello@yourcompany.com",
    to: "",
    content: "",
    template: "blank",
  });
  const [isSidebarOpen, setIsSidebarOpen] = useState(true);

  const updateEmailData = (updates: Partial<EmailData>) => {
    setEmailData((prev) => ({ ...prev, ...updates }));
  };

  useSetDocument("sheet-1", { documentName: "customer.io" });

  useEffect(() => {
    const mediaQuery = window.matchMedia("(min-width: 1024px)"); // lg breakpoint

    const handleResize = () => {
      setIsSidebarOpen(mediaQuery.matches); // true for large screens, false otherwise
    };

    handleResize(); // set initial state
    mediaQuery.addEventListener("change", handleResize);

    return () => {
      mediaQuery.removeEventListener("change", handleResize);
    };
  }, []);

  return (
    <div className="flex h-screen bg-gray-50 dark:bg-[#25293c]">
      {/* Sidebar */}
      <div
        className={cn(
          "max-h-screen overflow-y-scroll transition-all duration-300 ease-in-out bg-white border-r border-gray-200 no-scrollbar dark:bg-[#25293c]",
          isSidebarOpen ? "w-80" : "w-0 overflow-hidden"
        )}
      >
        <Sidebar
          isOpen={isSidebarOpen}
          onClose={() => setIsSidebarOpen(false)}
          onInsertBlock={(blockType) => {
            console.log("Insert block:", blockType);
          }}
        />
      </div>

      {/* Main Content */}
      <div className="flex-1 flex flex-col bg-white dark:bg-[#25293c]">
        {/* Header */}
        <Header
          emailData={emailData}
          updateEmailData={updateEmailData}
          isSidebarOpen={isSidebarOpen}
          setIsSidebarOpen={setIsSidebarOpen}
        />
        {/* Toolbar */}
        <Toolbar
          onFormatting={(format) => {
            console.log("Apply formatting:", format);
          }}
        />
        <EditorArea
          emailData={emailData}
          updateEmailData={updateEmailData}
        />
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The core editing area: EditorArea.tsx and TiptapEditor.tsx

EditorArea is the main layout for composing emails. It receives emailData and an updateEmailData function as props, allowing it to display and edit the recipient’s email address. The component organizes the UI into sections: an input for the "To" field, a rich text editor, and static informational cards showing journey and metrics.

The TiptapEditor

EditorArea.tsx:

// components/funtions/EditorArea.tsx

export const EditorArea: React.FC<EditorAreaProps> = ({
  emailData,
  updateEmailData,
}) => {
  return (
    <div className="flex-1 bg-white dark:bg-[#25293c]">
      <div className="p-4 border-b border-gray-200 dark:border-white/40">
        {/* "To" field input */}
      </div>

      <div className="grid grid-cols-1 lg:grid-cols-3 lg:gap-5 p-6 space-y-6">
        <div className="w-full lg:max-w-[70vw] col-span-2 mx-auto">
          <TiptapEditor />
        </div>
        <main className="space-y-6">
          {/* Journey Section */}
          <InfoCard title="Journey">{/* ... */}</InfoCard>

          {/* Metrics Section */}
          <InfoCard title="Metrics">{/* ... */}</InfoCard>
        </main>
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

TiptapEditor is the main area for writing and formatting the email content. The component is configured with the necessary extension to support rich text.

// components/funtions/TiptapEditor.tsx

const TiptapEditor = () => {
  // Initialize Tiptap editor
  const editor = useEditor({
    extensions: [
      StarterKit,
    ],
    content: `...`, // Initial editor content
    autofocus: true,
    immediatelyRender: false,
  });

  // ... (code for handling comments)

  return (
    <div className="w-full mx-auto">
      {/* Bubble Menu with comment button */}
      {editor && (
        <BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
          <div className="bubble-menu">
            <Button
              variant="outline"
              onClick={onClickComments}
              className="dark:text-white/70 dark:bg-[#2f3349] dark:border dark:border-white/30 hover:dark:bg-[#444a68] hover:dark:text-white"
            >
              Add Comment
            </Button>
          </div>
        </BubbleMenu>
      )}

      {/* Editor Content */}
      <EditorContent
        editor={editor}
        className="w-full min-h-56 p-4 border-2 border-dashed border-white/30 rounded-lg focus-within:border-blue-500 focus-within:bg-blue-50/50 dark:border-[1px] dark:focus-within:bg-[#2f3349] dark:focus-within:border-white text-white"
      />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The TiptapEditor renders two components:

  • BubbleMenu: displays a contextual menu near a selected text. The component allows users to add comments to their text selection.
  • EditorContent : the central building block of Tiptap, where users can compose and edit their emails.

Together, EditorArea manages the overall structure and state of the emailing editing area, while TiptapEditor handles the actual email content editing.

The Header and Toolbar

The Header component contains the primary email fields. The Toolbar component provides users with text formatting options.

Here’s the relevant snippet from the Header component:

// components/funtions/Header.tsx

export const Header: React.FC<HeaderProps> = ({
  emailData,
  updateEmailData,
  isSidebarOpen,
  setIsSidebarOpen,
}) => {
  return (
    <div className="p-4 border-b border-gray-200 dark:border-white/40">
      {/* ... */}
      <div className="flex items-center">
        <label className="w-20 text-sm text-gray-600 dark:text-white/70">From:</label>
        <Input
          value={emailData.from}
          onChange={(e) => updateEmailData({ from: e.target.value })}
          className="flex-1 bg-transparent border-none focus:ring-0 dark:text-white/70"
        />
      </div>
      {/* ... */}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Let’s take a closer look at the Toolbar component.

The Toolbar component is a crucial part of the UI. It provides users with a set of tools for formatting their email content. It is designed to be a simple, stateless component that triggers events when a user interacts with the formatting buttons.

The Toolbar component is stateless, meaning it doesn't manage any internal state. Its primary responsibility is to render the formatting buttons and to notify its parent component (EmailComposer) when a button is clicked. This is achieved through the onFormatting prop, which is a function passed down from EmailComposer.

export const Toolbar: React.FC<ToolbarProps> = ({ onFormatting }) => {
  const toolbarSections = [
    {
      name: 'History',
      items: [
        { icon: Undo, action: 'undo', tooltip: 'Undo' },
        { icon: Redo, action: 'redo', tooltip: 'Redo' },
      ]
    },
    {
      name: 'Formatting',
      items: [
        { icon: Bold, action: 'bold', tooltip: 'Bold' },
        { icon: Italic, action: 'italic', tooltip: 'Italic' },
        { icon: Underline, action: 'underline', tooltip: 'Underline' },
        { icon: Type, action: 'fontSize', tooltip: 'Font Size' },
        { icon: Palette, action: 'color', tooltip: 'Text Color' },
      ]
    },
    {
      name: 'Alignment',
      items: [
        { icon: AlignLeft, action: 'alignLeft', tooltip: 'Align Left' },
        { icon: AlignCenter, action: 'alignCenter', tooltip: 'Align Center' },
        { icon: AlignRight, action: 'alignRight', tooltip: 'Align Right' },
      ]
    },
    {
      name: 'Lists',
      items: [
        { icon: List, action: 'bulletList', tooltip: 'Bullet List' },
        { icon: ListOrdered, action: 'numberList', tooltip: 'Numbered List' },
      ]
    },
    {
      name: 'Insert',
      items: [
        { icon: Link, action: 'link', tooltip: 'Insert Link' },
        { icon: Image, action: 'image', tooltip: 'Insert Image' },
        { icon: Quote, action: 'quote', tooltip: 'Quote' },
        { icon: Code, action: 'code', tooltip: 'Code Block' },
      ]
    }
  ];

  return (
    <div className="bg-white border-b border-gray-200 px-6 py-3 dark:bg-[#25293c] dark:border-white/40">
      <div className="flex flex-wrap items-center space-x-1">
        {toolbarSections.map((section, sectionIndex) => (
          <React.Fragment key={section.name}>
            <div className="flex items-center space-x-1">
              {section.items.map((item) => (
                <Button
                  key={item.action}
                  variant="ghost"
                  size="sm"
                  onClick={() => onFormatting(item.action)}
                  className="h-8 w-8 p-0 hover:bg-gray-100 "
                  title={item.tooltip}
                >
                  <item.icon size={16} className='hover:dark:text-black'/>
                </Button>
              ))}
            </div>
            {sectionIndex < toolbarSections.length - 1 && (
              <Separator orientation="vertical" className="h-6 mx-2" />
            )}
          </React.Fragment>
        ))}
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Let’s look at the key concepts in the code above.

  • toolbarSections array: This array defines the different sections of the toolbar (e.g., "Formatting", "Alignment") and the buttons within each section. Each button is represented by an object that specifies the icon to display, the action to perform, and a helpful tooltip.
  • onFormatting prop: This is the communication channel between the Toolbar and the EmailComposer. When a button is clicked, the onClick handler calls the onFormatting function with the corresponding action (e.g., "bold", "italic").

In the EmailComposer component, the onFormatting prop is connected to a function that will interact with the Tiptap editor to apply the desired formatting to the selected text. This separation of concerns makes the code cleaner and easier to maintain.

Your UI should look like this (check the repo for the full code):

Image2

And there we have it—a complete UI for the email composer. It looks great, but it's still a solo experience.

Now for the exciting part. In the next section, we'll use Velt to add real-time commenting and transform this into a truly collaborative tool.

Adding Collaboration Features with Velt

In this section, you’ll add real-time commenting to your email composer app using Velt.

Before you start adding the real-time commenting feature, let me explain a key concept in working with Velt.

For Velt to work in your app, you’ll need a way to let Velt know about your app users. You’ll do this in an authentication (auth) component.

The auth component is where you authenticate and verify users. This tutorial uses a hardcoded users object and Zustand for user state management to simulate an auth fetch.

Note: In production, the auth component will be part of your app’s authentication system. You can switch Zustand for any state management method/library of your choice.

Wrapping your app in VeltProvider

The VeltProvider component uses the Context API to make the Velt client accessible to other components without prop drilling.

In your app’s root layout.tsx file, wrap your app in the VeltProvider component.

<VeltProvider apiKey={process.env.NEXT_PUBLIC_VELT_API_KEY || ""}>
    <ThemeProvider>{children}</ThemeProvider>
</VeltProvider>
Enter fullscreen mode Exit fullscreen mode

Creating your auth component

Like I mentioned earlier, Velt needs to be able to identify your app users. Without it, comments or other collaboration features won’t work.

Create a file, helper/userdb.ts, and add the following code:

import { create } from "zustand";
import { persist } from "zustand/middleware";

export type User = {
  uid: string;
  displayName: string;
  email: string;
  photoUrl?: string;
};

export interface UserStore {
  user: User | null;
  setUser: (user: User) => void;
}

export const userIds = ["user001", "user002"];
export const names = ["Nany", "Mary"];

export const useUserStore = create<UserStore>()(
  persist(
    (set) => ({
      user: null,
      setUser: (user) => set({ user }),
    }),
    {
      name: "user-storage",
    }
  )
);

Enter fullscreen mode Exit fullscreen mode

Let’s understand the above code.

  • You created userIds and names arrays for two users: Nany and Mary. In your application, you’d fetch users from your authentication system.
  • The useUserStore, create, and persist are for creating and persisting a user in Zustand.
  • create from zustand: creates a Zustand store. A Zustand store is a central location for your app’s state and functions that modify that state.
  • UserStore interface: defines the shape of your store, which includes a user object (set to null if no one is logged in) and a setUser function to update the user's state.
  • The persist middleware saves the store’s state to the browser’s localStorage. This means that when the app reloads, the store is loaded from localStorage.
  • The "user-storage" option is used to give a unique key for the data in localStorage.
  • useUserStore hook: a custom hook that our components will use to access the user data (user) and the function to modify it (setUser).

Note: Remember, you can replace Zustand with your preferred method of state management.

Now that you have a way to manage the user's state globally, you need to let Velt know who the current user is. This is essential for all of Velt's collaborative features, including comments.

The Header component contains the UI that allows you to simulate switching between users.

Let’s look at the relevant code snippets of the Header component.

// components/funtions/Header.tsx

export const Header: React.FC<HeaderProps> = ({
  // ...props
}) => {
  const { user, setUser } = useUserStore();
  const { client } = useVeltClient();

  const predefinedUsers = useMemo(
    () =>
      userIds.map((uid, index) => {
        // ... (user data)
      }),
    []
  );

  // ... (useEffect to set initial user)

  // ...
};
Enter fullscreen mode Exit fullscreen mode

You use the useUserStore hook to get the current user, and setUser function to update the current user.

The predefinedUsers array is used to populate the user selection dropdown.

// components/funtions/Header.tsx

<DropdownMenuContent align="end" className="w-64 dark:bg-[#2f3349]">
  <DropdownMenuLabel>Select User</DropdownMenuLabel>
  <DropdownMenuSeparator className="dark:bg-white/40" />
  {predefinedUsers.map((Currentuser) => (
    <DropdownMenuItem
      key={Currentuser.uid}
      onClick={() => setUser(Currentuser)}
      // ...
    >
      {/* ... (user avatar and details) */}
    </DropdownMenuItem>
  ))}
</DropdownMenuContent>
Enter fullscreen mode Exit fullscreen mode

A user can switch between the predefined users using the dropdown menu. When a new user is selected, the setUser function is triggered and updates the global state.

// components/funtions/Header.tsx

useEffect(() => {
  if (!client || !user) return;
  const veltUser = {
    userId: user.uid,
    organizationId: "organization_id", // Replace with your organization ID
    name: user.displayName,
    email: user.email,
    photoUrl: user.photoUrl,
  };

  client.identify(veltUser);
}, [client, user]);
Enter fullscreen mode Exit fullscreen mode

Once the user is authenticated (or, in this case, the user is selected), you need to identify the user to the Velt SDK.

You identify users to the Velt SDK using the client.identify() method. You call the method inside a useEffect hook that runs whenever the user or client object changes.

Velt requires a set of properties in a user object to identify a user. The required properties are userId, name, email, organizationId, and photoUrl. You can include additional properties, such as color, used in the background color of a user’s avatar, and textColor used for the color of the user’s initials if photoUrl is not specified.

This approach ensures that Velt always has the most up-to-date information about the current user, which is essential for a seamless collaborative experience. By identifying the user, Velt can:

  • Show the user's avatar and name in the comment threads.
  • Display the user's presence information to other users in real-time.
  • Send notifications to the correct user.

The next step is to add comments to the Tiptap editor.

Adding comments to the Tiptap editor

You’ll add comments to the Tiptap in three key steps.

Integrating the Velt Tiptap extension

The @veltdev/tiptap-velt-comments extension you installed earlier provides the necessary functionality to handle comment creation, rendering, and interaction within the Tiptap editor.

// components/funtions/TiptapEditor.tsx

import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react";
import {
  TiptapVeltComments,
  renderComments,
  addComment,
} from "@veltdev/tiptap-velt-comments";
import { StarterKit } from "@tiptap/starter-kit";

const TiptapEditor = () => {
  const editor = useEditor({
    extensions: [
      TiptapVeltComments.configure({
        persistVeltMarks: false,
      }),
      StarterKit,
    ],
    // ... (editor content and other options)
  });

  // ...
}; 
Enter fullscreen mode Exit fullscreen mode

You updated the TiptapEditor component to include the TiptapVeltComments extension in the useEditor hook.

Adding comments

The addComment function from @veltdev/tiptap-velt-comments lets users add comments to your app.

// components/funtions/TiptapEditor.tsx

const TiptapEditor = () => {
  const editor = useEditor({
    // ... (extensions)
  });

  // ... (annotations and renderComments)

  const onClickComments = () => {
    if (editor) {
      addComment({
        editor,
        editorId: EDITOR_ID,
      });
    }
  };

  return (
    <div className="w-full mx-auto">
      {editor && (
        <BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
          <div className="bubble-menu">
            <Button
              variant="outline"
              onClick={onClickComments}
              // ... (styling)
            >
              Add Comment
            </Button>
          </div>
        </BubbleMenu>
      )}

      <EditorContent editor={editor} /* ... (styling) */ />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

You added an "Add Comment" button to a BubbleMenu, which is a floating menu that appears when a user selects text in the editor. When the user clicks this button, we'll call the addComment function.

Rendering previous comments

To display previous comments, you’ll need to fetch the comment annotations from Velt and render them. You’ll use the useCommentAnnotations hook from @veltdev/react to fetch the comment annotations.

// components/funtions/TiptapEditor.tsx

import { useCommentAnnotations } from "@veltdev/react";
import { useEffect } from "react";

const EDITOR_ID = "customer.io-example"; // Use a consistent, meaningful ID

const TiptapEditor = () => {
  const editor = useEditor({
    // ... (extensions)
  });

  const annotations = useCommentAnnotations();

  useEffect(() => {
    if (editor && annotations?.length) {
      renderComments({
        editor,
        editorId: EDITOR_ID,
        commentAnnotations: annotations,
      });
    }
  }, [editor, annotations]);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

You used the hook in your TiptapEditor component inside a useEffect hook, and then called the renderComments function to display the annotations on the editor.

Here's the Quick Demo:

Note: It's important to know that by using the VeltComments component, you enable a range of collaboration features by default. These features include threaded replies, @mentions, read receipts, and being able to resolve comment threads.

Conclusion

You now have a fully integrated, real-time commenting system built into your Tiptap editor.

In this article, you’ve learned how to build a collaborative email composer app. You used Tiptap for your email editor and Velt for adding real-time comments.

You’ve learned that you don’t need a complex backend to add a real-time commenting feature to your apps. Velt lets you focus on your app's unique logic instead of reinventing the wheel.

Want to add more collaboration? The Velt platform is designed to be your complete toolkit. You can extend this project with other collaboration features like:

  • Live Presence: See which teammates are online.
  • Multiplayer Editing: Enable real-time, co-editing with Velt's CRDT (Conflict-free Replicated Data Type) feature. CRDT is based on Yjs and handles conflict-free synchronization seamlessly, allowing users to edit simultaneously or even offline.

Resources

Top comments (0)