Build an App Connections Dashboard

Markdown

View source on GitHub

Build a Chat App handles authentication in-chat. That works for getting started, but production apps need a dedicated page where users manage their connections. This cookbook builds a dashboard where users can see all available apps, connect via OAuth, and disconnect at any time.

What you'll build

  • A connections dashboard showing all available apps and their auth status
  • Connect and disconnect buttons that handle OAuth flows

Prerequisites

Stack: Next.js, @composio/core

Create the project

bunx create-next-app composio-dashboard --yes
cd composio-dashboard
bun add @composio/core

Add your API key to a .env.local file:

.env.local
COMPOSIO_API_KEY=your_composio_api_key

Setting up the client

Create app/api/connections/route.ts. Initialize the Composio client and set dynamic = "force-dynamic" so Next.js always fetches fresh connection data.

app/api/connections/route.ts
import { Composio } from "@composio/core";

const composio = new Composio();

export const dynamic = "force-dynamic";

"user_123" is a placeholder. In production, replace it with the authenticated user's ID from your auth system. See Users & Sessions for details.

List connections

The GET handler creates a session and returns every toolkit's name, logo, and connection status. Toolkits where isNoAuth is true don't require authentication, so we filter them out.

app/api/connections/route.ts
export async function GET() {
  const session = await composio.create("user_123");
  const { items } = await session.toolkits({ limit: 50 });

  return Response.json({
    // Filter out toolkits that don't require authentication
    toolkits: items
      .filter((t) => !t.isNoAuth)
      .map((t) => ({
        slug: t.slug,
        name: t.name,
        logo: t.logo,
        isConnected: t.connection?.isActive ?? false,
        connectedAccountId: t.connection?.connectedAccount?.id,
      })),
  });
}

session.toolkits() returns each toolkit with a connection object. When connection.isActive is true, the user has already authorized that app. We also return the connectedAccountId so the frontend can disconnect later.

session.toolkits() paginates results. This example fetches up to 50. Use the nextCursor value from the response to fetch the next page.

Connect an app

The POST handler starts an OAuth flow for a given toolkit and returns the redirect URL.

app/api/connections/route.ts
export async function POST(req: Request) {
  const { toolkit }: { toolkit: string } = await req.json();
  const origin = new URL(req.url).origin;
  const session = await composio.create("user_123");
  const connectionRequest = await session.authorize(toolkit, {
    callbackUrl: origin,
  });

  return Response.json({ redirectUrl: connectionRequest.redirectUrl });
}

session.authorize(toolkit) creates a connection request with a callbackUrl. After the user completes OAuth, they get redirected back to your app.

Disconnect an app

Create app/api/connections/disconnect/route.ts. This endpoint takes a connectedAccountId and deletes it:

app/api/connections/disconnect/route.ts
import { Composio } from "@composio/core";

const composio = new Composio();

export async function POST(req: Request) {
  const { connectedAccountId }: { connectedAccountId: string } =
    await req.json();
  await composio.connectedAccounts.delete(connectedAccountId);
  return Response.json({ success: true });
}

composio.connectedAccounts.delete() is a standalone SDK method. No session or provider needed.

Build the dashboard

Replace app/page.tsx with a dashboard that shows connection cards:

app/page.tsx
"use client";

import { useEffect, useState } from "react";

type Toolkit = {
  slug: string;
  name: string;
  logo?: string;
  isConnected: boolean;
  connectedAccountId?: string;
};

export default function Dashboard() {
  const [toolkits, setToolkits] = useState<Toolkit[]>([]);
  async function fetchConnections() {
    const res = await fetch("/api/connections", { cache: "no-store" });
    const data = await res.json();
    setToolkits(data.toolkits);
  }

  useEffect(() => {
    fetchConnections();
  }, []);

  async function connect(slug: string) {
    const res = await fetch("/api/connections", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ toolkit: slug }),
    });
    const { redirectUrl } = await res.json();
    window.location.href = redirectUrl;
  }

  async function disconnect(connectedAccountId: string) {
    await fetch("/api/connections/disconnect", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ connectedAccountId }),
    });
    fetchConnections();
  }

  return (
    <main className="max-w-4xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-2">App Connections</h1>
      <p className="text-gray-500 mb-6">
        Connect your apps to give your agent access.
      </p>

      <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
        {toolkits.map((t) => (
          <div
            key={t.slug}
            className="flex items-center justify-between p-4 border rounded-lg"
          >
            <div className="flex items-center gap-3">
              {t.logo && (
                <img src={t.logo} alt={t.name} className="w-8 h-8 rounded" />
              )}
              <div>
                <p className="font-medium">{t.name}</p>
                <p
                  className={`text-xs ${
                    t.isConnected ? "text-green-600" : "text-gray-400"
                  }`}
                >
                  {t.isConnected ? "Connected" : "Not connected"}
                </p>
              </div>
            </div>
            {t.isConnected ? (
              <button
                onClick={() => disconnect(t.connectedAccountId!)}
                className="px-3 py-1.5 text-sm border rounded hover:bg-gray-50"
              >
                Disconnect
              </button>
            ) : (
              <button
                onClick={() => connect(t.slug)}
                className="px-3 py-1.5 text-sm bg-black text-white rounded hover:bg-gray-800"
              >
                Connect
              </button>
            )}
          </div>
        ))}
      </div>
    </main>
  );
}

The page fetches toolkit connection status on load. Clicking Connect redirects the user to OAuth. After they authorize, the callbackUrl brings them back to the dashboard with the connection now active. Clicking Disconnect deletes the connection and refreshes the list.

Complete route

The full app/api/connections/route.ts with both handlers:

app/api/connections/route.ts
// region setup
import { Composio } from "@composio/core";

const composio = new Composio();

export const dynamic = "force-dynamic";
// endregion setup

// region list
export async function GET() {
  const session = await composio.create("user_123");
  const { items } = await session.toolkits({ limit: 50 });

  return Response.json({
    // Filter out toolkits that don't require authentication
    toolkits: items
      .filter((t) => !t.isNoAuth)
      .map((t) => ({
        slug: t.slug,
        name: t.name,
        logo: t.logo,
        isConnected: t.connection?.isActive ?? false,
        connectedAccountId: t.connection?.connectedAccount?.id,
      })),
  });
}
// endregion list

// region connect
export async function POST(req: Request) {
  const { toolkit }: { toolkit: string } = await req.json();
  const origin = new URL(req.url).origin;
  const session = await composio.create("user_123");
  const connectionRequest = await session.authorize(toolkit, {
    callbackUrl: origin,
  });

  return Response.json({ redirectUrl: connectionRequest.redirectUrl });
}
// endregion connect

Running the app

bun dev

Open localhost:3000. You'll see all available apps with their connection status.

  1. Click Connect on GitHub. You'll be redirected to GitHub's OAuth page.
  2. Authorize the app. You'll land back on the dashboard with GitHub showing "Connected."
  3. Click Disconnect on GitHub. The connection is removed immediately.

Take it further