Introduction
Typefully, a writing and scheduling platform, helps creators and marketing teams build their brands on X (formerly Twitter) and LinkedIn. It also features collaborative tools, such as adding comments or notes to post drafts, which could be very useful for social media teams.
Managers need to know who’s working on what. Team members need notifications for when they’re mentioned or assigned new tasks. Without this, workflows break down, leading to miscommunications and delays.
Large marketing teams need a platform with more collaboration features.
This article guides you through building a Typefully clone supercharged with real-time collaboration features, such as notifications, mentions, and commenting.
Project Setup and Structure
In this section, you’re going to set up your project. For this tutorial, we’ll be using the following tech stack:
- Next.js: full-stack framework for building the app.
- Tiptap: for adding a rich text editor to our app.
- Velt: for adding the collaboration features.
- Shadcn: for UI components.
- Zustand: for state management.
Use the following command to scaffold a Next.js:
npx create-next-app@latest my-app --yes
The --yes flag skips the prompts and creates a new Next.js project with saved preferences and defaults. The default setup enables TypeScript, Tailwind, ESLint, App Router, and Turbopack, with import alias @/*. This setup is enough for this tutorial.
Next, install the necessary packages for your app.
Run the following command to install Tiptap, Velt, and Zustand:
npm install zustand @tiptap/react @tiptap/starter-kit @veltdev/react @veltdev/tiptap-velt-comments
Here’s what these packages are for:
-
zustand: the Zustand library for state management. -
@tiptap/react: Tiptap package for React-based frameworks. -
@tiptap/starter-kit: a starter kit for the Tiptap editor. -
@veltdev/react: Velt SDK for React-based frameworks. -
@veltdev/tiptap-velt-comments: Velt package for adding comments in a Tiptap editor.
Now that you’re done with setting up your project, the next sections show you how to build the Typefully clone. Before that, let’s see why we’re using Velt for the collaboration features.
Why Velt for collaboration features?
If you’ve built a collaborative web app from the ground up, you know how much complexity goes into building the collaboration features.
You have to:
- Design an intuitive, user-friendly UI for comments and user avatars.
- Handle the dynamic and complex interactions, such as text selection, popovers, user tracking, real-time updates, and push notifications.
- Manage comment and notification annotation functionalities and storage.
Handling these tasks yourself will slow down your development process.
Velt solves this problem for you. Velt offers ready-made full-stack components that enable you to add real-time collaboration features to your app without writing huge lines of code. Velt handles the syncing of the UI and backend, storage, and push notifications. This allows you to focus on the core features that make your app unique.
Implementing the core editor using Tiptap
In this section, you’ll add the Tiptap editor to your Next.js app.
Let me point out something before we proceed. The code snippets in this tutorial focus on the important parts of building the Typefully clone. The UI and styling parts will be skipped. For the full working code, check out the repo.
To add Tiptap to your app, you’ll create the following components:
-
PutThread: initializes and renders the editor UI. -
PostCard: renders the posts andPutThreadif a certain condition is met. -
ThreadView: renders thePostCardcomponent.
Also, you’ll configure Zustand to manage user state. This tutorial uses a mock user database. In your app, you’d integrate Zustand with your authentication system.
Configuring the Zustand store for user management
Create a new folder, helper, in the root directory of your app. Inside helper, create a userdb.ts file.
Inside the userdb.ts file, 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",
}
)
);
Let’s understand what’s happening in the code:
-
userIdsandnamesare arrays of hardcoded users. In your app, you’d fetch users from your auth function or database. -
createis a function from Zustand for creating a Zustand store. A Zustand store is a centralized location for managing your application’s state. The Zustand store has the shape of theUserStoreinterface. -
persist, from Zustand, is used to “persist” users in the browser’slocalStorage. -
useUserStoreis the hook you’ll use to interact with your Zustand store.
Next up is creating the PutThread component.
Creating the PutThread component
Like I mentioned earlier, PutThread initializes and renders the Tiptap editor.
Create a components folder. Inside the components folder, create a PutThread.tsx file and add the following code:
"use client";
import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react";
import { StarterKit } from "@tiptap/starter-kit";
import { Button } from "../ui/button";
import { MessageCircle } from "lucide-react";
const PutThread = () => {
// Initialize Tiptap editor
const editor = useEditor({
extensions: [
StarterKit,
],
content: `
<article class="post">
<header>
<h2>Building an Audience: A Compounding Investment</h2>
<p>Building an audience is a great investment that compounds over the years, maximizing the impact of everything else you do online.</p>
</header>
<section aria-labelledby="tips-title">
<h3 id="tips-title">Tips to get started</h3>
<ul>
<li>🚀 <strong>Start small</strong></li>
<li>📋 <strong>Consistently deliver value</strong></li>
<li>✨ <strong>Watch your influence multiply</strong></li>
</ul>
</section>
<footer>
<p>
This is why
<a href="https://typefully.com" target="_blank" rel="noopener">@typefully</a>
is an investment in your future reach and impact.
</p>
</footer>
</article>
`,
autofocus: true,
});
// Comment handler (will be enhanced later with Velt)
const onClickComments = () => {
// Basic comment functionality - to be enhanced in next section
console.log("Comment button clicked");
};
return (
<div className="border-2 p-4 my-3 border-dashed rounded">
{/* Bubble Menu with comment button */}
{editor && (
<BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
<div className="bubble-menu">
<Button
variant="outline"
onClick={onClickComments}
className="bg-[#b056ef] hover:bg-[#a22ff5] p-2 flex items-center justify-center rounded-full focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-300"
>
<MessageCircle color="white" />
</Button>
</div>
</BubbleMenu>
)}
{/* Editor Content */}
<EditorContent editor={editor} />
</div>
);
};
export default PutThread;
Let's break down what's happening in this code:
- You defined a
PutThreadcomponent that renders a rich text editor. The component uses theuseEditorhook from Tiptap to initialize and manage the editor's state.useEditoraccepts a configuration object that defines the editor's behavior, available features, and initial content. - You configured the editor instance by passing an object to
useEditor. This object contains:-
extensions: an array that enables specific editor capabilities. Here, you includeStarterKit, which provides common formatting options like bold, italics, lists, and headings. -
content: the initial HTML content that populates the editor when it first loads. -
autofocus: a boolean that determines if the editor should automatically receive focus when mounted. It’s set totrue, which makes the editor immediately ready for input.
-
- You set up a bubble menu that appears when text is selected. The
BubbleMenucomponent from Tiptap wraps the custom comment button, which will be used to add comments directly in the editor. ThetippyOptionsprop controls the animation behavior of the menu. - You created an
onClickCommentshandler function that currently logs a message to the console. This function provides the foundation for adding the comment functionality to the editor. - You returned the JSX structure that renders the editor interface. The component includes:
- The conditional
BubbleMenucontaining a styled button with a message icon. - The
EditorContentcomponent that renders the actual editable area where users can interact with the text.
- The conditional
This component creates a fully functional rich text editor with a floating toolbar.
Creating the PostCard component
Now that you’ve created the PutThread component, you’ll create the PostCard component. This component will conditionally render the PutThread component.
Inside your components folder, create a PostCard.tsx file and add the following code:
"use client";
import PutThread from "./PutThread";
import { useUserStore } from "@/helper/userdb";
interface PostCardProps {
author: {
name: string;
username: string;
avatar: string;
verified?: boolean;
};
timestamp?: string;
content?: string;
isThread?: boolean;
showConnector?: boolean;
}
export function PostCard({
author,
timestamp,
isThread,
content,
showConnector = false,
}: PostCardProps) {
const { user } = useUserStore();
return (
<div className="p-3 sm:p-4 transition-shadow">
<div className="flex items-start gap-3 relative">
{/* Avatar and user info */}
<div className="">
{/* ... avatar component */}
</div>
{/* Content column */}
<div className="flex-1 min-w-0">
{/* ... user header info */}
{/* Post content */}
<div className="prose prose-sm dark:prose-invert max-w-none">
<p className="whitespace-pre-line text-sm leading-relaxed mb-3">
{content}
</p>
</div>
{/* Conditionally render Tiptap editor */}
{isThread && <PutThread />}
</div>
</div>
</div>
);
}
Let's understand what's happening in the code:
- You defined a
PostCardcomponent that renders a social media-style post interface. The component accepts props, includingauthorinformation,timestamp,content, and flags to control the display of threaded content. TheisThreadprop determines whether to show the Tiptap editor for content creation. - You initialized user data using the
useUserStorehook, which provides access to the current user's information, including display name and avatar. - You conditionally render the
PutThreadcomponent whenisThreadis true. This integrates the Tiptap rich text editor we configured earlier, allowing users to create and edit threaded content.
This component creates a reusable post interface that can display both simple content and rich text editor threads.
Creating the ThreadView component
Now that you’ve created the PostCard and PutThread components, you’ll create the ThreadView component. The ThreadView component renders the PostCard component.
Inside your components folder, create a thread-view.tsx file and add the following code:
"use client";
import { PostCard } from "./post-card";
const threadPosts = [
{
author: {
name: "Fabrizio Rinaldi",
username: "linuz90",
avatar:
"https://images.pexels.com/photos/220453/pexels-photo-220453.jpeg?auto=compress&cs=tinysrgb&w=400",
verified: true,
},
},
{
author: {
name: "Fabrizio Rinaldi",
username: "linuz90",
avatar:
"https://images.pexels.com/photos/220453/pexels-photo-220453.jpeg?auto=compress&cs=tinysrgb&w=400",
verified: true,
},
content: `By the way, this is a sample draft!`,
},
{
author: {
name: "Fabrizio Rinaldi",
username: "linuz90",
avatar:
"https://images.pexels.com/photos/220453/pexels-photo-220453.jpeg?auto=compress&cs=tinysrgb&w=400",
verified: true,
},
content: `Some more tips for you:
• Use the buttons in the top right to share & organize drafts
• Drag & drop pictures and videos in the editor
• Type : followed by an email alias
• Paste a tweet link to quote it`,
},
];
export function ThreadView() {
return (
<div className="max-w-2xl mx-auto p-4 sm:p-6">
<div className="space-y-0">
{threadPosts.map((post, index) => (
<div key={index} className="relative">
<PostCard
{...post}
isThread={index === 0}
showConnector={index < threadPosts.length - 1}
/>
</div>
))}
</div>
</div>
);
}
Let's break down what's happening in this code:
- You defined a
ThreadViewcomponent that renders a threaded conversation interface similar to X (formerly Twitter). The component uses athreadPostsarray containing mock data. In your app, you’d fetch these posts from your database. - Mapped through the
threadPostsarray to render each post using thePostCardcomponent you created earlier. For each post, we pass the author data and content while dynamically setting two key properties:-
isThread: set totrueonly for the first post, enabling the Tiptap rich text editor. -
showConnector: set totruefor all posts except the last one, creating visual connections between consecutive posts.
-
- You rendered the complete thread interface, where the first post contains your Tiptap editor (via
isThread={true}) and subsequent posts display static content. This creates a realistic social media thread experience with the foundational editor integrated.
This component creates a complete threaded conversation view similar to X.
Displaying the threaded conversation in the UI
With the components created, you need to display the ThreadView in the UI. ThreadView is a wrapper of the other two components.
In your app’s home page, page.tsx, add the following code:
"use client";
import { useState } from "react";
import { Header } from "@/components/layout/header";
import { Sidebar } from "@/components/layout/sidebar";
import { ThreadView } from "@/components/post/thread-view";
export default function Home() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const toggleSidebar = () => {
setSidebarOpen(!sidebarOpen);
};
const closeSidebar = () => {
setSidebarOpen(false);
};
return (
<div className="min-h-screen bg-background">
<Header onToggleSidebar={toggleSidebar} />
<div className="flex">
<Sidebar isOpen={sidebarOpen} onClose={closeSidebar} />
<main className="flex-1 overflow-auto min-w-0">
<ThreadView />
</main>
</div>
</div>
);
}
This is the entry point of our application. It contains the threaded post interface.
With that, your UI should look like this:
Remember, the full code is in the GitHub repo.
Now that you’ve created the UI and added the Tiptap editor to your app, you’re going to add real-time collaboration features in the next section.
Adding Real-Time Collaboration with Velt
Now that you’ve built the UI of the clone, you’ll add collaboration features using Velt next.
Before you add the collaboration features, let me explain a few key concepts to consider when working with Velt.
First, you need an API key to work with Velt. Go to Velt’s console and get your free API key.
Note: you need to upgrade to the paid version before using the Velt API key in production.
Add the API key to your .env file:
NEXT_PUBLIC_VELT_ID="your-api-key"
With that out of the way, let’s get into adding the collaboration features.
Configuring Velt
Velt provides a Provider component to enable Velt in your app.
Update your page.tsx file to wrap your app inside VeltProvider and pass in your API key:
"use client";
import { useState } from "react";
import { Header } from "@/components/layout/header";
import { Sidebar } from "@/components/layout/sidebar";
import { ThreadView } from "@/components/post/thread-view";
import { VeltComments, VeltProvider } from "@veltdev/react";
import useTheme from "@/hooks/use-theme";
export default function Home() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const toggleSidebar = () => {
setSidebarOpen(!sidebarOpen);
};
const closeSidebar = () => {
setSidebarOpen(false);
};
const { theme } = useTheme();
return (
<VeltProvider apiKey={process.env.NEXT_PUBLIC_VELT_ID!}>
<div className="min-h-screen bg-background">
<Header onToggleSidebar={toggleSidebar} />
<div className="flex">
<Sidebar isOpen={sidebarOpen} onClose={closeSidebar} />
<main className="flex-1 overflow-auto min-w-0">
<ThreadView />
</main>
</div>
</div>
</VeltProvider>
);
}
These are the collaboration features that you’ll be adding to your app:
- Comments
- Presence
- Notification
Let’s tackle them one after the other.
Adding comments
Velt requires authentication to identify your users. Without proper authentication and document initialization (we’ll do that in a bit), Velt won’t work.
For this tutorial, we’ll simulate fetching a list of users and then use the client.identify() method to identify the user with Velt.
The logic for authentication will be in the Header component. In your app, you can separate the logic into its own component.
Create a header.tsx file inside the components folder and add the following code:
export function Header({ onToggleSidebar }: HeaderProps) {
const { user, setUser } = useUserStore();
const { client } = useVeltClient();
// Velt user identification
useEffect(() => {
if (!client || !user) return;
const initializeVelt = async () => {
// Identify user with Velt
const veltUser = {
userId: user.uid,
name: user.displayName,
email: user.email,
photoUrl: user.photoUrl,
};
await client.identify(veltUser);
// Set document for comment scope
await client.setDocuments([
{
id: "typefully-comments",
metadata: { documentName: "typefully-comments" },
},
]);
};
initializeVelt();
}, [client, user]);
// ... rest of component
}
Let’s understand what’s happening in the code:
- You created a user dropdown component that allows switching between predefined users. The component uses the
useVeltClienthook to access the Velt client instance, which handles the real-time connection and user management. - You defined mock user data with unique user IDs, display names, and avatar URLs. The mock user database provides the necessary user information that Velt requires to identify users and display their avatars when they’re online.
- The
useEffecthook initializes Velt when both the client and user data are available. The hook detects user switches and re-identifies the user with Velt using theclient.identify()method. Velt requires these fields:userId,name,email,organization, andphotoUrlto identify users. - You configured the document context using
client.setDocuments()with a unique document ID (”typefully-comments”). This scopes the collaboration features to a specific document or thread, which means comments and presence are isolated between different content areas.
This authentication setup allows Velt to track your users across all collaboration features, which allows for personalized comments, presence indicators, and real-time updates throughout your application.
Adding the Comments Sidebar
The Comments Sidebar provides a toggleable sidebar to view and filter comments. You need two components to add a comments sidebar to your app:
-
VeltCommentsSidebar: the sidebar component that contains all comments in a document (in this case, the content area). -
VeltSidebarButton: a button to toggle theVeltCommentsSidebaron and off.
Update the Header component by adding the VeltCommentsSidebar and VeltSidebarButton components.
// header.tsx
"use client";
// ... other imports
import {
VeltCommentsSidebar,
VeltSidebarButton,
} from "@veltdev/react";
// ... other imports
import useTheme from "@/hooks/use-theme";
export function Header({ onToggleSidebar }: HeaderProps) {
// ... user authentication and Velt initialization logic
const { theme } = useTheme();
return (
<header className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-14 items-center justify-between px-4">
{/* ... header left section (logo, menu button) */}
<div className="flex items-center justify-end gap-3 lg:gap-4">
{/* Add Comments Sidebar and Toggle Button */}
<VeltCommentsSidebar darkMode={theme === "dark"} />
{/* ... user dropdown and other header controls */}
<VeltSidebarButton darkMode={theme === "dark"} />
</div>
</div>
</header>
);
}
Adding comments to the Tiptap Editor
In this part of adding comments to your app, you’ll add comments to the Tiptap editor.
Velt provides an extension and hooks for adding comments in a Tiptap editor.
Go to the PutThread component, where you initialized the Tiptap editor, and update the code:
"use client";
// ... existing Tiptap imports
import {
TiptapVeltComments,
renderComments,
addComment,
} from "@veltdev/tiptap-velt-comments";
import { useCommentAnnotations } from "@veltdev/react";
import { useEffect } from "react";
const EDITOR_ID = "typefully-comments";
const PutThread = () => {
const editor = useEditor({
extensions: [
TiptapVeltComments.configure({
persistVeltMarks: false,
}),
// ... existing extensions
],
// ... existing configuration
});
// Velt comment integration
const annotations = useCommentAnnotations();
useEffect(() => {
if (editor && annotations?.length) {
renderComments({
editor,
editorId: EDITOR_ID,
commentAnnotations: annotations,
});
}
}, [editor, annotations]);
const onClickComments = () => {
if (editor) {
addComment({
editor,
editorId: EDITOR_ID,
});
}
};
return (
// ... existing UI structure
// Bubble menu now functional with Velt comments
);
};
export default PutThread;
Let’s understand the updated part of the code:
-
EDITOR_IDrepresents the unique ID of the Tiptap editor.EDITOR_IDshould match the valueorganizationIdfield of theveltUserobject. -
TiptapVeltCommentsextension adds commenting functionality within the editor. It is configured so that Velt marks are not persisted in the editor. - The
useCommentAnnotationshook fetches comment data from Velt. -
renderCommentsinside theuseEffecthook renders the comments in the Tiptap editor.renderCommentsaccepts an object as its parameter with the following properties:-
editor: instance of the Tiptap editor. -
editorId: the Tiptap editor ID (EDITOR_ID). -
commentAnnotations: array of Comment Annotation objects.
-
- The
addCommentmethod inside theonClickCommentsfunction allows users to add comments to selected text in the Tiptap editor.addCommenttakes in two required parameters:-
editor: instance of the Tiptap editor. -
editorId: ID of the Tiptap editor.
-
Update the page.tsx file with the following code to enable comments in your app:
"use client";
import { useState } from "react";
import { Header } from "@/components/layout/header";
import { Sidebar } from "@/components/layout/sidebar";
import { ThreadView } from "@/components/post/thread-view";
import { VeltComments, VeltProvider } from "@veltdev/react";
import useTheme from "@/hooks/use-theme";
export default function Home() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const toggleSidebar = () => {
setSidebarOpen(!sidebarOpen);
};
const closeSidebar = () => {
setSidebarOpen(false);
};
const { theme } = useTheme();
return (
<VeltProvider apiKey={process.env.NEXT_PUBLIC_VELT_ID!}>
<div className="min-h-screen bg-background">
<Header onToggleSidebar={toggleSidebar} />
<div className="flex">
<Sidebar isOpen={sidebarOpen} onClose={closeSidebar} />
<main className="flex-1 overflow-auto min-w-0">
<ThreadView />
</main>
</div>
</div>
<VeltComments
textMode={false}
shadowDom={false}
textCommentToolShadowDom={false}
darkMode={theme === "dark"}
/>
</VeltProvider>
);
}
Now your users can add comments directly inside the Tiptap editor.
It's important to know that by adding 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.
In the next subsection, you’ll add Presence to your app.
Adding Presence
Presence allows users to see other users who are online. This feature makes your app feel like a chat application.
To add Presence to your app, you only need to do two things:
- Import the
VeltPresencefrom the@veltdev/reactpackage. - Render the component where you’d like to see user avatars.
No extra configuration or setup.
Easy, right? I know.
Update your header.tsx file to include Presence:
"use client";
// ... other imports
import { VeltPresence } from "@veltdev/react";
// ... other imports
export function Header({ onToggleSidebar }: HeaderProps) {
// ... existing user and theme logic
return (
<header className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-14 items-center justify-between px-4">
{/* ... header left section */}
<div className="flex items-center justify-end gap-3 lg:gap-4">
{/* ... other components */}
{/* Add Velt Presence to show live user avatars */}
<VeltPresence />
{/* ... other header buttons */}
</div>
</div>
</header>
);
}
Now users can see other users’ avatars who are online.
In the next subsection, you’ll add In-app Notifications.
Adding In-App Notifications
In-app Notifications allow users to receive email notifications when they’re mentioned in a document.
To add notifications to your app, you’ll have to enable notifications in your Velt console.
Go to the Notifications section in the Configurations section of the Velt Console and enable Notifications.
Update your header.tsx file to add the VeltNotificationTool, which is the component for toggling the notification panel.
"use client";
// ... other imports
import { VeltNotificationsTool } from "@veltdev/react";
// ... other imports
import useTheme from "@/hooks/use-theme";
export function Header({ onToggleSidebar }: HeaderProps) {
// ... existing user and theme logic
const { theme } = useTheme();
return (
<header className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-14 items-center justify-between px-4">
{/* ... header left section */}
<div className="flex items-center justify-end gap-3 lg:gap-4">
{/* ... other components */}
{/* Add Notifications Tool for in-app alerts */}
<VeltNotificationsTool darkMode={theme === "dark"} />
{/* ... other header buttons */}
</div>
</div>
</header>
);
}
Demo
Start your development server using the following command:
npm run dev
Open your browser, and you should see something like this:
Conclusion
You supercharged your Typefully clone with real-time commenting, presence, and in-app notification features.
The best part? You didn’t handle the backend yourself or build it from scratch. Velt took care of syncing, storage, and collaboration, letting you focus on the user experience.
If you’re working on your own SaaS, you can extend your app further by adding other powerful Velt features like screen recording, cursor tracking, reactions, and real-time hurdles. Adding these features can make your app more robust and give your users an even better experience.




Top comments (1)
That's a great idea!