writing/tutorial/2026/05
TutorialMay 6, 2026·30 min read

Build In-App AI Copilots with CopilotKit and Next.js 15

Learn how to embed a production-ready AI copilot directly into your Next.js 15 app using CopilotKit. We cover the runtime route, the CopilotKit provider, useCopilotReadable for shared context, useCopilotAction for frontend tools, and shipping a polished CopilotSidebar that actually changes your app state.

Most AI tutorials in 2026 build backend agents — Claude, LangGraph, Mastra, Pydantic AI. Useful, but they all stop at a chat endpoint. The next problem is harder: how do you embed an AI assistant inside your product so it can read the same state your user sees and call the same actions your buttons call?

That is exactly what CopilotKit is for. It is an open-source React framework that puts a copilot next to your UI, hands it a typed view of your app state, and lets it trigger functions you already wrote. By the end of this tutorial you will have a working Next.js 15 app where a sidebar copilot can read a task list, add tasks, mark them done, and render rich UI cards inside the chat — all without leaving your codebase.

Why CopilotKit instead of a raw chat widget? A chat widget answers questions. A copilot operates the app. CopilotKit gives you typed React hooks (useCopilotReadable, useCopilotAction) so the LLM has structured context and a structured tool surface — which is the difference between "looks like AI" and "feels like a teammate".

What You Will Learn

By the end of this tutorial, you will be able to:

  • Install CopilotKit packages and wire up the <CopilotKit> provider in a Next.js 15 App Router project
  • Build the /api/copilotkit runtime route with the OpenAI adapter
  • Share React state with the LLM using useCopilotReadable
  • Define typed frontend actions with useCopilotAction so the copilot can mutate UI state
  • Render generative UI inside the chat (custom cards, not just text)
  • Ship the CopilotSidebar UI and theme it to match your brand

Prerequisites

Before starting, ensure you have:

  • Node.js 20 or later (Node 18 will not work — see Troubleshooting)
  • Next.js 15 familiarity (App Router, Server Components vs Client Components)
  • An OpenAI API key (or an Anthropic / Groq / Azure key — CopilotKit supports several adapters)
  • Basic knowledge of React hooks and TypeScript

What You Will Build

We are building a Smart Task Manager — a Next.js 15 page with a tasks list and a CopilotKit sidebar. The user can talk to the copilot to add tasks, mark tasks as done, filter by status, and ask for a summary of what is left for the day. The copilot will render task cards directly in the chat instead of plain text.

Step 1: Project Setup

Spin up a fresh Next.js 15 project with TypeScript and Tailwind:

npx create-next-app@latest copilot-tasks \
  --typescript --tailwind --app --eslint --src-dir
cd copilot-tasks

Now install the three CopilotKit packages we need. The frontend needs react-core and react-ui, and the API route needs runtime:

npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime
npm install openai

Add your OpenAI key to .env.local:

OPENAI_API_KEY=sk-proj-...

Tip: Never commit .env.local. Confirm .gitignore already excludes it (Next.js scaffolds this by default).

Step 2: Build the Runtime API Route

CopilotKit ships a runtime that bridges your React app and the LLM provider. In the App Router, that is one route handler at app/api/copilotkit/route.ts:

// src/app/api/copilotkit/route.ts
import {
  CopilotRuntime,
  OpenAIAdapter,
  copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import OpenAI from "openai";
import { NextRequest } from "next/server";
 
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const serviceAdapter = new OpenAIAdapter({ openai });
const runtime = new CopilotRuntime();
 
export const POST = async (req: NextRequest) => {
  const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
    runtime,
    serviceAdapter,
    endpoint: "/api/copilotkit",
  });
 
  return handleRequest(req);
};

Three things to notice:

  1. The OpenAIAdapter is one of several — swap it for AnthropicAdapter, GroqAdapter, or LangChainAdapter without touching any other file.
  2. CopilotRuntime is the brain. We will pass server-side actions to it later.
  3. copilotRuntimeNextJSAppRouterEndpoint returns a handleRequest function that already speaks the App Router contract — no manual streaming wiring.

Step 3: Mount the CopilotKit Provider

The <CopilotKit> provider needs to wrap any tree that uses copilot hooks. Put it in your root layout, but mark the wrapper as a client component:

// src/components/CopilotProvider.tsx
"use client";
 
import { CopilotKit } from "@copilotkit/react-core";
import "@copilotkit/react-ui/styles.css";
 
export function CopilotProvider({ children }: { children: React.ReactNode }) {
  return (
    <CopilotKit runtimeUrl="/api/copilotkit">
      {children}
    </CopilotKit>
  );
}

Then plug it into the root layout (which stays a Server Component):

// src/app/layout.tsx
import { CopilotProvider } from "@/components/CopilotProvider";
import "./globals.css";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <CopilotProvider>{children}</CopilotProvider>
      </body>
    </html>
  );
}

The runtimeUrl is the public path of the route we built in Step 2. Anything inside <CopilotProvider> can now use useCopilotReadable and useCopilotAction.

Step 4: Build the Task List UI

Before we add any AI, we build a normal React state-driven task list. The copilot will plug into this exact state — no rewrite required.

// src/app/page.tsx
"use client";
 
import { useState } from "react";
import { CopilotSidebar } from "@copilotkit/react-ui";
 
type Task = {
  id: string;
  title: string;
  status: "todo" | "done";
};
 
export default function HomePage() {
  const [tasks, setTasks] = useState<Task[]>([
    { id: "1", title: "Write tutorial draft", status: "todo" },
    { id: "2", title: "Review PR #42", status: "todo" },
  ]);
 
  return (
    <main className="mx-auto max-w-3xl p-8">
      <h1 className="mb-6 text-3xl font-semibold">Smart Tasks</h1>
 
      <ul className="space-y-2">
        {tasks.map((task) => (
          <li
            key={task.id}
            className="flex items-center justify-between rounded border p-3"
          >
            <span
              className={
                task.status === "done"
                  ? "text-gray-400 line-through"
                  : "text-gray-900"
              }
            >
              {task.title}
            </span>
            <span className="text-xs uppercase text-gray-500">
              {task.status}
            </span>
          </li>
        ))}
      </ul>
 
      <CopilotSidebar
        labels={{
          title: "Task Copilot",
          initial: "Hi! I can add, complete, or summarize your tasks.",
        }}
      />
    </main>
  );
}

Run npm run dev, open http://localhost:3000, and you should see the list and a chat sidebar on the right. The copilot will respond — but it does not know about your tasks yet. We fix that next.

Step 5: Share State with useCopilotReadable

useCopilotReadable injects any React value into the copilot's context window every turn. Add it next to your useState:

import { useCopilotReadable } from "@copilotkit/react-core";
 
// inside HomePage()
useCopilotReadable({
  description: "The user's current task list, including title and status",
  value: tasks,
});

That is all the AI plumbing required to give the copilot read access. Reload the page and ask: "How many tasks do I have left?" — it will answer correctly because tasks is now part of every prompt.

Why describe the value? The description field is what the LLM sees in its system prompt. Vague descriptions like "data" produce vague answers. Concrete descriptions like "the user's current task list, including title and status" produce precise tool calls.

Step 6: Define Frontend Actions with useCopilotAction

Reading state is half the job. Now we let the copilot mutate state. useCopilotAction is a typed function the LLM can call directly from the chat. The framework handles the JSON schema, the tool routing, and the streaming response.

Add an addTask action:

import { useCopilotAction } from "@copilotkit/react-core";
 
useCopilotAction({
  name: "addTask",
  description: "Add a new task to the user's task list",
  parameters: [
    {
      name: "title",
      type: "string",
      description: "The title of the new task",
      required: true,
    },
  ],
  handler: async ({ title }) => {
    const newTask: Task = {
      id: crypto.randomUUID(),
      title,
      status: "todo",
    };
    setTasks((prev) => [...prev, newTask]);
    return `Added task: ${title}`;
  },
});

Now ask the copilot: "Add a task to deploy the staging branch by Friday." You will see the chat acknowledge the action, and the new task appears in the list instantly. No page reload, no API roundtrip — the action runs inside the React tree.

Add a second action to mark tasks as done:

useCopilotAction({
  name: "markTaskDone",
  description: "Mark a task as completed by its title",
  parameters: [
    {
      name: "title",
      type: "string",
      description: "The title (or partial title) of the task to complete",
      required: true,
    },
  ],
  handler: async ({ title }) => {
    let matched: Task | undefined;
    setTasks((prev) =>
      prev.map((t) => {
        if (
          t.status === "todo" &&
          t.title.toLowerCase().includes(title.toLowerCase())
        ) {
          matched = t;
          return { ...t, status: "done" };
        }
        return t;
      }),
    );
    return matched
      ? `Marked done: ${matched.title}`
      : `No matching task found for "${title}"`;
  },
});

Try: "Mark the PR review as done." The copilot will pick the right task even when you do not give the exact title — the useCopilotReadable from Step 5 is what makes that fuzzy matching possible.

Step 7: Generative UI — Render Cards in the Chat

Plain text responses are fine for confirmations, but the copilot can also render arbitrary React inside the chat. Pass a render function to your action and the chat bubble becomes whatever you return.

useCopilotAction({
  name: "showTaskSummary",
  description: "Show a visual summary of pending vs completed tasks",
  parameters: [],
  handler: async () => "summary rendered",
  render: () => {
    const todo = tasks.filter((t) => t.status === "todo").length;
    const done = tasks.filter((t) => t.status === "done").length;
    return (
      <div className="rounded-lg border bg-white p-4 shadow-sm">
        <div className="mb-2 text-sm font-semibold text-gray-700">
          Task Summary
        </div>
        <div className="flex gap-4 text-sm">
          <span className="text-blue-600">Pending: {todo}</span>
          <span className="text-green-600">Done: {done}</span>
        </div>
      </div>
    );
  },
});

Ask: "Show me a summary." Instead of a paragraph, you get a styled card embedded in the chat. This pattern scales — you can render charts with Recharts, maps, file diffs, or even interactive forms.

Step 8: Polish and Theme the Sidebar

CopilotKit sidebar styles are CSS variables, so you can match your brand without forking the component. Drop these into globals.css:

:root {
  --copilot-kit-primary-color: #0ea5e9;
  --copilot-kit-contrast-color: #ffffff;
  --copilot-kit-background-color: #ffffff;
  --copilot-kit-secondary-color: #f4f4f5;
  --copilot-kit-secondary-contrast-color: #18181b;
}

You can also pass instructions to the sidebar so the copilot has a system-level personality:

<CopilotSidebar
  instructions={`You are a productivity copilot. Keep replies under 3 sentences. Always confirm destructive actions before performing them.`}
  labels={{ title: "Task Copilot" }}
/>

Testing Your Implementation

Walk through these prompts against your dev server. All four should now work end to end:

  1. "What is on my list?" — answered from useCopilotReadable
  2. "Add a task to renew the SSL cert." — calls addTask, the list grows
  3. "Mark the SSL one as done." — calls markTaskDone with fuzzy matching
  4. "Show me a summary." — renders the generative UI card

If any of these fail, open the Network tab. Every copilot turn hits POST /api/copilotkit. The response is a streamed JSON body — if it 500s, your OPENAI_API_KEY is missing or the model name is wrong.

Troubleshooting

The sidebar shows but messages do nothing. Your runtimeUrl does not match the route. Confirm runtimeUrl="/api/copilotkit" matches the file path app/api/copilotkit/route.ts.

Module not found: @copilotkit/react-ui/styles.css. You forgot to import the stylesheet in CopilotProvider.tsx. Without it, the sidebar renders unstyled.

The action fires but state never updates. You probably defined the action outside the component that owns the state. useCopilotAction must run inside the same component tree as the useState it mutates.

Build error: Unexpected identifier 'assert'. You are on Node 18. Switch to Node 20 with nvm use 20.

Next Steps

You now have a real copilot that reads and writes app state. Natural extensions:

  • Server-side actions: Pass actions: [...] to CopilotRuntime to expose database mutations the LLM can call without going through the browser.
  • Multi-agent flows: Combine CopilotKit with LangGraph for stateful, branching agents that hold the sidebar over multiple turns.
  • Streaming markdown: Replace plain handlers with streamed responses so long answers appear word-by-word.
  • Auth + per-user context: Read the session in your route handler and inject user-scoped readables (workspace ID, role) into the runtime.

For broader AI patterns, the Claude Agent SDK tutorial covers the backend agent angle, and the Vercel AI SDK agentic RAG tutorial covers retrieval-augmented copilots.

Conclusion

CopilotKit closes the gap between "chatbot stapled to a website" and "AI that operates the product". Three primitives carry the whole experience: the provider, useCopilotReadable for shared state, and useCopilotAction for typed tools. The rest — the sidebar, generative UI, model adapters — is polish.

Ship one copilot per product surface, keep readables specific, keep actions narrow, and your users will start treating the chat as a faster keyboard rather than a help desk.