Jan 14, 2026 · 15 min read

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.
In this section, you’re going to set up your project. For this tutorial, we’ll be using the following tech stack:
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.
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:
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.
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 and PutThread if a certain condition is met.ThreadView: renders the PostCard component.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.
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:
userIds and names are arrays of hardcoded users. In your app, you’d fetch users from your auth function or database.create is 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 the UserStore interface.persist, from Zustand, is used to “persist” users in the browser’s localStorage.useUserStore is the hook you’ll use to interact with your Zustand store.Next up is creating the PutThread component.
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:
PutThread component that renders a rich text editor. The component uses the useEditor hook from Tiptap to initialize and manage the editor's state. useEditor accepts a configuration object that defines the editor's behavior, available features, and initial content.useEditor. This object contains:
extensions: an array that enables specific editor capabilities. Here, you include StarterKit, 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 to true, which makes the editor immediately ready for input.BubbleMenu component from Tiptap wraps the custom comment button, which will be used to add comments directly in the editor. The tippyOptions prop controls the animation behavior of the menu. onClickComments handler function that currently logs a message to the console. This function provides the foundation for adding the comment functionality to the editor.BubbleMenu containing a styled button with a message icon.EditorContent component that renders the actual editable area where users can interact with the text.This component creates a fully functional rich text editor with a floating toolbar.
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:
PostCard component that renders a social media-style post interface. The component accepts props, including author information, timestamp, content, and flags to control the display of threaded content. The isThread prop determines whether to show the Tiptap editor for content creation.useUserStore hook, which provides access to the current user's information, including display name and avatar.PutThread component when isThread is 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.
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:
ThreadView component that renders a threaded conversation interface similar to X (formerly Twitter). The component uses a threadPosts array containing mock data. In your app, you’d fetch these posts from your database.threadPosts array to render each post using the PostCard component you created earlier. For each post, we pass the author data and content while dynamically setting two key properties:
isThread: set to true only for the first post, enabling the Tiptap rich text editor.showConnector: set to true for all posts except the last one, creating visual connections between consecutive posts.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.
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.
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.
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:
Let’s tackle them one after the other.
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:
useVeltClient hook to access the Velt client instance, which handles the real-time connection and user management.useEffect hook initializes Velt when both the client and user data are available. The hook detects user switches and re-identifies the user with Velt using the client.identify() method. Velt requires these fields: userId, name, email, organization, and photoUrl to identify users. 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 the VeltCommentsSidebar on 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_ID represents the unique ID of the Tiptap editor. EDITOR_ID should match the value organizationId field of the veltUser object.TiptapVeltComments extension adds commenting functionality within the editor. It is configured so that Velt marks are not persisted in the editor.useCommentAnnotations hook fetches comment data from Velt.renderComments inside the useEffect hook renders the comments in the Tiptap editor. renderComments accepts 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.addComment method inside the onClickComments function allows users to add comments to selected text in the Tiptap editor. addComment takes 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.
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:
VeltPresence from the @veltdev/react package.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.
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>
);
}
Start your development server using the following command:
npm run dev
Open your browser, and you should see something like this:
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.
Also published on DEV.to.