Linear has redefined what project management should feel like. Its real-time collaboration, inline comments, and fluid interface make team coordination effortless. But here's the challenge: replicating Linear's collaborative features from scratch takes months of engineering work.
What if you could build a Linear-inspired collaborative app in days instead of months?
In this tutorial, we'll build a project management dashboard that captures Linear's core collaborative experience. We'll use Next.js for the framework, Tiptap for rich text editing, Tailwind CSS for Linear's minimal aesthetic, and Velt to handle the collaboration infrastructure that makes Linear so powerful.
Final Linear-style app with real-time collaboration
Why Linear's Approach Works
Linear's success comes from three collaboration principles:
- Real-time everything: Changes sync instantly across team members
- Context-rich comments: Discussions happen right where work is being done
- Effortless presence: You always know who's working on what
These aren't just nice features—they're what make distributed teams productive. We'll implement all three using Velt's collaboration SDK.
What We're Building With
Let's quickly cover the tools:
Next.js is a React framework that handles routing and server-side rendering. It's perfect for real-time apps like Linear..
Tiptap is a headless rich text editor built on ProseMirror. It gives us Linear's smooth editing experience, a headless rich text editor that integrates seamlessly with collaborative features.
Tailwind CSS provides utility-first styling, so we can build a polished UI quickly without writing custom CSS.
Velt is the star here. Instead of building collaboration infrastructure like Linear yourself, Velt's SDK provides real-time sync, comments, presence tracking, and notifications out of the box. Think of it as a collaboration layer you can drop into any app.
Prerequisites
You'll need:
- Node.js (v16+) installed on your machine
- Basic familiarity with Next.js, React hooks, and TypeScript
- A Velt account (sign up free at velt.dev)
- Understanding of Tailwind CSS basics
No prior experience with Tiptap or Velt is required, we'll walk through everything.
Project Setup
Create a Next.js Project
Start by scaffolding a new Next.js project with TypeScript and Tailwind CSS:
npx create-next-app@latest collaborative-project-manager
When prompted, select:
- TypeScript: Yes
- App Router: Yes
- Tailwind CSS: Yes
Then navigate into your project:
cd collaborative-project-manager
Install Dependencies
Install the packages we need for collaboration, editing, and UI:
npm install @tiptap/react @veltdev/client @veltdev/react @veltdev/tiptap-velt-comments zustand lucide-react @radix-ui/react-avatar @radix-ui/react-dropdown-menu
Here's what each does:
- @tiptap/react & @tiptap/starter-kit: Tiptap editor components and basic formatting extensions
- @veltdev/client & @veltdev/react: Velt's core SDK and React hooks
- @veltdev/tiptap-velt-comments: Integration between Tiptap and Velt's comment system
- zustand: Lightweight state management for user data
- lucide-react: Icon library for the UI
- @radix-ui: Accessible component primitives (we'll use these without styling them ourselves)
Get Your Velt API Key
Head to console.velt.dev, sign up, and create a new API key. Create a .env.local file in your project root:
NEXT_PUBLIC_VELT_ID=your_api_key_here
Velt dashboard showing API key creation
Setting Up Linear-Style Collaboration
Set Up User Management
Just as Linear tracks who's doing what, we need to also manage which user is currently "logged in" so we can test collaboration with multiple users. Create helper/userdb.ts:
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useUserStore = create<UserStore>()(
persist(
set => ({
user: null,
setUser: user => set({ user }),
}),
{ name: "user-storage" }
)
);
export const userIds = ["user001", "user002"];
export const names = ["Nany", "Mary"]
This creates a Zustand store that holds the current user and persists it to localStorage. The persist middleware automatically saves user changes, so when you refresh the page, it remembers which user you were. For this demo, we define two users—in production, but in a real-world app, you would connect this to your auth system and pull real user data from there.
Set Up Theme Management
Linear offers both light and dark modes. Create hooks/useTheme.tsx to handle light and dark themes:
import { createContext, useContext, useEffect, useState } from "react";
const ThemeContext = createContext<ThemeContextProps | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
const saved = localStorage.getItem("theme") as "light" | "dark" | null;
setTheme(saved || "light");
}, []);
useEffect(() => {
document.documentElement.setAttribute("class", theme);
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prev => (prev === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export default useTheme;
This context manages the theme state globally. When the component mounts, it checks localStorage for a saved theme preference. When the user toggles the theme, it updates the DOM and saves the preference so it persists across sessions.
Setting Up the Root Layout
Wrap Your App with Velt and Theme Providers
Wrap your app with Velt to enable Linear-style collaboration app/(root)/layout.tsx:
import { ThemeProvider } from "@/hooks/useTheme";
import { VeltProvider } from "@veltdev/react";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<VeltProvider apiKey={process.env.NEXT_PUBLIC_VELT_ID || ""}>
<ThemeProvider>{children}</ThemeProvider>
</VeltProvider>
);
}
The VeltProvider initializes collaboration infrastructure—this is what powers Linear's real-time features.
Set Up the Main App Page
Update app/(root)/page.tsx:
"use client";
import { MainLayout } from "@/components/layout/MainLayout";
import { VeltComments } from "@veltdev/react";
import useTheme from "@/hooks/useTheme";
export default function Home() {
const { theme } = useTheme();
return (
<>
<MainLayout />
<VeltComments
textMode={false}
shadowDom={false}
darkMode={theme === "dark"}
/>
</>
);
}
The VeltComments component activates Velt's inline commenting system globally. By passing darkMode={theme === "dark"}, Velt's UI automatically matches your app's theme. This is the minimal setup needed to enable comments throughout your app.
Building the Editor
Create the Linear-Style Rich Text Editor
Linear's editor is fast, minimal, and collaborative. Here's how we replicate it with Tiptap and Velt components/editor/RichTextEditor.tsx:
import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react";
import { TiptapVeltComments, renderComments, addComment } from "@veltdev/tiptap-velt-comments";
import { useCommentAnnotations } from "@veltdev/react";
import { StarterKit } from "@tiptap/starter-kit";
import { MessageCircle } from "lucide-react";
const RichTextEditor = () => {
const editor = useEditor({
extensions: [
TiptapVeltComments.configure({ persistVeltMarks: false }),
StarterKit,
],
content: `<h2>Project Description</h2>
<p>Add your project details here. Team members can edit and comment in real-time.</p>`,
});
return (
<div className="border-2 border-dashed border-white/30 rounded-lg p-4">
<EditorToolbar editor={editor} />
{editor && (
<BubbleMenu editor={editor}>
<Button
onClick={() =>
addComment({ editor, editorId: "linearStyleEditor" })
}>
<MessageCircle size={16} />
</Button>
</BubbleMenu>
)}
<EditorContent editor={editor} />
</div>
);
};
export default RichTextEditor;
Key insight: The TiptapVeltComments extension handles collaborative commenting. Users select text, click the bubble menu button, and add comments—just like Linear.
For the complete toolbar implementation with formatting buttons, check the repository (linked at the end).
Build the Editor Toolbar
The toolbar lets users apply formatting. Create components/editor/EditorToolbar.tsx:
import { Editor } from "@tiptap/react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
export function EditorToolbar({ editor }: EditorToolbarProps) {
if (!editor) return null;
// Helper to style active buttons
const getButtonClass = (isActive: boolean) =>
isActive ? "text-blue-500 bg-blue-100 dark:bg-blue-900" : "";
return (
<div className="flex flex-wrap items-center gap-1 mb-4 pb-4 border-b">
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBold().run()}
className={getButtonClass(editor.isActive("bold"))}>
<Bold className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={getButtonClass(editor.isActive("italic"))}>
<Italic className="w-4 h-4" />
</Button>
{/* ... other formatting buttons (strikethrough, code, etc.) ... */}
</div>
);
}
Each button calls a Tiptap command to apply formatting. For example, editor.chain().focus().toggleBold().run() applies bold formatting to selected text. The isActive() method checks if that formatting is currently applied, so we can highlight active buttons to show the user what formatting is already in use.
Creating Linear's Three-Column Layout
Linear uses a clean three-column structure. Here's the main layout components/layout/MainLayout.tsx:
import { Sidebar } from "./Sidebar";
import { MainContent } from "./MainContent";
import { RightSidebar } from "./RightSidebar";
export function MainLayout() {
return (
<div className="flex max-h-screen bg-gray-950">
<Sidebar />
<MainContent />
<RightSidebar />
</div>
);
}
Build the Collapsible Sidebar
Linear's sidebar collapses to maximize workspace. Create components/layout/Sidebar.tsx:
import { useState } from "react";
import { Button } from "@/components/ui/button";
export function Sidebar() {
const [expanded, setExpanded] = useState(false);
return (
<div
className={`${
expanded ? "w-64" : "w-20"
} bg-[#181A1B] border-r transition-all`}>
<div className="p-4 border-b">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<Zap className="w-5 h-5 text-white" />
</div>
{expanded && <span className="font-semibold">ProjectHub</span>}
</div>
</div>
{/* Navigation items... */}
<Button onClick={() => setExpanded(!expanded)}>
{expanded ? <ChevronLeft /> : <ChevronRight />}
</Button>
</div>
);
}
Build the Main Content Area
The main content stacks vertically: header, document, activity, and comments. Create components/layout/MainContent.tsx:
import { ProjectHeader } from "@/components/project/ProjectHeader";
import { DocumentSection } from "@/components/project/DocumentSection";
import { ActivitySection } from "@/components/project/ActivitySection";
import { CommentBox } from "@/components/project/CommentBox";
export function MainContent() {
return (
<div className="flex-1 flex flex-col min-w-0 max-h-screen overflow-y-auto bg-white dark:bg-[#101113]">
<ProjectHeader />
<div className="w-full lg:w-8/12 mx-auto flex-1 p-4 lg:p-6 space-y-8">
<DocumentSection />
<ActivitySection />
<CommentBox />
</div>
</div>
);
}
The content area takes up available space and scrolls vertically. The inner container centers the content and limits its max width for readability. It includes the project header, document editor, activity feed, and comment section stacked with spacing.
Build the Right Sidebar
The right sidebar shows task properties like status, priority, and assignee. Create components/layout/RightSidebar.tsx:
import { Button } from "@/components/ui/button";
export function RightSidebar() {
return (
<div className="w-80 bg-white dark:bg-[#101113] border-l border-gray-800 p-6 hidden sm:block max-h-screen overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-sm font-semibold">Properties</h3>
<div className="flex gap-2">
<Link className="w-4 h-4" />
<Workflow className="w-4 h-4" />
</div>
</div>
<div className="space-y-6">
{propertyButtons.map((btn, i) => (
<Button
key={i}
variant="ghost"
className="w-full justify-start gap-2">
<btn.icon className="w-4 h-4" />
{btn.label}
</Button>
))}
{/* Labels section ... */}
{/* Project section ... */}
</div>
</div>
);
}
The right sidebar is hidden on mobile (hidden sm:block) to maximize screen space. On larger screens, it shows alongside the main content.
Building the Collaborative Components
Create the Project Header with Velt Integration
This is where we connect to Velt, enabling Linear's real-time magic. The header initializes Velt and lets users switch between demo users to test collaboration. Create components/project/ProjectHeader.tsx:
This is the heart of Linear-style collaboration:
-
client.identify()tells Velt who the user is -
client.setDocuments()specifies what document they're working on - VeltPresence: Shows real-time avatars of who's online
- VeltNotificationsTool: Bell icon showing notifications when others comment or edit
- VeltCommentsSidebar: Panel showing all comments in the document
- VeltSidebarButton: Toggle for the comments sidebar
All these components automatically work together and update in real-time as users edit.
Document and Activity Sections
Linear shows document content and activity in one flow. Here's the structure (components/layout/MainContent.tsx):
export function MainContent() {
return (
<div className="flex-1 overflow-y-auto bg-[#101113]">
<ProjectHeader />
<div className="w-8/12 mx-auto p-6 space-y-8">
<DocumentSection />
<ActivitySection />
<CommentBox />
</div>
</div>
);
}
The DocumentSection contains your rich text editor (shown earlier). The ActivitySection displays a feed of changes—who created the project, who added it to which workspace, etc.
For complete implementations of these sections, refer to the repository.
Create the Comment Section
Linear's inline comments are context-rich and threaded. Here's how we replicate that components/project/CommentBox.tsx:
import { VeltInlineCommentsSection } from "@veltdev/react";
import useTheme from "@/hooks/useTheme";
export function CommentBox() {
const { theme } = useTheme();
return (
<div className="space-y-3">
<h3 className="text-lg font-semibold">Comments</h3>
<section id="container-id">
<VeltInlineCommentsSection
multiThread={true}
targetElementId="container-id"
shadowDom={false}
darkMode={theme === "dark"}
/>
</section>
</div>
);
}
The VeltInlineCommentsSection creates a dedicated comment area with threading—multiple discussions can happen independently, just like in Linear.
Testing Linear-Style Collaboration
Run your app:
npm run dev
Open http://localhost:3000 in two browser windows to test real-time collaboration:
- Switch users: Use the header dropdown to switch between "Nany" and "Mary"
- Edit simultaneously: Type in one window, watch it sync in the other
- Add comments: Select text, click comment, see it appear instantly for both users
- Check presence: Notice both user avatars showing who's online
- Test notifications: Comment in one window, see the notification bell update in the other
Two browser windows showing Linear-style real-time collaboration
The magic here is that Velt handles all the synchronization. Your editor just dispatches changes, and Velt ensures everyone sees the same content with conflict resolution built in.
How It All Works
Let's break down the flow:
-
User logs in (or switches via dropdown) →
ProjectHeadercallsclient.identify() - Velt knows the user → All subsequent actions are associated with that user
-
Document is set →
client.setDocuments()tells Velt what document we're editing - Users edit → Tiptap captures changes and sends them to Velt
- Changes sync → Velt syncs edits across all connected clients in real-time
- Comments are added → User selects text, clicks comment button, adds feedback
- Comments are stored → Velt stores comments and shows them to all users
-
Presence updates →
VeltPresencedisplays active users -
Notifications fire →
VeltNotificationsToolalerts users of changes
The key insight: You're not building any of this infrastructure. Velt provides it all. Your code just composes Velt's components and handles the UI.
Collaborative Features You Get Automatically
Once you integrate Velt, you get some collaborative features without extra code:
Reactions: Users can react to comments with emojis. Click any comment and select a reaction—it appears for everyone instantly.
Status Tracking: Mark comments as resolved, in progress, or pending. Team members see status changes immediately like Linear's comment workflow.
Read Receipts: See who's read which comments. No extra API calls needed.
@Mentions: Tag team members in comments. They get notifications and can follow up.
Edit History: Velt tracks who changed what and when. Useful for accountability and auditing.
These are all standard in Linear and now available in your app with zero additional development.
Velt comment component showing Linear-style reactions and status
Before You Ship to Production
A few things to keep in mind when taking this to production:
Replace the demo user system: Connect to your real authentication system. When users log in, call client.identify() with their actual data—just like Linear authenticates users.
Set up permissions: Use Velt's permission system to control who can edit, comment, or view documents mirroring Linear's access controls..
Associate documents with real data: Right now, we use a hardcoded document ID. In production, create a document ID for each project and pass it when initializing. Example:
const projectId = router.query.projectId;
await client.setDocuments([
{
id: `project-${projectId}`,
metadata: { projectName: "...", teamId: "..." },
},
]);
Handle errors gracefully: Add try-catch around Velt initialization. If Velt is unreachable, users should still be able to use the app (without collaboration features).
Customize the UI: Velt's components are customizable. You can style them to match your brand or hide features you don't need. Check Velt's documentation for customization options.
Monitor performance: Collaborative features can add latency if not optimized. Monitor real-time updates and test with multiple users editing simultaneously.
Demo Video
App link to try - https://linear-velt.vercel.app/
Conclusion
Linear's collaborative experience sets the standard for modern project management tools. Real-time editing, contextual comments, instant notifications, and seamless presence tracking make distributed teams productive.
But replicating Linear's collaboration layer from scratch is a massive engineering effort. Real-time synchronization, conflict resolution, comment threading, and presence tracking are complex distributed systems problems. Linear's team spent years building this infrastructure.
That's where Velt changes the equation. By providing Linear's core collaborative features as a ready-to-use SDK, you can build Linear-quality experiences in days instead of months. The project management dashboard you just built has:
- Real-time collaborative editing like Linear's editor
- Inline comments with threading like Linear's feedback system
- User presence indicators like Linear's workspace
- Instant notifications like Linear's alert system
And features like reactions, read receipts, @mentions, and edit history come automatically, so no extra code needed, just like they work in Linear.
Whether you're building a Linear alternative, an internal project management tool, or any collaborative app, Velt removes the infrastructure complexity. You focus on your unique features and workflow, while Velt handles the collaboration layer that makes Linear so powerful.
Ready to build your Linear-style app? Check out the complete repository for all code files, and start building with Velt today at velt.dev.




Top comments (0)