Build an AI-Powered Collaborative Editor

Build a real-time collaborative editor with an AI copilot using CRDTs.

Introduction

30 Second Summary

You have probably used Google Docs and watched someone else's cursor type in real time. Behind that seamless experience are three separate technologies working together: conflict-free data syncing, real-time networking, and intelligent writing assistance.

In this project, you will build your own collaborative rich text editor with a built-in AI writing assistant. Multiple users can type at the same time without conflicts, and the AI can read the document and write directly into it.

What You'll Build

Open your finished editor in two browser tabs and type in one. The other updates instantly, with zero conflicts. Then open the AI sidebar, ask it to generate content, and watch the AI write directly into your document for every connected user to see.

By the end of this project, you'll have:

  • A rich text editor built with TipTap and Next.js where you type, apply bold, italic, and heading formatting, and see your writing render in real time.
  • Real-time collaboration across browser tabs, powered by Yjs CRDTs and PartyKit WebSockets. Type in one tab and see it appear in the other instantly.
  • An AI writing assistant sidebar built with CopilotKit and the OpenAI API. Ask the AI to generate content and watch it insert text, headings, or entire paragraphs directly into your document.
  • Secret Mission: Add live cursor presence so you can see where other users are typing in real time.

Are there any prerequisites?

This project assumes you are comfortable with React and TypeScript basics. You will also need Node.js 20+ installed and an OpenAI API key.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters to you. You're about to combine three powerful technologies into one app, so taking a moment to commit to the goal will keep you focused through each step.

Set Up the Project

Building a collaborative editor that combines real-time syncing with AI requires several libraries working together. Getting the foundation right now means the rest of the project snaps together cleanly.

In this step, you will scaffold a Next.js project, install all the packages you need, and create a PartyKit server that handles real-time document syncing.

In this step, get ready to:
  • Scaffold a Next.js project and install all dependencies.
  • Create a PartyKit server for real-time document syncing.
  • Start both dev servers and verify they are running.
Scaffold Next.js and install dependencies

You need a terminal to run the setup commands. Cursor has a built-in terminal you can use for the entire project.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • Open the integrated terminal by selecting Terminal in the top menu bar, then clicking New Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your Next.js project by running this command:
npx create-next-app@latest collab-editor --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"

What does this command do?

The flags pre-answer the setup prompts so the project is created without interactive questions. You get TypeScript, Tailwind CSS, ESLint, the App Router, a src/ directory for your code, and the @/* import alias.

Any remaining options (like React Compiler or AGENTS.md) default to the recommended values automatically.

  • Wait for the scaffolding to finish. You will see a success message in the terminal once it completes.
  • Confirm the project folder was created by running:
ls

You should see collab-editor listed in the output.

  • Move into the project folder by running:
cd collab-editor
  • Install all the project packages by running this command:
npm install @tiptap/react @tiptap/starter-kit \
  @tiptap/extension-collaboration yjs y-partykit \
  partykit partysocket @copilotkit/react-core \
  @copilotkit/runtime zod

What are all these packages?

  • @tiptap/react, @tiptap/starter-kit, @tiptap/extension-collaboration provide the rich text editor with collaboration support.
  • yjs is the CRDT library that resolves editing conflicts automatically.
  • y-partykit, partykit, and partysocket handle real-time syncing over WebSockets.
  • @copilotkit/react-core and @copilotkit/runtime add the AI copilot sidebar.
  • zod defines parameter schemas for the AI copilot's frontend tools.

You should see output ending with a line like added X packages confirming the install succeeded.

Install failed or timed out?

  • Make sure you ran cd collab-editor before the install. Running npm install outside the project folder will fail.
  • Check your internet connection. All packages are fetched from the npm registry.
  • If you see permission errors on macOS, try prefixing the command with sudo.

Still stuck? Help me debug my npm install failure for the collab-editor project.

  • Open the project in Cursor by running:
cursor .

Already have the folder open?

If cursor . does not work, open the folder manually. Click File in the top menu, then Open Folder, and select the collab-editor folder on your Desktop.

Set up the PartyKit Yjs server

PartyKit needs a tiny server file that handles Yjs connections. This file is the entire server-side code needed for real-time document syncing.

  • In the Cursor Explorer sidebar on the left, right-click the collab-editor project root.
  • Click New Folder and name it party.
  • Right-click the new party folder and click New File.
  • Name the file server.ts.
  • Paste the following code into party/server.ts:
import type * as Party from "partykit/server";
import { onConnect } from "y-partykit";

export default class EditorServer implements Party.Server {
  constructor(public room: Party.Room) {}

  async onConnect(conn: Party.Connection) {
    return onConnect(conn, this.room);
  }
}

EditorServer satisfies Party.Worker;

What does this code do?

  • onConnect from y-partykit handles all the Yjs synchronization logic. It receives document updates from one client and broadcasts them to every other connected client.
  • EditorServer is a class that implements Party.Server. Each document gets its own room, and PartyKit manages the lifecycle for you.
  • The satisfies Party.Worker line is a TypeScript assertion that ensures the class matches the PartyKit worker interface.
  • Save party/server.ts.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

import type * as Party from "partykit/server";
import { onConnect } from "y-partykit";

export default class EditorServer implements Party.Server {
  constructor(public room: Party.Room) {}

  async onConnect(conn: Party.Connection) {
    return onConnect(conn, this.room);
  }
}

EditorServer satisfies Party.Worker;

PartyKit also needs a configuration file to know where your server code lives.

  • In the Explorer sidebar, right-click the collab-editor project root (the same level as package.json).
  • Click New File and name it partykit.json.
  • Paste the following configuration into partykit.json:
{
  "$schema": "https://www.partykit.io/schema.json",
  "name": "collab-editor",
  "main": "party/server.ts",
  "compatibilityDate": "2024-09-01"
}

What does this config do?

The main field tells PartyKit where to find your server code. The name field identifies your project when deploying later.

  • Save partykit.json.
  • Confirm both new files exist by running this in the terminal:
ls party/server.ts partykit.json

You should see both file paths listed in the output with no errors.

File not found?

  • Make sure partykit.json is in the project root (next to package.json), not inside the party folder.
  • Make sure server.ts is inside the party folder, not in the project root.

Need help? Help me check my file structure for the collab-editor PartyKit setup.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

{
  "$schema": "https://www.partykit.io/schema.json",
  "name": "collab-editor",
  "main": "party/server.ts",
  "compatibilityDate": "2024-09-01"
}
Configure environment and verify

The AI copilot needs your OpenAI API key to generate responses. You will store it in a .env file so the app can read it at runtime.

  • In the Explorer sidebar, right-click the collab-editor project root.
  • Click New File and name it .env.
  • Paste the following into .env:
OPENAI_API_KEY=your-api-key-here
  • Replace your-api-key-here with your actual OpenAI API key.
  • Save .env.

Keep your key safe

Never commit .env to version control. The .gitignore that create-next-app generated already excludes .env files by default.

Time to start both dev servers and confirm everything is wired up.

  • Start the Next.js dev server by running:
npm run dev

You should see output indicating the dev server is running at localhost:3000.

  • Open a second terminal. In Cursor, click Terminal in the top menu, then New Terminal.
  • Start the PartyKit dev server by running:
npx partykit dev

You should see PartyKit log that the server is running at port 1999.

Before you check the browser, what do you think you will see at localhost:3000?

  • Open your browser and visit localhost:3000.

You should see the default Next.js welcome page. This confirms your project is scaffolded correctly and the dev server is serving it.

Don't see the Next.js page?

  • Check that the terminal running npm run dev shows no errors. If it crashed, stop it with Ctrl+C and run npm run dev again.
  • Make sure your second terminal is in the collab-editor folder before running npx partykit dev. Run cd ~/Desktop/collab-editor if needed.
  • If port 3000 is already in use, Next.js will pick the next available port. Check the terminal output for the actual URL.

Still stuck? Help me troubleshoot my Next.js and PartyKit dev servers.

Your foundation is in place. Two servers are running and ready. Next up, you will build the rich text editor that every later feature builds on.

Build the Rich Text Editor

Your project is scaffolded and both servers are running. Before adding collaboration or AI, you need a working editor you can type into.

TipTap is a headless rich text editor built on ProseMirror. It provides a React hook called useEditor that makes setting up a full-featured editor straightforward.

In this step, get ready to:
  • Create a TipTap editor component with StarterKit.
  • Wire the editor into the page and see it in the browser.
  • Add a formatting toolbar with Bold, Italic, and Heading buttons.
Create the TipTap editor component

TipTap's StarterKit extension bundles the most common editing features: paragraphs, bold, italic, headings, lists, and more. You get a rich text editor with a single import.

  • In the Explorer sidebar in Cursor, right-click the src folder.
  • Select New File.
  • Type components/Editor.tsx and press Enter. Cursor creates the components folder automatically.
  • Paste the following code into Editor.tsx:
"use client";

import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";

export function Editor() {
  // Set up the TipTap editor with StarterKit for basic formatting
  const editor = useEditor({
    extensions: [StarterKit],
    editorProps: {
      attributes: {
        class:
          "prose prose-sm max-w-none focus:outline-none min-h-[400px] px-6 py-4",
      },
    },
  });

  // Wait for the editor to initialize before rendering
  if (!editor) return null;

  return (
    <div className="bg-white rounded-lg shadow-sm border border-gray-200">
      <EditorContent editor={editor} />
    </div>
  );
}

What does this code do?

  • "use client" tells Next.js this is a client component that runs in the browser. TipTap needs browser APIs to work.
  • useEditor creates a TipTap editor instance with StarterKit, which bundles paragraph, bold, italic, heading, and list extensions.
  • editorProps.attributes.class applies Tailwind classes to the editable area, giving it a minimum height and prose typography.
  • EditorContent renders the actual editable area where the user types.
  • Save Editor.tsx.
Wire the editor into the page

The component exists but isn't rendered anywhere yet. You need to import it into the main page so the browser can display it.

  • Open src/app/page.tsx in the Explorer sidebar.
  • Select all the existing code and replace it with the following:
"use client";

import { Editor } from "@/components/Editor";

export default function Home() {
  return (
    // Full-page layout with a header and centered editor
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white border-b border-gray-200 px-6 py-3">
        <h1 className="text-xl font-semibold text-gray-800">AI Collaborative Editor</h1>
      </header>
      <main className="max-w-4xl mx-auto py-8 px-4">
        <Editor />
      </main>
    </div>
  );
}

What does this code do?

  • "use client" marks the page as a client component so it can render the Editor.
  • The @/components/Editor import uses the path alias you set up during scaffolding.
  • The layout adds a header with the app title and centers the editor in a max-width container.
  • Save page.tsx.
  • Switch back to your browser at localhost:3000. You should see an "AI Collaborative Editor" header and a white editor area below it.
  • Click inside the editor area and type a few words to confirm it works.

Don't see the editor?

  • Make sure both page.tsx and Editor.tsx are saved. Unsaved files won't trigger a hot reload.
  • Check that Editor.tsx is at the path src/components/Editor.tsx. The import in page.tsx uses @/components/Editor, which maps to that exact path.
  • Confirm the Next.js dev server is still running in your terminal. If it stopped, restart it by running npm run dev.

Help me debug why my Editor component isn't showing up.

✔️ Awesome, I've got everything!

Great. Double check you've saved page.tsx.

ⓧ I'd like to double check the full code

"use client";

import { Editor } from "@/components/Editor";

export default function Home() {
  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white border-b border-gray-200 px-6 py-3">
        <h1 className="text-xl font-semibold text-gray-800">AI Collaborative Editor</h1>
      </header>
      <main className="max-w-4xl mx-auto py-8 px-4">
        <Editor />
      </main>
    </div>
  );
}
Add a formatting toolbar

The editor accepts text, but there is no way for the user to apply formatting. TipTap exposes chain commands that let you programmatically toggle formatting on the current selection.

  • In src/components/Editor.tsx, find this line inside the return statement:
      <EditorContent editor={editor} />
  • Add the following toolbar code directly above that line:
      {/* Formatting toolbar with active state highlighting */}
      <div className="flex gap-1 px-4 py-2 border-b border-gray-200 bg-gray-50 rounded-t-lg">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={`px-3 py-1 text-sm rounded ${
            editor.isActive("bold")
              ? "bg-gray-800 text-white"
              : "bg-white text-gray-700 hover:bg-gray-100"
          }`}
        >
          Bold
        </button>
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={`px-3 py-1 text-sm rounded ${
            editor.isActive("italic")
              ? "bg-gray-800 text-white"
              : "bg-white text-gray-700 hover:bg-gray-100"
          }`}
        >
          Italic
        </button>
        <button
          onClick={() =>
            editor.chain().focus().toggleHeading({ level: 2 }).run()
          }
          className={`px-3 py-1 text-sm rounded ${
            editor.isActive("heading", { level: 2 })
              ? "bg-gray-800 text-white"
              : "bg-white text-gray-700 hover:bg-gray-100"
          }`}
        >
          Heading
        </button>
      </div>

What does this code do?

  • Each button calls editor.chain().focus().toggleBold().run() (or the equivalent for italic/heading). The chain pattern focuses the editor, applies the formatting toggle, and runs it in one go.
  • editor.isActive("bold") checks whether the current selection has bold formatting applied. When it returns true, the button gets a dark background so the user can see which formats are active.
  • Save Editor.tsx.
  • Switch back to your browser. You should see a toolbar row with Bold, Italic, and Heading buttons above the editor area.

Before you test the buttons, think about this: what do you expect to happen when you select some text and click Bold?

  • Type a sentence in the editor.
  • Select a word and click Bold.
  • Select another word and click Italic.
  • Place your cursor on a line and click Heading.

The selected text turns bold or italic, and the toolbar button highlights to show the active format. The heading button converts the entire line into a larger heading.

Toolbar buttons not working?

  • Make sure the toolbar <div> is inside the outer <div> wrapper, directly above <EditorContent editor={editor} />. If it's outside the wrapper, the layout breaks.
  • Check for missing backticks or curly braces in the template literal classNames. Each button's className uses a template string with a ternary expression.

Help me fix my TipTap toolbar buttons.

✔️ Awesome, I've got everything!

Great. Double check you've saved Editor.tsx.

ⓧ I'd like to double check the full code

Your Editor.tsx should have these sections in order. Scroll up and confirm each one is present:

  • The "use client" directive at the top, followed by imports for useEditor, EditorContent, and StarterKit.
  • The useEditor hook with StarterKit in the extensions array and the editorProps Tailwind classes.
  • The if (!editor) return null guard.
  • The toolbar <div> with three buttons (Bold, Italic, Heading) directly above <EditorContent editor={editor} />.

Your editor is live. You can type, select text, and toggle formatting right in the browser. Next up, you'll connect it to Yjs and PartyKit so changes sync across browser tabs in real time.

Make It Collaborative with Yjs and PartyKit

Your editor works. You can type, apply bold and italic formatting, and toggle headings. Right now, though, it only works for one person. Open it in a second browser tab and you get two completely separate documents with no connection between them.

In this step, you will wire the editor to a shared Yjs document synced through your PartyKit server. Yjs is a CRDT library that merges edits from multiple users without conflicts, and PartyKit relays those edits over WebSockets.

In this step, get ready to:
  • Connect the editor to a shared Yjs document through PartyKit.
  • Test real-time sync across two browser tabs.
Connect the editor to a Yjs document via PartyKit

The editor currently stores its content in local memory. To make it collaborative, you need three pieces: a shared document (Y.Doc), a network provider that syncs it through your PartyKit server (YPartyKitProvider), and a TipTap extension that binds the editor to the shared document (Collaboration).

  • Switch back to src/components/Editor.tsx in Cursor.
  • Add these imports directly below the existing import StarterKit line:
import Collaboration from "@tiptap/extension-collaboration";
import * as Y from "yjs";
import YPartyKitProvider from "y-partykit/provider";
import { useMemo, useEffect } from "react";
  • Inside the Editor function, add this code above the const editor = useEditor(...) call:
// Create a shared Yjs document that holds the editor state
const ydoc = useMemo(() => new Y.Doc(), []);

// Connect to the PartyKit server for real-time sync
const provider = useMemo(
  () => new YPartyKitProvider("localhost:1999", "my-document", ydoc),
  [ydoc]
);

What does this code do?

  • Y.Doc is the shared document that Yjs uses to track every change. Instead of storing text in the browser's memory alone, all edits flow through this document.
  • YPartyKitProvider opens a WebSocket connection to your PartyKit server at localhost:1999 and joins a room called "my-document". Every client that joins the same room shares the same document.
  • Both are wrapped in useMemo so React creates them once and reuses the same instances across re-renders.
  • Save Editor.tsx.
  • Check the terminal where npx partykit dev is running. You should see a connection log, confirming the editor reached your PartyKit server.

No connection log in the PartyKit terminal?

  • Make sure npx partykit dev is still running in a separate terminal. If it stopped, restart it.
  • Confirm the import path is exactly y-partykit/provider (not y-partykit alone).

Help me debug my PartyKit connection in Editor.tsx

  • Still in Editor.tsx, find this line inside the useEditor call:
extensions: [StarterKit],
  • Replace it with the following:
extensions: [
  StarterKit.configure({
    // Disable built-in undo/redo since Collaboration provides its own
    history: false,
  }),
  Collaboration.configure({
    // Bind TipTap to the shared Yjs document
    document: ydoc,
  }),
],
  • Next, add this cleanup effect after the closing }); of the useEditor call and before the if (!editor) return null; line:
// Clean up the provider and document when the component unmounts
useEffect(() => {
  return () => {
    provider.destroy();
    ydoc.destroy();
  };
}, [provider, ydoc]);

What does this code do?

  • history: false disables StarterKit's built-in undo/redo. The Collaboration extension provides its own history that works across all connected clients.
  • Collaboration.configure({ document: ydoc }) tells TipTap to read and write to the shared Yjs document instead of its own internal state. Every keystroke now flows through Yjs.
  • The useEffect cleanup destroys the provider and document when the component unmounts, preventing memory leaks and stale WebSocket connections.
  • Save Editor.tsx and switch to your browser at localhost:3000.
  • The editor loads with the same toolbar as before. Type some text to confirm it still works.

Editor not loading or showing a blank page?

  • Open your browser's developer console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and check for error messages.
  • Make sure you replaced the entire extensions: [StarterKit], line. Leaving the old StarterKit alongside the new config causes duplicate extension errors.
  • Confirm that npx partykit dev is still running. The Collaboration extension needs the PartyKit server to initialise the document.

Help me debug my TipTap Collaboration setup

✔️ Awesome, I've got everything!

Great. Double check you have saved Editor.tsx.

ⓧ I'd like to double check the full code

Scroll up and confirm your Editor.tsx has each of these sections in order:

  • "use client"; at the very top.
  • Six import lines: @tiptap/react, @tiptap/starter-kit, @tiptap/extension-collaboration, yjs, y-partykit/provider, and react.
  • ydoc created with useMemo at the top of the Editor function.
  • provider created with useMemo, pointing at localhost:1999 with room "my-document".
  • useEditor with StarterKit.configure({ history: false }) and Collaboration.configure({ document: ydoc }).
  • useEffect cleanup that destroys provider and ydoc on unmount.
  • The toolbar buttons and EditorContent in the return JSX, unchanged from before.
Test real-time sync

Before you test, think about what will happen when you type in one browser tab. Will the other tab update instantly, or will there be a noticeable delay?

  • Open localhost:3000 in two browser tabs and place them side by side.
  • In one tab, type a sentence into the editor.

The text appears in the other tab within a fraction of a second. Try typing in both tabs at the same time. The CRDT resolves all conflicts automatically, with no manual merging required.

What are CRDTs?

CRDTs (Conflict-free Replicated Data Types) are data structures designed for distributed systems. Each client can edit independently, and when the changes meet on the server, they merge automatically without conflicts.

This is the same technology that powers collaborative editors like Google Docs. Your PartyKit server relays the Yjs updates between connected clients, and Yjs guarantees that every client converges to the same document state.

Why PartyKit instead of a custom WebSocket server?

You could build your own WebSocket server with Node.js and the y-websocket package. PartyKit removes that infrastructure friction. Your entire server is about 8 lines of code, and PartyKit handles connection management, room isolation, and reconnection logic for you.

Your editor now syncs in real time across browser tabs. Next up, you will add an AI copilot that can read this collaborative document and respond to questions about its content.

Add the AI Copilot with CopilotKit

Your editor syncs across browser tabs, but it has no intelligence. It can't summarize what you've written, generate new content, or answer questions about the document.

In this step, you will integrate CopilotKit to give the editor an AI sidebar that can read the document and respond to questions about it. This is where the project goes from "collaborative editor" to "AI-powered collaborative editor."

In this step, get ready to:
  • Set up the CopilotKit runtime API route.
  • Add the CopilotKit provider and AI sidebar to the app.
  • Make the AI aware of the document content.
Set up the CopilotKit runtime API route

CopilotKit's frontend sidebar needs a backend endpoint to communicate with OpenAI. Next.js API routes let you create this endpoint inside your app with a single file.

  • Create the file src/app/api/copilotkit/route.ts by running these commands:
mkdir -p src/app/api/copilotkit
touch src/app/api/copilotkit/route.ts
  • Confirm the file was created by running:
ls src/app/api/copilotkit/

You should see route.ts in the output.

  • Open src/app/api/copilotkit/route.ts in Cursor and paste the following code:
import {
  CopilotRuntime,
  copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { BuiltInAgent } from "@copilotkit/runtime/v2";
import { NextRequest } from "next/server";

// Create an AI agent that uses OpenAI's GPT-4o model
const builtInAgent = new BuiltInAgent({
  model: "openai:gpt-4o",
});

// Initialize the CopilotKit runtime with the agent
const runtime = new CopilotRuntime({
  agents: { default: builtInAgent },
});

// Handle POST requests from the CopilotKit frontend
export const POST = async (req: NextRequest) => {
  const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
    runtime,
    endpoint: "/api/copilotkit",
  });

  return handleRequest(req);
};

What does this code do?

This file creates a Next.js API route that CopilotKit's sidebar calls whenever you send a message.

  • BuiltInAgent configures a pre-built CopilotKit agent that uses the openai:gpt-4o model. CopilotKit reads your OPENAI_API_KEY from the environment automatically.
  • copilotRuntimeNextJSAppRouterEndpoint wires the runtime into Next.js App Router's route handler pattern, exporting a POST handler that the sidebar calls.
  • Save route.ts.

File not found?

  • Make sure you created the nested folders src/app/api/copilotkit/. The mkdir -p command creates all intermediate directories.
  • Confirm you are running the command from the collab-editor project folder.

Why can't Next.js find my API route?

✔️ Awesome, I've got everything!

Great. Make sure you've saved route.ts before moving on.

ⓧ I'd like to double check the full code

import {
  CopilotRuntime,
  copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { BuiltInAgent } from "@copilotkit/runtime/v2";
import { NextRequest } from "next/server";

const builtInAgent = new BuiltInAgent({
  model: "openai:gpt-4o",
});

const runtime = new CopilotRuntime({
  agents: { default: builtInAgent },
});

export const POST = async (req: NextRequest) => {
  const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
    runtime,
    endpoint: "/api/copilotkit",
  });

  return handleRequest(req);
};
Wrap the app with CopilotKit and add the sidebar

CopilotKit needs two things to work in your app: a provider that connects the frontend to the API route, and a UI component for the chat sidebar.

  • Open src/app/layout.tsx in Cursor.
  • Replace the entire contents of the file with the following code:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
// Import the CopilotKit provider and styles
import { CopilotKit } from "@copilotkit/react-core/v2";
import "@copilotkit/react-core/v2/styles.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "AI Collaborative Editor",
  description: "A real-time collaborative editor with an AI copilot",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        {/* Wrap the entire app with CopilotKit, pointing to the API route */}
        <CopilotKit runtimeUrl="/api/copilotkit">
          {children}
        </CopilotKit>
      </body>
    </html>
  );
}

What does this code do?

  • The CopilotKit provider wraps your entire app and connects the frontend to the /api/copilotkit route you just created.
  • The styles import (@copilotkit/react-core/v2/styles.css) loads the CSS for CopilotKit's sidebar UI.
  • Save layout.tsx.

✔️ Awesome, I've got everything!

Great. Double check you've saved layout.tsx before continuing.

ⓧ I'd like to double check the full code

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { CopilotKit } from "@copilotkit/react-core/v2";
import "@copilotkit/react-core/v2/styles.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "AI Collaborative Editor",
  description: "A real-time collaborative editor with an AI copilot",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <CopilotKit runtimeUrl="/api/copilotkit">
          {children}
        </CopilotKit>
      </body>
    </html>
  );
}

Now add the chat sidebar to the page.

  • Open src/app/page.tsx in Cursor.
  • Replace the entire contents of the file with the following code:
"use client";

import { Editor } from "@/components/Editor";
// Import the AI sidebar component
import { CopilotSidebar } from "@copilotkit/react-core/v2";

export default function Home() {
  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white border-b border-gray-200 px-6 py-3">
        <h1 className="text-xl font-semibold text-gray-800">
          AI Collaborative Editor
        </h1>
      </header>
      <main className="max-w-4xl mx-auto py-8 px-4">
        <Editor />
      </main>
      {/* Add the AI sidebar alongside the editor */}
      <CopilotSidebar
        labels={{
          modalHeaderTitle: "AI Writing Assistant",
          welcomeMessageText:
            "I can help you write! Try asking me to add content, summarize the document, or generate text on any topic.",
        }}
      />
    </div>
  );
}

What does CopilotSidebar do?

CopilotSidebar renders a collapsible chat panel on the right side of the page. The labels prop customizes the header title and welcome message the learner sees when they first open it.

  • Save page.tsx.
  • Return to your browser at localhost:3000. The page should auto-refresh.

You should see the AI sidebar toggle on the right side of the page. Click it to expand the chat panel.

Don't see the sidebar?

  • Make sure your npm run dev terminal is still running. If it crashed, restart it.
  • Check that layout.tsx imports @copilotkit/react-core/v2/styles.css. Without the styles import, the sidebar renders but is invisible.
  • Confirm page.tsx imports CopilotSidebar from @copilotkit/react-core/v2 and includes it in the JSX.

Why is my CopilotKit sidebar not showing up?

✔️ Awesome, I've got everything!

Confirm you've saved both layout.tsx and page.tsx.

ⓧ I'd like to double check the full code

"use client";

import { Editor } from "@/components/Editor";
import { CopilotSidebar } from "@copilotkit/react-core/v2";

export default function Home() {
  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white border-b border-gray-200 px-6 py-3">
        <h1 className="text-xl font-semibold text-gray-800">
          AI Collaborative Editor
        </h1>
      </header>
      <main className="max-w-4xl mx-auto py-8 px-4">
        <Editor />
      </main>
      <CopilotSidebar
        labels={{
          modalHeaderTitle: "AI Writing Assistant",
          welcomeMessageText:
            "I can help you write! Try asking me to add content, summarize the document, or generate text on any topic.",
        }}
      />
    </div>
  );
}
Make the AI aware of the document content

The sidebar can chat, but it has no idea what's in your document. You need to feed the editor's text content to the AI so it can answer questions about what you've written.

  • Open src/components/Editor.tsx in Cursor.
  • Find the import line that says:
import { useMemo, useEffect } from "react";
  • Add this new import directly below it:
import { useAgentContext } from "@copilotkit/react-core/v2";
  • Now scroll down to the useEditor block. Find the closing }); of the useEditor call.
  • Add the following code directly below that closing });:
  // Feed the current document text to the AI as read-only context
  useAgentContext({
    description: "The current contents of the collaborative document",
    value: editor?.getText() ?? "",
  });

What does useAgentContext do?

useAgentContext is a one-way channel from your UI to the AI. It publishes the editor's current text so the agent can read it on every turn.

The description acts like a label the AI sees alongside the value, so it knows what the data represents. Every time the editor content changes and React re-renders, the AI automatically receives the updated text.

  • Save Editor.tsx.

Seeing a TypeScript error?

  • Make sure the import path is exactly @copilotkit/react-core/v2. Using the v1 path (@copilotkit/react-core without /v2) will not find useAgentContext.
  • Confirm the useAgentContext call is placed inside the Editor function body, after useEditor and before useEffect.

Why is useAgentContext not found in my imports?

✔️ Awesome, I've got everything!

Confirm your Editor.tsx import section includes useAgentContext from @copilotkit/react-core/v2, and the hook call sits between the useEditor block and the useEffect cleanup block.

ⓧ I'd like to double check the full code

Your Editor.tsx is long, so confirm these key sections are in place, in order:

  • The imports include import { useAgentContext } from "@copilotkit/react-core/v2"; after the React imports.
  • The useAgentContext({...}) call appears directly after the useEditor({...}) block's closing });.
  • The useEffect cleanup and the rest of the component remain unchanged.

Time to test the AI. Type a sentence or two into the editor so the document is not empty.

Before you ask the AI anything, think about this: do you expect the AI to know what's in your document, or will it respond generically?

  • Open the AI sidebar by clicking the toggle on the right.
  • Type What is in my document? and send the message.

The AI responds with a summary of whatever you typed into the editor. It knows the document content because useAgentContext is feeding the text to it on every turn.

Your editor now has an AI copilot that understands the document in real time. Next up, you will give the AI the power to write directly into the editor.

Wire AI Actions to the Editor

Your AI copilot can read the document, but it is stuck in read-only mode. It can answer questions about your text but it cannot actually change anything in the editor.

In this step, you will register frontend tools that let the AI insert text, add headings, and replace content directly in the editor. The AI becomes a real-time collaborator that can write alongside you.

In this step, get ready to:
  • Register three frontend tools that give the AI the ability to manipulate the editor.
  • Test the AI inserting text, headings, and replacing document content.
  • Confirm that AI-generated content syncs across browser tabs in real time.
Register frontend tools for document manipulation

CopilotKit v2 uses a hook called useFrontendTool to register actions the AI can call. Each tool has a name, a description (so the AI knows when to use it), a Zod schema that defines its parameters, and a handler function that runs when the AI invokes it.

You will register three tools: one to insert text, one to insert headings, and one to replace the entire document.

  • In src/components/Editor.tsx, find this import line:
import { useAgentContext } from "@copilotkit/react-core/v2";
  • Replace that single import line with these three imports, which add useFrontendTool and z from Zod:
import { useFrontendTool } from "@copilotkit/react-core/v2";
import { useAgentContext } from "@copilotkit/react-core/v2";
import { z } from "zod";

Why separate import lines?

Both useFrontendTool and useAgentContext come from the same package. Keeping them on separate import lines matches the Complete Code and makes it easy to spot which hooks your component uses at a glance.

  • Now add the first frontend tool. In Editor.tsx, find the useAgentContext block. Copy the following code directly below it:
  useFrontendTool(
    {
      name: "insertText",
      description:
        "Insert text at the end of the document. Use this to add paragraphs, sentences, or any text content.",
      parameters: z.object({
        text: z.string().describe("The text content to insert"),
      }),
      handler: async ({ text }) => {
        if (!editor) return "Editor not ready";
        editor
          .chain()
          .focus()
          .insertContentAt(editor.state.doc.content.size - 1, {
            type: "paragraph",
            content: [{ type: "text", text }],
          })
          .run();
        return `Inserted text: "${text.substring(0, 50)}..."`;
      },
    },
    [editor]
  );

What does this tool do?

The insertText tool gives the AI the ability to add paragraphs to the document. The description field tells the AI when to call this tool.

The parameters field uses a Zod schema to define exactly what arguments the AI must provide. The handler function uses TipTap's chain commands to insert a new paragraph node at the end of the document.

  • Save Editor.tsx.
  • Return to your browser at localhost:3000.
  • Open the AI sidebar and type: Write a paragraph about collaborative editing. The AI should insert text directly into your editor.

AI not inserting text?

Make sure you saved Editor.tsx after adding the imports and the tool. Missing the z import from zod will cause a build error in the terminal.

Check that the useFrontendTool call is placed inside the Editor function body, after useAgentContext. If it is outside the function, the hook will not work.

help me debug why the AI is not inserting text into my TipTap editor

  • Add the second tool directly below the insertText tool you just added. Copy this code into Editor.tsx:
  useFrontendTool(
    {
      name: "insertHeading",
      description: "Insert a heading into the document.",
      parameters: z.object({
        text: z.string().describe("The heading text"),
        level: z
          .number()
          .min(1)
          .max(3)
          .describe("Heading level: 1, 2, or 3"),
      }),
      handler: async ({ text, level }) => {
        if (!editor) return "Editor not ready";
        editor
          .chain()
          .focus()
          .insertContentAt(editor.state.doc.content.size - 1, {
            type: "heading",
            attrs: { level },
            content: [{ type: "text", text }],
          })
          .run();
        return `Inserted heading level ${level}: "${text}"`;
      },
    },
    [editor]
  );

What does this tool do?

The insertHeading tool works like insertText but inserts a heading node instead of a paragraph. The Zod schema accepts a level parameter (1, 2, or 3) so the AI can choose the right heading size.

  • Save Editor.tsx.
  • In the AI sidebar, type: Add a heading that says Introduction. A heading should appear in the editor, styled larger than regular text.

Heading looks like regular text?

Make sure the insertHeading handler inserts a node with type: "heading" and an attrs: { level } property. If the type is set to "paragraph" by accident, TipTap renders it as body text.

help me figure out why my heading tool is not rendering headings

  • Add the third and final tool directly below the insertHeading tool. Copy this code into Editor.tsx:
  useFrontendTool(
    {
      name: "replaceDocument",
      description:
        "Replace the entire document content. Use this when asked to rewrite or clear the document.",
      parameters: z.object({
        content: z
          .string()
          .describe("The new content for the entire document"),
      }),
      handler: async ({ content }) => {
        if (!editor) return "Editor not ready";
        editor.commands.setContent(`<p>${content}</p>`);
        return "Document replaced with new content";
      },
    },
    [editor]
  );

What does this tool do?

The replaceDocument tool wipes the entire editor and replaces it with new content. Instead of chaining insert commands, it uses editor.commands.setContent() to overwrite everything at once.

This is useful when the user asks the AI to rewrite the whole document or start fresh.

  • Save Editor.tsx.
  • In the AI sidebar, type: Replace the document with a short note about today's weather. The editor content should be completely replaced with the AI-generated text.

Document not being replaced?

Check that you are using editor.commands.setContent() in the replaceDocument handler, not insertContentAt. The setContent method overwrites the document, while insertContentAt appends to it.

help me debug why replaceDocument is not replacing the editor content

✔️ Awesome, I've got everything!

Great. Double check you have saved Editor.tsx with all three tools and the updated imports.

ⓧ I'd like to double check the full code

Your Editor.tsx file is now quite long. Scroll through and confirm these sections are present in this order:

  • Imports: useFrontendTool, useAgentContext, and z from zod.
  • The useAgentContext block (unchanged from the previous step).
  • insertText tool using useFrontendTool with a z.object({ text: z.string() }) schema.
  • insertHeading tool with text and level parameters.
  • replaceDocument tool using editor.commands.setContent().
  • The useEffect cleanup and the return JSX (unchanged from the previous step).
Test the AI writing into the collaborative document

All three tools are registered. Time to test whether AI-generated content syncs across tabs, just like content you type by hand.

  • Open localhost:3000 in two browser tabs, placed side by side.
  • Type a few words into the editor in one tab so both tabs have some content.

Before you send the next prompt, think about what you expect to happen. Will the AI-generated content appear in just the tab with the sidebar, or in both tabs?

  • In one tab, open the AI sidebar and type: Add a heading that says 'Meeting Notes' and write a paragraph about project planning.

The AI inserts a heading and a paragraph directly into the editor. Check the second tab. The AI-generated content appears there too, synced in real time through Yjs and PartyKit. The AI is now a real-time collaborator.

Why does AI content sync automatically?

The AI tools use the same TipTap editor commands you would use when typing. TipTap writes into the Yjs document, and Yjs automatically broadcasts those changes through the PartyKit WebSocket connection to every connected client.

From the CRDT's perspective, there is no difference between a human typing and the AI inserting content. Both are just edits to the shared document.

Your collaborative editor now has full AI superpowers. The copilot reads the document, writes into it, and every change syncs instantly to all connected clients. Next up, you will take on a secret mission to add live cursor presence.

Secret mission

Add Cursor Presence

Your editor syncs text beautifully, but you cannot see where other users are typing. In a real collaborative editor like Google Docs, colored cursors show each person's position in real time. Add live cursor presence to complete the collaborative editing experience.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine. No cloud resources were created, so there are no ongoing costs.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The only cost from this project is the OpenAI API usage from interacting with the AI sidebar, which is typically under $0.05.

Resources you used:

  • The collab-editor project folder on your Desktop.
  • The Next.js dev server running at localhost:3000.
  • The PartyKit dev server running at localhost:1999.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the editor and AI sidebar.

  • Leave both terminal windows open with the Next.js and PartyKit dev servers running.
  • Continue using the AI sidebar to generate content. Each interaction makes a small OpenAI API call.

Pause - I'll come back to this later

Shut down the running dev servers to free up your terminal. Your project files stay on disk so you can pick up where you left off.

  • Switch to the terminal running the Next.js dev server and press Ctrl+C to stop it.
  • Switch to the terminal running the PartyKit dev server and press Ctrl+C to stop it.
  • When you are ready to come back, open two terminals, navigate to the collab-editor folder, and restart with:
npm run dev

In the second terminal:

npx partykit dev

Delete - I don't want to use this again

Remove all project files from your machine. No cloud resources need to be deleted.

  • Switch to the terminal running the Next.js dev server and press Ctrl+C to stop it.
  • Switch to the terminal running the PartyKit dev server and press Ctrl+C to stop it.
  • Delete the project folder by running this command on macOS/Linux:
rm -rf ~/Desktop/collab-editor
  • On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\collab-editor
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see collab-editor in the output.

Nice Work!

Nice Work!

Nice work! You just built an AI-powered collaborative editor from scratch, combining real-time document syncing with an intelligent writing assistant.

You've learned how to:

  • Build a TipTap rich text editor with formatting controls for bold, italic, and headings.
  • Make the editor collaborative using Yjs CRDTs and PartyKit WebSockets, with real-time sync across browser tabs.
  • Integrate CopilotKit with AI frontend tools that let the copilot read the document and insert text, headings, and generated content directly into the editor.
  • Secret Mission: Add live cursor presence so users can see where each other is typing in real time.

Ready to quiz yourself?