Build an AI chatbot SaaS with Next.js and the Whop infrastructure. This 6-part tutorial walks you through building a multi-bot chat platform with tiered subscriptions, access gating, a custom bot builder, and more.

Build this with AI

Open the tutorial prompt in your favorite AI coding tool:

You can build an AI chatbot SaaS in a single day with Next.js and Whop's infrastructure. While this might sound hard due to user authentication, payments services, and complex backend processes, it's much easier than you think thanks to a combination of services like Whop, Vercel, and Neon.

In this tutorial, we're going to walk you through building a complete AI chatbot SaaS with real payment processing via Whop Payments Network, admin access based on whop teams, AI chat powered by Anthropic's Claude and OpenAI's ChatGPT, tiered subscriptions, and a custom bot builder.

You can preview the finished product demo here.

Project overview

Before we start building the project, let's take a look at the features of our project:

  • Multi-bot chat where users can pick from a roster of specialized chatbots including raw models Claude and ChatGPT
  • User authentication with Whop OAuth for signing in
  • Tiered subscriptions with dynamic plans where admins create paid plans at any price point through an admin page. Each plan is provisioned on Whop automatically, and bots can be gated behind any plan
  • Price-based access gating - Free users chat with free bots (20 messages/day, 10 conversations). Paid users unlock bots at or below their plan's price, with higher daily limits
  • Custom bot builder on plans with allowCustomBots. Subscribers can create private bots with custom system prompts and plain-text knowledge bases
  • Webhook-driven billing via Whop. It handles checkout, invoicing, and payment methods. Membership changes arrive as webhook events, keeping our database in sync without polling

Tech stack

  • Next.js 16 - Server Components for auth-gated pages, API routes for the chat endpoint, and Vercel deployment in one framework.
  • Whop OAuth - Sign-in, token exchange, and identity. No registration forms or password resets.
  • Whop Payments Network - Checkout links, subscription plans, and membership webhooks. The owner collects revenue directly.
  • Neon - Serverless Postgres with connection pooling via PgBouncer. No local Postgres installation needed.
  • Prisma - Type-safe database queries with a declarative schema. Generates a client into the source tree for full type inference.
  • Anthropic Claude + OpenAI GPT + Vercel AI SDK - Multiple LLM providers behind a unified streaming protocol that handles SSE, abort signals, and client-side hooks
  • Zod - Runtime validation at system boundaries: environment variables, API inputs, and form data.
  • iron-session - Encrypted cookie-based sessions. No session store, no Redis, fully stateless.
  • Vercel - Hosting with vercel.ts for type-safe deployment configuration.

Pages

  • / - Redirects to /chat
  • /chat - New chat - bot selector dropdown, conversation sidebar, streaming chat
  • /chat/[conversationId] - Resume an existing conversation
  • /admin/bots - Admin - create and manage system bots, assign plans
  • /admin/plans - Admin - create and manage paid plans via Whop API
  • /bots/new - Create a custom bot (plans with allowCustomBots only)
  • /bots/[botId]/edit - Edit a custom bot's prompt and knowledge

Payment flow

  1. Admin creates a plan in the admin page
  2. User clicks "Upgrade" in the app
  3. App redirects to Whop's hosted checkout page for the selected plan
  4. User completes payment on Whop
  5. Whop fires a membership.activated webhook to our /api/webhooks/whop endpoint
  6. App looks up the plan by Whop product ID and creates or updates the Membership record
  7. Renewals, cancellations, and payment failures arrive as subsequent webhook events

Whop handles checkout, billing, invoicing, and all payment methods and we don't have to worry about payment forms, or the billing infrastructure.

Why Whop

There are three main functionalities we need to solve in a chatbot project like this: an AI to provide chatting experience to the users (which we'll use Anthropic and OpenAI for), a payments system, and user authentication. We will solve the payment and authentication with:

  • Whop Payments Network lets us charge customers directly with simple integrations
  • Whop OAuth provides a simple and secure user authentication so users can sign into our app and we don't have to deal with credential storage

Prerequisites

Before starting to work on the project, you should have:

  • A sandbox.whop.com account (for Whop's infrastructure)
  • A Vercel account (for deployment)
  • An Anthropic account (provides Claude API access)
  • An OpenAI account (for GPT API access)

Part 1: Scaffold, deploy, and authenticate

In this part, we're going to scaffold the Next.js project, deploy it to Vercel, set up a database using Neon, and implement Whop OAuth so users can sign in. Then, we're going to build on our deployment.

Create the project

First, go to the directory you want to create your project in and use the command below to scaffold a new Next.js app (we'll call the app "Chatforge," feel free to customize it):

BASH
npx create-next-app@latest chatforge --typescript --tailwind --eslint --app --src-dir --use-npm

The list we install via the command includes packages we won't use until Parts 3-5 (like the AI SDK, Anthropic provider, and OpenAI provider), but having them present from the start means the package.json is almost final from day one:

BASH
npm install @whop/sdk @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/react ai @prisma/client @prisma/adapter-pg zod iron-session lucide-react
npm install -D prisma dotenv

Deploy the project

Now that we have the scaffold, let's upload our project to a GitHub repository and connect it to Vercel. Once the Vercel deployment is done, you're going to get a production URL - we're going to use it to set up Whop OAuth. Add the URL as an environment variable to the Vercel project:

.env
NEXT_PUBLIC_APP_URL=https://your-app.vercel.app

Neon through Vercel integration

We're going to skip the local database installation entirely and use the Neon integration from Vercel's integration marketplace.

This will automatically populate the DATABASE_URL and DATABASE_URL_UNPOOLED as environment variables.

Set up your Whop app

The first thing we're going to do for our project is to set up the user authentication. One thing you should note is that we're going to use Whop Sandbox (sandbox.whop.com) so we can test our project without moving real money.
To create the app and get the secrets you need:

  1. Go to sandbox.whop.com and create a whop
  2. Go to the Developer page of your whop (bottom left) and click the Create app button under the Apps section
  3. Give your app a name and click Create. In the app details page, get the secrets:
    • App ID from App details tab - WHOP_APP_ID
    • API Key from App details tab - WHOP_API_KEY
    • Client ID from OAuth tab - WHOP_CLIENT_ID
    • Client Secret from OAuth tab - WHOP_CLIENT_SECRET
  4. Copy your Company ID (which starts with biz_) in the URL of your whop dashboard (this will be WHOP_COMPANY_ID)
  5. Go to the OAuth tab of your app and add these links as redirect URIs:
    1. http://localhost:3000/api/auth/callback
    2. https://your-app.vercel.app/api/auth/callback

Configure environment variables

Every environment variable in this tutorial follows the same pattern: add it to Vercel first, then pull locally. Vercel is the source of truth. .env.local is a local cache.
We need one more variable before pulling. Generate a session encryption key and take note of it, we'll use it in a second:

BASH
openssl rand -base64 32

Now that we have the keys we need, let's configure the environment variables of the project in Vercel. Go to the Settings page of your Vercel project and select Environment Variables. There, click the Add Environment Variable button and add these secrets:

VariableSourceDescription
DATABASE_URLNeon via VercelPooled connection
DATABASE_URL_UNPOOLEDNeon via VercelDirect connection, used by Prisma CLI
WHOP_APP_IDWhop dashboardStarts with app_
WHOP_API_KEYWhop dashboardStarts with apik_
WHOP_COMPANY_IDWhop dashboardStarts with biz_
WHOP_CLIENT_IDWhop dashboardOAuth client identifier
WHOP_CLIENT_SECRETWhop dashboardOAuth client secret
SESSION_SECRETGeneratedAt least 32 characters for iron-session encryption
NEXT_PUBLIC_APP_URLSet manuallyProduction URL on Vercel, http://localhost:3000 locally
WHOP_SANDBOXSet manuallytrue during development

Now, let's link your local project to Vercel and pull the variables you just added using the commands below:

BASH
vercel link
vercel env pull .env.local

vercel link connects your local directory to the Vercel project you deployed earlier. Select "Link to existing project" and choose the project.

After pulling, open .env.local and add the variables that aren't stored in Vercel. Add the line below to your environment file. Keep in mind that this line should be removed when switching from sandbox to production:

.env.local
WHOP_SANDBOX=true

Also change the value of NEXT_PUBLIC_APP_URL to http://localhost:3000 for local development. Find the line that was pulled from Vercel and change it to:

.env.local
NEXT_PUBLIC_APP_URL=http://localhost:3000

The production NEXT_PUBLIC_APP_URL on Vercel stays as your Vercel URL. Locally, we override it so OAuth redirects come back to the development server.

Environment variable validation

When an environment variable isn't set up correctly, it will cause problems that might be a bit hard to track. To prevent this, we'll set up a validation system with Zod so we can get clear error messages.
Go to src/lib and create a file called env.ts with the content:

env.ts
import { z } from "zod";

const envSchema = z.object({
  WHOP_APP_ID: z.string().startsWith("app_"),
  WHOP_API_KEY: z.string().startsWith("apik_"),
  WHOP_COMPANY_ID: z.string().startsWith("biz_"),
  WHOP_CLIENT_ID: z.string().min(1),
  WHOP_CLIENT_SECRET: z.string().min(1),

  DATABASE_URL: z.string().url(),
  DATABASE_URL_UNPOOLED: z.string().url(),

  SESSION_SECRET: z.string().min(32),

  NEXT_PUBLIC_APP_URL: z.string().url(),

  WHOP_SANDBOX: z.string().optional(),
});

let _env: z.infer<typeof envSchema> | null = null;

export function getEnv() {
  if (!_env) {
    _env = envSchema.parse(process.env);
  }
  return _env;
}

export const env = new Proxy({} as z.infer<typeof envSchema>, {
  get(_target, prop: string) {
    return getEnv()[prop as keyof z.infer<typeof envSchema>];
  },
});

Prisma setup

We start with a minimal User model, just enough to store authenticated users after OAuth. We'll expand the schema in Part 2. Go to the prisma folder and update the schema.prisma file to:

schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model User {
  id          String   @id @default(cuid())
  whopUserId  String   @unique
  email       String?
  name        String?
  avatarUrl   String?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

Then, go back to the project root and update the prisma.config.ts file to:

prisma.config.ts
import { config } from "dotenv";
config({ path: ".env.local" });

import { defineConfig } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: process.env["DATABASE_URL_UNPOOLED"],
  },
});

Now, let's push the schema to create the User table:

BASH
npx prisma db push

Now, let's create a Prisma client singleton, a shared instance so that every file in the app reuses the same database connection instead of opening a new one. Go to src/lib and create a file called prisma.ts with the content:

prisma.ts
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    adapter,
    log: process.env.NODE_ENV === "development" ? ["query"] : [],
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

Whop OAuth

Instead of building our own login system from scratch, we will offer our users the option to log in with Whop. While Whop handles identity verification, our project will receive the verified user profile information.

Session configuration

Sessions track who has logged in and who has not, and we will choose to store session data in a secure browser cookie using iron-session rather than storing it in our database. The cookie will interact with every request and will be able to authenticate the user without making any database queries.
To do this, go to src/lib and create a file called session.ts with the content:

session.ts
import { getIronSession, SessionOptions } from "iron-session";
import { cookies } from "next/headers";

export interface SessionData {
  userId?: string;
  whopUserId?: string;
  accessToken?: string;
  codeVerifier?: string;
  oauthState?: string;
}

const sessionOptions: SessionOptions = {
  password: process.env.SESSION_SECRET!,
  cookieName: "chatforge_session",
  cookieOptions: {
    secure: process.env.NODE_ENV === "production",
    httpOnly: true,
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 7, // 7 days
  },
};

export async function getSession() {
  const cookieStore = await cookies();
  return getIronSession<SessionData>(cookieStore, sessionOptions);
}

Whop SDK and OAuth configuration

The Whop SDK client and OAuth configuration both need to be sandbox-aware. When WHOP_SANDBOX=true is in the environment variables, OAuth redirects go to sandbox.whop.com and API calls go to sandbox-api.whop.com. In production, they route to whop.com and api.whop.com.

We request three OAuth scopes, the minimum needed for sign-in:

  • openid - Returns a unique user identifier
  • profile - Grants access to name and avatar
  • email - Grants access to the user's email address

Go to src/lib and create a file called whop.ts with the content:

whop.ts
import Whop from "@whop/sdk";

let _whop: Whop | null = null;
export function getWhop(): Whop {
  if (!_whop) {
    _whop = new Whop({
      appID: process.env.WHOP_APP_ID!,
      apiKey: process.env.WHOP_API_KEY!,
      ...(process.env.WHOP_SANDBOX === "true" && {
        baseURL: "https://sandbox-api.whop.com/api/v1",
      }),
    });
  }
  return _whop;
}

const isSandbox = () => process.env.WHOP_SANDBOX === "true";
const whopApiDomain = () =>
  isSandbox() ? "sandbox-api.whop.com" : "api.whop.com";

export const WHOP_OAUTH = {
  get authorizationUrl() {
    return `https://${whopApiDomain()}/oauth/authorize`;
  },
  get tokenUrl() {
    return `https://${whopApiDomain()}/oauth/token`;
  },
  get userInfoUrl() {
    return `https://${whopApiDomain()}/oauth/userinfo`;
  },
  get clientId() {
    return process.env.WHOP_CLIENT_ID!;
  },
  get clientSecret() {
    return process.env.WHOP_CLIENT_SECRET!;
  },
  scopes: ["openid", "profile", "email"],
  get redirectUri() {
    return `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback`;
  },
};

export async function generatePKCE() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  const verifier = base64UrlEncode(array);

  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest("SHA-256", data);
  const challenge = base64UrlEncode(new Uint8Array(digest));

  return { verifier, challenge };
}

function base64UrlEncode(buffer: Uint8Array): string {
  let binary = "";
  for (const byte of buffer) {
    binary += String.fromCharCode(byte);
  }
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

Rate limiting

The login and callback routes of our project are public, meaning that everyone on the internet can access them. If we don't implement rate limiting, malicious systems can spam requests and overwhelm the server. We'll add a simple rate limited that tracks requests from IPs and block them temporarily if they request too fast.

Go to src/lib and create a file called rate-limit.ts with the content:
Callout: This rate limiter stores counts in memory, which means it resets whenever the server restarts and doesn't share state across multiple server instances.

For a production app with heavy traffic, consider a Redis-backed solution like @upstash/ratelimit. For our use case, this is more than enough.

rate-limit.ts
import { NextResponse } from "next/server";

interface RateLimitConfig {
  interval: number;
  maxRequests: number;
}

const rateLimitMap = new Map<string, { count: number; lastReset: number }>();

export function rateLimit(
  key: string,
  config: RateLimitConfig = { interval: 60_000, maxRequests: 30 }
): NextResponse | null {
  const now = Date.now();
  const entry = rateLimitMap.get(key);

  if (!entry || now - entry.lastReset > config.interval) {
    rateLimitMap.set(key, { count: 1, lastReset: now });
    return null;
  }

  if (entry.count >= config.maxRequests) {
    return NextResponse.json(
      { error: "Too many requests. Please try again later." },
      {
        status: 429,
        headers: {
          "Retry-After": String(
            Math.ceil((config.interval - (now - entry.lastReset)) / 1000)
          ),
        },
      }
    );
  }

  entry.count++;
  return null;
}

if (typeof globalThis !== "undefined") {
  const CLEANUP_INTERVAL = 5 * 60 * 1000;
  setInterval(() => {
    const now = Date.now();
    for (const [key, entry] of rateLimitMap.entries()) {
      if (now - entry.lastReset > 10 * 60 * 1000) {
        rateLimitMap.delete(key);
      }
    }
  }, CLEANUP_INTERVAL).unref?.();
}

Login route

When a user clicks the "Sign in" button, our route will generate security tokens, store them in the session, and redirect the user to Whop's login page. When Whop sends the user back to our site, we will check that the tokens have not been altered and are legitimate.
Go to src/app/api/auth/login and create a file called route.ts with the content:

route.ts
import { NextResponse, NextRequest } from "next/server";
import { getSession } from "@/lib/session";
import { WHOP_OAUTH, generatePKCE } from "@/lib/whop";
import { rateLimit } from "@/lib/rate-limit";
import { headers } from "next/headers";

export async function GET(request: NextRequest) {
  const headersList = await headers();
  const ip = headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";

  const limited = rateLimit(`auth:login:${ip}`, {
    interval: 60_000,
    maxRequests: 10,
  });
  if (limited) return limited;

  const { verifier, challenge } = await generatePKCE();

  const nonceArray = new Uint8Array(16);
  crypto.getRandomValues(nonceArray);
  const nonce = Array.from(nonceArray, (b) => b.toString(16).padStart(2, "0")).join("");

  const stateArray = new Uint8Array(16);
  crypto.getRandomValues(stateArray);
  const state = Array.from(stateArray, (b) => b.toString(16).padStart(2, "0")).join("");

  const session = await getSession();
  session.codeVerifier = verifier;
  session.oauthState = state;
  await session.save();

  const params = new URLSearchParams({
    client_id: WHOP_OAUTH.clientId,
    redirect_uri: WHOP_OAUTH.redirectUri,
    response_type: "code",
    scope: WHOP_OAUTH.scopes.join(" "),
    code_challenge: challenge,
    code_challenge_method: "S256",
    state,
    nonce,
  });

  return NextResponse.redirect(
    `${WHOP_OAUTH.authorizationUrl}?${params.toString()}`
  );
}

The callback route

Once users have logged in to our site via Whop, they will be redirected back to us with a temporary code. This process is necessary to retrieve the user's profile, save it to the database and start their session. If the login was unsuccessful, we send them back to the sign-in.
Go to src/app/api/auth/callback and create a file called route.ts with the content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/session";
import { WHOP_OAUTH } from "@/lib/whop";
import { prisma } from "@/lib/prisma";

export async function GET(request: NextRequest) {
  const session = await getSession();

  const codeVerifier = session.codeVerifier;
  const savedState = session.oauthState;
  delete session.codeVerifier;
  delete session.oauthState;
  await session.save();

  try {
    const code = request.nextUrl.searchParams.get("code");
    const state = request.nextUrl.searchParams.get("state");

    if (!code) {
      return NextResponse.redirect(new URL("/sign-in?error=missing_code", request.url));
    }

    if (!codeVerifier) {
      return NextResponse.redirect(new URL("/sign-in?error=missing_verifier", request.url));
    }

    if (!savedState || savedState !== state) {
      return NextResponse.redirect(new URL("/sign-in?error=invalid_state", request.url));
    }

    const tokenResponse = await fetch(WHOP_OAUTH.tokenUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        grant_type: "authorization_code",
        code,
        redirect_uri: WHOP_OAUTH.redirectUri,
        client_id: WHOP_OAUTH.clientId,
        client_secret: WHOP_OAUTH.clientSecret,
        code_verifier: codeVerifier,
      }),
    });

    if (!tokenResponse.ok) {
      console.error("Token exchange failed:", tokenResponse.status);
      return NextResponse.redirect(new URL("/sign-in?error=token_exchange", request.url));
    }

    const tokenData = await tokenResponse.json();
    const accessToken: string = tokenData.access_token;

    const userInfoResponse = await fetch(WHOP_OAUTH.userInfoUrl, {
      headers: { Authorization: `Bearer ${accessToken}` },
    });

    if (!userInfoResponse.ok) {
      console.error("User info fetch failed:", userInfoResponse.status);
      return NextResponse.redirect(new URL("/sign-in?error=userinfo", request.url));
    }

    const userInfo = await userInfoResponse.json();

    const avatarUrl =
      typeof userInfo.picture === "string" && userInfo.picture.startsWith("https://")
        ? userInfo.picture
        : null;
    const name =
      typeof userInfo.name === "string" ? userInfo.name.slice(0, 100) : null;

    const user = await prisma.user.upsert({
      where: { whopUserId: userInfo.sub },
      update: {
        email: userInfo.email ?? null,
        name,
        avatarUrl,
      },
      create: {
        whopUserId: userInfo.sub,
        email: userInfo.email ?? null,
        name,
        avatarUrl,
      },
    });

    session.userId = user.id;
    session.whopUserId = user.whopUserId;
    session.accessToken = accessToken;
    await session.save();

    return NextResponse.redirect(new URL("/", request.url));
  } catch (error) {
    console.error("OAuth callback error:", error);
    return NextResponse.redirect(new URL("/sign-in?error=unknown", request.url));
  }
}

Logout route

To create the logout route so your users can quickly and securely log out, go to src/app/api/auth/logout and create a file called route.ts with the content:

route.ts
import { NextResponse, NextRequest } from "next/server";
import { getSession } from "@/lib/session";

export async function POST(request: NextRequest) {
  const session = await getSession();
  session.destroy();
  return NextResponse.redirect(new URL("/sign-in", request.url), 303);
}

Auth helper

All server components and API routes in the project must authenticate the current user. To do this, we will use a single requireAuth function. Go to src/lib and create a file called auth.ts with the content:

auth.ts
import { redirect } from "next/navigation";
import { getSession } from "./session";
import { prisma } from "./prisma";

export async function requireAuth(
  options?: { redirect?: boolean }
): Promise<{
  id: string;
  whopUserId: string;
  email: string | null;
  name: string | null;
  avatarUrl: string | null;
} | null> {
  const session = await getSession();

  if (!session.userId) {
    if (options?.redirect === false) return null;
    redirect("/sign-in");
  }

  const user = await prisma.user.findUnique({
    where: { id: session.userId },
  });

  if (!user) {
    session.destroy();
    if (options?.redirect === false) return null;
    redirect("/sign-in");
  }

  return user;
}

export async function isAuthenticated(): Promise<boolean> {
  const session = await getSession();
  return !!session.userId;
}

Then, go to src and create a file called middleware.ts with the content:

middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const publicPaths = ["/sign-in", "/api/auth/"];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  if (publicPaths.some((p) => pathname.startsWith(p))) {
    return NextResponse.next();
  }

  const session = request.cookies.get("chatforge_session");
  if (!session) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Building the UI

The database, session, OAuth, and route backend is complete. Now, it's time to build the pages that tie them all together: a sign-in page and a home page.

Sign-in page

For the sign-in page, we're going to build a very simple centered card with the app name, tagline, and a "Sign in with Whop" button. If an authenticated user tries to visit this page, they will be redirected to the home page.

Go to src/app/sign-in and create a file called page.tsx with the content:

page.tsx
import { redirect } from "next/navigation";
import { isAuthenticated } from "@/lib/auth";
import { LogIn } from "lucide-react";

export default async function SignInPage() {
  if (await isAuthenticated()) {
    redirect("/");
  }

  return (
    <div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950">
      <div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white p-8 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
        <div className="mb-8 text-center">
          <h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
            ChatForge
          </h1>
          <p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
            AI-powered chatbots, tailored to your needs
          </p>
        </div>

        <a
          href="/api/auth/login"
          className="flex w-full items-center justify-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
        >
          <LogIn className="h-4 w-4" />
          Sign in with Whop
        </a>
      </div>
    </div>
  );
}

Home page

Now, we need a temporary home page so we can test the authentication works. This page will display the user's name and avatar after they sign-in. It will be replaced with the real chat interface in Part 3.

Go to src/app and create a file called page.tsx with the content:

page.tsx
import { requireAuth } from "@/lib/auth";
import { LogOut } from "lucide-react";

export default async function HomePage() {
  const user = await requireAuth();
  if (!user) return null;

  return (
    <div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950">
      <div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white p-8 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
        <div className="mb-6 text-center">
          {user.avatarUrl && (
            <img
              src={user.avatarUrl}
              alt={user.name ?? "Avatar"}
              className="mx-auto mb-4 h-16 w-16 rounded-full"
            />
          )}
          <h1 className="text-xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
            Welcome, {user.name ?? "there"}
          </h1>
          <p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
            You&apos;re signed in to ChatForge
          </p>
        </div>

        <form action="/api/auth/logout" method="post">
          <button
            type="submit"
            className="flex w-full items-center justify-center gap-2 rounded-lg border border-zinc-200 px-4 py-2.5 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
          >
            <LogOut className="h-4 w-4" />
            Sign out
          </button>
        </form>
      </div>
    </div>
  );
}

App layout and metadata

All pages in Next.js projects have default titles and descriptions that show up in the browser tabs and search results. To customize them, go to src/app and update the layout.tsx file:

layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "ChatForge",
  description: "Multi-bot AI chat platform with tiered subscriptions",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}

Vercel configuration

Before we deploy our project, we need to give some instructions to Vercel. To do this, create a file called vercel.ts at the project root with the content:

vercel.ts
const config = {
  framework: "nextjs" as const,
  buildCommand: "prisma generate && next build",
  regions: ["iad1"],
  headers: [
    {
      source: "/(.*)",
      headers: [
        {
          key: "X-Content-Type-Options",
          value: "nosniff",
        },
        {
          key: "X-Frame-Options",
          value: "DENY",
        },
        {
          key: "Referrer-Policy",
          value: "strict-origin-when-cross-origin",
        },
        {
          key: "Content-Security-Policy",
          value:
            "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' https://*.whop.com https://ui-avatars.com data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'",
        },
      ],
    },
  ],
};

export default config;

We also need to tell Next.js to allow loading images from Whop's servers (for user avatars). Update next.config.ts:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "*.whop.com",
      },
    ],
  },
};

export default nextConfig;

Checkpoint: auth works

We're done with authentication. So, let's test locally first, then deploy to production.

Start the dev server by using the command below:

BASH
npm run dev

Walk through the full authentication flow:

  1. Visit http://localhost:3000 - you should be redirected to /sign-in
  2. Click "Sign in with Whop" - you should land on Whop's OAuth authorization page on sandbox.whop.com
  3. After authorizing, you arrive back at the home page and see a welcome message with your name
  4. Check the Neon console - a row exists in the User table with your Whop user ID
  5. Open your browser's developer tools - a chatforge_session cookie is set
  6. Click "Sign out" - your session is cleared and you're redirected to the sign-in page

If any step fails, check the terminal output. The most common issues are a mismatched redirect URI in your Whop app settings (it must exactly match NEXT_PUBLIC_APP_URL + /api/auth/callback, which is http://localhost:3000/api/auth/callback locally) or a missing SESSION_SECRET in .env.local.

Once the flow works locally, push to GitHub and redeploy on Vercel. Verify the same flow on your production URL - this time OAuth redirects through the production redirect URI you registered in step 2.

In Part 2, we'll build out the complete data model and seed the platform with its first bots.

Part 2: Data models and pre-built bots

Part 1 ended with a working authentication system and a single database table. Every logged-in user has a row in User, but there's no concept of subscriptions, bots, or conversations. In this part, we expand the schema from one model to seven, then build an admin page where we can create and manage the platform's bots.

The data model

ChatForge needs seven models:

  • User - Already exists from Part 1. We add relationship fields so Prisma can navigate from a user to their membership, conversations, and custom bots.
  • Plan - A paid subscription tier with a name, monthly price, and a link to its Whop product. Admins create plans through the admin page (Part 4), which provisions them on Whop automatically.
  • Membership - Connects a user to their plan. One user has at most one membership. Users with no membership (or a cancelled one) are free-tier by default.
  • Bot - A chatbot with a name, system prompt, optional knowledge base, and a model field specifying which LLM to use. Model bots provide raw access to Claude or GPT. System bots add curated personalities on top. User bots are created by subscribers whose plan allows custom bots (Part 5).
  • Conversation - A chat thread between one user and one bot.
  • Message - A single message in a conversation, either from the user or the assistant.
  • WebhookEvent - Records processed Whop webhook event IDs for idempotency.

Update prisma/schema.prisma:

schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model User {
  id          String   @id @default(cuid())
  whopUserId  String   @unique
  email       String?
  name        String?
  avatarUrl   String?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  membership    Membership?
  bots          Bot[]
  conversations Conversation[]
}

model Plan {
  id              String   @id @default(cuid())
  name            String
  price           Int      // cents per month
  whopProductId   String   @unique
  whopPlanId      String   @unique
  checkoutUrl     String
  allowCustomBots Boolean  @default(false)
  isActive        Boolean  @default(true)
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  bots        Bot[]
  memberships Membership[]
}

enum MembershipStatus {
  ACTIVE
  CANCELLED
  PAST_DUE
}

model Membership {
  id                 String           @id @default(cuid())
  userId             String           @unique
  user               User             @relation(fields: [userId], references: [id], onDelete: Cascade)
  planId             String?
  plan               Plan?            @relation(fields: [planId], references: [id])
  whopMembershipId   String?          @unique
  status             MembershipStatus @default(ACTIVE)
  periodStart        DateTime?
  periodEnd          DateTime?
  lastWebhookEventId String?
  createdAt          DateTime         @default(now())
  updatedAt          DateTime         @updatedAt
}

enum BotType {
  SYSTEM
  USER
  MODEL
}

model Bot {
  id           String   @id @default(cuid())
  name         String
  description  String
  avatarUrl    String?
  systemPrompt String
  knowledge    String?
  model        String?
  type         BotType  @default(SYSTEM)
  planId       String?
  plan         Plan?    @relation(fields: [planId], references: [id])
  createdById  String?
  createdBy    User?    @relation(fields: [createdById], references: [id], onDelete: Cascade)
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  conversations Conversation[]
}

model Conversation {
  id        String   @id @default(cuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  botId     String
  bot       Bot      @relation(fields: [botId], references: [id], onDelete: Cascade)
  title     String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  messages Message[]
}

enum Role {
  USER
  ASSISTANT
}

model Message {
  id             String       @id @default(cuid())
  conversationId String
  conversation   Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
  role           Role
  content        String
  tokenCount     Int          @default(0)
  createdAt      DateTime     @default(now())
}

model WebhookEvent {
  id        String   @id
  createdAt DateTime @default(now())
}

A few things to note about this schema:

  • No membership = free user. When someone signs up, they don't get a Membership record. We treat the absence of one as the free tier. When they upgrade through Whop, a webhook creates the Membership linking them to the plan they purchased.
  • Bots have three types. Model bots are raw LLM access points - users talk directly to Claude or ChatGPT with no custom personality. System bots add curated system prompts on top. User bots (Part 5) are custom-built by subscribers whose plan allows it - these are private and only visible to their creator.
  • Bot access is based on plans. A bot with no planId is free for everyone. A bot with a planId requires that plan (or a higher-priced one) to access.
  • WebhookEvent prevents duplicate processing. When Whop sends a webhook, we record its ID. If the same event arrives again, we skip it.

Callout: We ship with Claude Haiku 4.5 and GPT-4o-mini, but adding a new provider is one line in the model registry we'll build in Part 3.

Push the schema

Apply the expanded schema to the database:

BASH
npx prisma db push

This creates six new tables in the database. Prisma also regenerates the client, so we can start using all seven models in our code right away.

Building the bot catalog

A real platform needs a way for the owner to create and manage bots - not just a hardcoded seed script. We'll build an admin page where you define each bot's name, personality, and tier availability. The bots you create here become the catalog your customers choose from when they chat.

Admin access

We don't want regular users creating or deleting bots - that's only for the platform owner and their team. The Whop SDK has a checkAccess method that tells us what role a user has in relation to our company.

If they're a team member (owner, operations, support, etc.), the API returns "admin" as their access level. Regular customers get "customer", and everyone else gets "no_access". We only let users with "admin" access manage bots.

This means admin access is managed entirely through the Whop dashboard - add someone to your company's team and they can manage bots. Remove them and they lose access immediately, no code changes needed.

Before this will work, we need to install the app on our Whop company. Open the direct install link - replace the app ID with your own:

https://sandbox.whop.com/apps/APP_ID/install

Select your company, approve the permissions, and confirm. This is a one-time setup step that also enables webhook events in Part 4.

Go to src/lib and create a file called admin.ts with the content:

admin.ts
import { getSession } from "./session";
import { getWhop } from "./whop";

export async function isAdmin(): Promise<boolean> {
  const session = await getSession();
  if (!session.whopUserId) return false;

  try {
    const whop = getWhop();
    const access = await whop.users.checkAccess(
      process.env.WHOP_COMPANY_ID!,
      { id: session.whopUserId }
    );
    return access.access_level === "admin";
  } catch {
    return false;
  }
}

Managing bots

Server actions handle bot creation and deletion. Each action verifies admin access before making any changes and redirects back to the admin page afterward, which clears the form and refreshes the list.

Go to src/app/admin/bots and update the actions.ts file with the content:

actions.ts
"use server";

import { redirect } from "next/navigation";
import { isAdmin } from "@/lib/admin";
import { prisma } from "@/lib/prisma";

export async function createBot(formData: FormData) {
  if (!(await isAdmin())) throw new Error("Unauthorized");

  const name = (formData.get("name") as string)?.trim();
  const description = (formData.get("description") as string)?.trim();
  const systemPrompt = (formData.get("systemPrompt") as string)?.trim();
  const knowledge = (formData.get("knowledge") as string)?.trim() || null;
  const model = (formData.get("model") as string)?.trim() || null;

  if (!name || !description || !systemPrompt) {
    throw new Error("Name, description, and system prompt are required.");
  }

  await prisma.bot.create({
    data: { name, description, systemPrompt, knowledge, model, type: "SYSTEM" },
  });

  redirect("/admin/bots");
}

export async function deleteBot(formData: FormData) {
  if (!(await isAdmin())) throw new Error("Unauthorized");

  const botId = formData.get("botId") as string;
  if (!botId) throw new Error("Bot ID is required.");

  await prisma.bot.delete({ where: { id: botId } });
  redirect("/admin/bots");
}

export async function updateBotPrompt(formData: FormData) {
  if (!(await isAdmin())) throw new Error("Unauthorized");

  const botId = formData.get("botId") as string;
  const systemPrompt = (formData.get("systemPrompt") as string)?.trim();
  const model = (formData.get("model") as string)?.trim() || null;

  if (!botId || !systemPrompt) {
    throw new Error("Bot ID and system prompt are required.");
  }

  await prisma.bot.update({
    where: { id: botId },
    data: { systemPrompt, model },
  });

  redirect("/admin/bots");
}

The admin page

The admin page lists all system bots and provides a form to create new ones. Each bot in the list shows its name, description, free-tier status, and a delete button.

Go to src/app/admin/bots and create a file called page.tsx with the content:

page.tsx
import { redirect } from "next/navigation";
import { isAdmin } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import { createBot, deleteBot } from "./actions";
import { Trash2, Plus, Bot as BotIcon, ArrowLeft } from "lucide-react";

export default async function AdminBotsPage() {
  if (!(await isAdmin())) redirect("/");

  const bots = await prisma.bot.findMany({
    where: { type: { in: ["SYSTEM", "MODEL"] } },
    orderBy: { createdAt: "asc" },
  });

  return (
    <div className="min-h-screen bg-zinc-50 p-8 dark:bg-zinc-950">
      <div className="mx-auto max-w-3xl">
        <a
          href="/"
          className="mb-6 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
        >
          <ArrowLeft className="h-4 w-4" />
          Back
        </a>

        <h1 className="mb-8 text-2xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
          Bot Catalog
        </h1>

        <form
          action={createBot}
          className="mb-8 rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
        >
          <h2 className="mb-4 text-lg font-semibold text-zinc-900 dark:text-zinc-50">
            Create a bot
          </h2>

          <div className="space-y-4">
            <div>
              <label
                htmlFor="name"
                className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                Name
              </label>
              <input
                type="text"
                id="name"
                name="name"
                required
                className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
                placeholder="e.g. Code Tutor"
              />
            </div>

            <div>
              <label
                htmlFor="description"
                className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                Description
              </label>
              <input
                type="text"
                id="description"
                name="description"
                required
                className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
                placeholder="A one-sentence summary of what this bot does"
              />
            </div>

            <div>
              <label
                htmlFor="systemPrompt"
                className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                System prompt
              </label>
              <textarea
                id="systemPrompt"
                name="systemPrompt"
                required
                rows={6}
                className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
                placeholder="Define the bot's role, constraints, tone, and format preferences..."
              />
            </div>

            <div>
              <label
                htmlFor="knowledge"
                className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                Knowledge{" "}
                <span className="font-normal text-zinc-400">(optional)</span>
              </label>
              <textarea
                id="knowledge"
                name="knowledge"
                rows={3}
                className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
                placeholder="Plain text that the bot can reference during conversations..."
              />
            </div>

            <div>
              <label
                htmlFor="model"
                className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                Model
              </label>
              <select
                id="model"
                name="model"
                className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
              >
                <option value="claude-haiku-4-5-20251001">Claude Haiku 4.5</option>
                <option value="gpt-4o-mini">GPT-4o mini</option>
              </select>
            </div>

            <button
              type="submit"
              className="flex items-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
            >
              <Plus className="h-4 w-4" />
              Create bot
          </div>
        </form>

        {bots.length === 0 ? (
          <p className="py-12 text-center text-sm text-zinc-500 dark:text-zinc-400">
            No bots yet. Create your first one above.
          </p>
        ) : (
          <div className="space-y-3">
            {bots.map((bot) => (
              <div
                key={bot.id}
                className="flex items-center justify-between rounded-xl border border-zinc-200 bg-white px-5 py-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
              >
                <div className="flex items-center gap-3">
                  <BotIcon className="h-5 w-5 text-zinc-400" />
                  <div>
                    <div className="flex items-center gap-2">
                      <p className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
                        {bot.name}
                      </p>
                      {bot.model && (
                        <span className="rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
                          {bot.model}
                        </span>
                      )}
                      <span className="rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
                        {bot.type}
                      </span>
                    </div>
                    <p className="text-xs text-zinc-500 dark:text-zinc-400">
                      {bot.description}
                    </p>
                  </div>
                </div>

                <form action={deleteBot}>
                  <input type="hidden" name="botId" value={bot.id} />
                  <button
                    type="submit"
                    className="rounded-lg p-2 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-red-500 dark:hover:bg-zinc-800"
                  >
                    <Trash2 className="h-4 w-4" />
                  </button>
                </form>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

Updated home page

Update the page.tsx file contents in src/app to show a "Manage bots" link for admin users:

page.tsx
import { requireAuth } from "@/lib/auth";
import { isAdmin } from "@/lib/admin";
import { LogOut, Settings } from "lucide-react";

export default async function HomePage() {
  const user = await requireAuth();
  if (!user) return null;

  const admin = await isAdmin();

  return (
    <div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950">
      <div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white p-8 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
        <div className="mb-6 text-center">
          {user.avatarUrl && (
            <img
              src={user.avatarUrl}
              alt={user.name ?? "Avatar"}
              className="mx-auto mb-4 h-16 w-16 rounded-full"
            />
          )}
          <h1 className="text-xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
            Welcome, {user.name ?? "there"}
          </h1>
          <p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
            You&apos;re signed in to ChatForge
          </p>
        </div>

        <div className="space-y-3">
          {admin && (
            <a
              href="/admin/bots"
              className="flex w-full items-center justify-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
            >
              <Settings className="h-4 w-4" />
              Manage bots
            </a>
          )}

          <form action="/api/auth/logout" method="post">
            <button
              type="submit"
              className="flex w-full items-center justify-center gap-2 rounded-lg border border-zinc-200 px-4 py-2.5 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
            >
              <LogOut className="h-4 w-4" />
              Sign out
            </button>
          </form>
        </div>
      </div>
    </div>
  );
}

Then, visit http://localhost:3000/admin/bots to test creating your first bot. Every bot starts as free by default.

In Part 3, we're going to build the chat interface, so you need to create a couple bots now. In Part 4, we'll create paid plans and assign bots to them so only subscribers can access premium bots.

Part 3: The chat interface

We have users, bots, and an admin panel. Now we build the reason people come to ChatForge - the chat itself. By the end of this part, users can pick a bot, send messages, and watch streaming responses appear in real time.

Conversations persist to the database and show up in a sidebar, and free-tier users hit the guardrails we designed in the data model.

We use the Vercel AI SDK to handle streaming between server and client - it saves us from writing all the plumbing ourselves. We use it with two providers - Anthropic's Claude and OpenAI's GPT - and each bot's model field determines which one handles the conversation.

Install the AI packages

We need three packages: the Anthropic and OpenAI providers for the Vercel AI SDK, and the React hooks package:

BASH
npm install @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/react

Configure AI provider keys

We need API keys for both providers. Go to console.anthropic.com, create an API key, and restrict its permissions to only what we need. Then go to platform.openai.com, create a project API key, and do the same.

Add both keys to Vercel via the Environment Variables page in the project settings for ANTHROPIC_API_KEY and OPENAI_API_KEY. Then, pull all environment variables locally:

BASH
vercel env pull .env.local

Update the env validation schema to require both keys. Go to src/lib and update the env.ts file with the content:

env.ts
import { z } from "zod";

const envSchema = z.object({
  WHOP_APP_ID: z.string().startsWith("app_"),
  WHOP_API_KEY: z.string().startsWith("apik_"),
  WHOP_COMPANY_ID: z.string().startsWith("biz_"),
  WHOP_CLIENT_ID: z.string().min(1),
  WHOP_CLIENT_SECRET: z.string().min(1),

  DATABASE_URL: z.string().url(),
  DATABASE_URL_UNPOOLED: z.string().url(),

  SESSION_SECRET: z.string().min(32),

  NEXT_PUBLIC_APP_URL: z.string().url(),

  ANTHROPIC_API_KEY: z.string().min(1),
  OPENAI_API_KEY: z.string().min(1),

  WHOP_SANDBOX: z.string().optional(),
});

let _env: z.infer<typeof envSchema> | null = null;

export function getEnv() {
  if (!_env) {
    _env = envSchema.parse(process.env);
  }
  return _env;
}

export const env = new Proxy({} as z.infer<typeof envSchema>, {
  get(_target, prop: string) {
    return getEnv()[prop as keyof z.infer<typeof envSchema>];
  },
});

The AI model resolver

Each bot has a model field that maps to a specific LLM. We need a central registry that translates model IDs into the right provider. Go to src/lib and create a file called ai.ts with the content:

ai.ts
import { anthropic } from "@ai-sdk/anthropic";
import { openai } from "@ai-sdk/openai";
import type { LanguageModel } from "ai";

export const SUPPORTED_MODELS = [
  {
    id: "claude-haiku-4-5-20251001",
    label: "Claude Haiku 4.5",
    provider: "anthropic" as const,
  },
  {
    id: "gpt-4o-mini",
    label: "GPT-4o mini",
    provider: "openai" as const,
  },
] as const;

export type SupportedModelId = (typeof SUPPORTED_MODELS)[number]["id"];

const DEFAULT_MODEL: SupportedModelId = "claude-haiku-4-5-20251001";

export function getModel(modelId?: string | null): LanguageModel {
  const id = modelId || DEFAULT_MODEL;
  const entry = SUPPORTED_MODELS.find((m) => m.id === id);
  if (!entry) {
    return anthropic(DEFAULT_MODEL);
  }
  switch (entry.provider) {
    case "anthropic":
      return anthropic(entry.id);
    case "openai":
      return openai(entry.id);
  }
}

This is our model registry - the single source of truth for every model the app supports. getModel() reads the bot's model field and returns the right provider. SYSTEM bots (which have no model field) fall through to the default, Claude Haiku 4.5.

Adding a third provider - say, Google Gemini - means running npm install @ai-sdk/google and adding one entry to the array. The rest of the app picks it up automatically.

Plan enforcement helpers

Before building the chat route, we need functions that answer three questions: what plan is this user on, can they access this bot, and have they hit their daily limit? These enforce the guardrails from the introduction. Free users get 20 messages per day and 10 conversations, paid users get 50 messages per day.

Callout: We set FREE_DAILY_LIMIT to 20 and PAID_DAILY_LIMIT to 50 for this demo. Adjust these constants to fit your pricing model.

Go to src/lib and create a file called membership.ts with the content:

membership.ts
import { prisma } from "./prisma";

type UserPlan = {
  id: string;
  name: string;
  price: number;
  checkoutUrl: string;
  allowCustomBots: boolean;
};

export async function getUserPlan(userId: string): Promise<UserPlan | null> {
  const membership = await prisma.membership.findUnique({
    where: { userId },
    include: { plan: true },
  });

  if (!membership || membership.status !== "ACTIVE" || !membership.plan) {
    return null;
  }

  return {
    id: membership.plan.id,
    name: membership.plan.name,
    price: membership.plan.price,
    checkoutUrl: membership.plan.checkoutUrl,
    allowCustomBots: membership.plan.allowCustomBots,
  };
}

export function canAccessBot(
  bot: { planId: string | null; type?: string; createdById?: string | null; plan?: { price: number } | null },
  userPlan: { price: number } | null,
  currentUserId?: string
): boolean {
  if (bot.type === "MODEL") {
    return !!userPlan;
  }
  if (!bot.planId) return true; // free bot
  if (!userPlan) return false; // free user, paid bot
  return userPlan.price >= (bot.plan?.price ?? 0);
}

const FREE_DAILY_LIMIT = 20;
const PAID_DAILY_LIMIT = 50;
const FREE_CONVERSATION_LIMIT = 10;

export async function getMessageCountToday(userId: string): Promise<number> {
  const startOfDay = new Date();
  startOfDay.setHours(0, 0, 0, 0);

  return prisma.message.count({
    where: {
      conversation: { userId },
      role: "USER",
      createdAt: { gte: startOfDay },
    },
  });
}

export function isOverMessageLimit(
  count: number,
  userPlan: { price: number } | null
): boolean {
  const limit = userPlan ? PAID_DAILY_LIMIT : FREE_DAILY_LIMIT;
  return count >= limit;
}

export function getConversationLimit(
  userPlan: { price: number } | null
): number | undefined {
  return userPlan ? undefined : FREE_CONVERSATION_LIMIT;
}

Users with no Membership record are treated as free-tier - everyone starts free, and paid memberships are created in Part 4 via webhooks. canAccessBot handles three bot types: Model bots (raw LLM access like Claude or GPT) require any paid plan, System bots check the plan price hierarchy, and free bots (no planId) are open to everyone.

Right now all System bots are free since we haven't created any plans yet, and Model bots will be locked until a user subscribes.

The chat API route

This is where everything comes together. The route receives messages from the client, validates the user's tier and limits, creates a conversation if needed, calls the LLM, streams the response back, and saves both messages to the database after the stream completes.

Go to src/app/api/chat and create a file called route.ts with the content:

route.ts
import { convertToModelMessages, streamText } from "ai";
import { getModel } from "@/lib/ai";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import {
  getUserPlan,
  canAccessBot,
  getMessageCountToday,
  isOverMessageLimit,
} from "@/lib/membership";

export async function POST(req: Request) {
  const user = await requireAuth({ redirect: false });
  if (!user) return new Response("Unauthorized", { status: 401 });

  const body = await req.json();
  const { messages, botId, conversationId } = body;

  const bot = await prisma.bot.findUnique({
    where: { id: botId },
    include: { plan: { select: { price: true } } },
  });
  if (!bot) return new Response("Bot not found", { status: 404 });

  const userPlan = await getUserPlan(user.id);

  if (!canAccessBot(bot, userPlan, user.id)) {
    return new Response("Upgrade required to access this bot", { status: 403 });
  }

  const count = await getMessageCountToday(user.id);
  if (isOverMessageLimit(count, userPlan)) {
    return new Response("Daily message limit reached", { status: 429 });
  }

  const lastMessage = messages[messages.length - 1];
  const lastUserText =
    lastMessage?.parts
      ?.filter((p: { type: string }) => p.type === "text")
      .map((p: { text: string }) => p.text)
      .join("") ||
    lastMessage?.content ||
    "New chat";

  let activeConversationId = conversationId as string | undefined;
  if (!activeConversationId) {
    const conversation = await prisma.conversation.create({
      data: {
        userId: user.id,
        botId: bot.id,
        title: lastUserText.slice(0, 50),
      },
    });
    activeConversationId = conversation.id;
  }

  let systemPrompt = bot.systemPrompt;
  if (bot.knowledge) {
    systemPrompt += `\n\nReference knowledge:\n${bot.knowledge}`;
  }

  const recentMessages = messages.slice(-20);
  const modelMessages = await convertToModelMessages(recentMessages);

  const result = streamText({
    model: getModel(bot.model),
    maxRetries: 1,
    maxOutputTokens: 1024,
    system: systemPrompt,
    messages: modelMessages,
    onFinish: async ({ text, usage }) => {
      await prisma.message.createMany({
        data: [
          {
            conversationId: activeConversationId!,
            role: "USER",
            content: lastUserText,
            tokenCount: usage?.inputTokens || 0,
          },
          {
            conversationId: activeConversationId!,
            role: "ASSISTANT",
            content: text,
            tokenCount: usage?.outputTokens || 0,
          },
        ],
      });

      await prisma.conversation.update({
        where: { id: activeConversationId },
        data: { updatedAt: new Date() },
      });
    },
  });

  return result.toUIMessageStreamResponse({
    headers: { "X-Conversation-Id": activeConversationId! },
  });
}

A few things to note: we only send the last 20 messages to the model (messages.slice(-20)) to keep token costs predictable - the full history is still in the database. We cap responses at 1024 tokens to prevent runaway costs. And messages are saved to the database after the stream completes (onFinish), so we never store partial responses.

The chat layout

The chat interface has a classic two-panel layout: a sidebar with conversation history on the left, and the main chat area on the right. The layout is a server component that loads conversations from the database and passes them to client components.

Go to src/app/chat and create a file called layout.tsx with the content:

layout.tsx
import { requireAuth } from "@/lib/auth";
import { isAdmin } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import { getUserPlan, getConversationLimit } from "@/lib/membership";
import { Sidebar } from "./_components/sidebar";

export default async function ChatLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await requireAuth();
  if (!user) return null;

  const [userPlan, admin] = await Promise.all([
    getUserPlan(user.id),
    isAdmin(),
  ]);

  const limit = getConversationLimit(userPlan);

  const [conversations, cheapestPlan] = await Promise.all([
    prisma.conversation.findMany({
      where: { userId: user.id },
      orderBy: { updatedAt: "desc" },
      take: limit,
      select: {
        id: true,
        title: true,
        updatedAt: true,
        bot: { select: { name: true } },
      },
    }),
    !userPlan
      ? prisma.plan.findFirst({
          where: { isActive: true },
          orderBy: { price: "asc" },
          select: { name: true, price: true, checkoutUrl: true },
        })
      : null,
  ]);

  const serialized = conversations.map((c) => ({
    ...c,
    updatedAt: c.updatedAt.toISOString(),
  }));

  return (
    <div className="flex h-screen bg-zinc-50 dark:bg-zinc-950">
      <Sidebar
        conversations={serialized}
        user={{ name: user.name, avatarUrl: user.avatarUrl }}
        userPlan={userPlan ? { name: userPlan.name, price: userPlan.price } : null}
        isAdmin={admin}
        cheapestPlan={cheapestPlan}
      />
      <main className="flex-1">{children}</main>
    </div>
  );
}

The layout fetches the user's plan, admin status, conversations, and the cheapest available plan (for the upgrade prompt). Free users see up to 10 conversations; paid users have no limit.

The sidebar

The sidebar has a "New chat" button at the top, the conversation list in the middle, and a settings popover at the bottom with the user's plan info, admin links, upgrade prompt, and sign out.

Go to src/app/chat/_components and create a file called sidebar.tsx with the content:

sidebar.tsx
"use client";

import { useState, useRef, useEffect } from "react";
import { usePathname } from "next/navigation";
import {
  Plus,
  MessageSquare,
  Settings,
  LogOut,
  Zap,
  Bot,
  CreditCard,
} from "lucide-react";

type ConversationItem = {
  id: string;
  title: string | null;
  bot: { name: string };
  updatedAt: string;
};

type SidebarProps = {
  conversations: ConversationItem[];
  user: { name: string | null; avatarUrl: string | null };
  userPlan: { name: string; price: number } | null;
  isAdmin: boolean;
  cheapestPlan: { name: string; price: number; checkoutUrl: string } | null;
};

export function Sidebar({
  conversations,
  user,
  userPlan,
  isAdmin,
  cheapestPlan,
}: SidebarProps) {
  const pathname = usePathname();
  const [settingsOpen, setSettingsOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    function handleClick(e: MouseEvent) {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        setSettingsOpen(false);
      }
    }
    if (settingsOpen) document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, [settingsOpen]);

  return (
    <aside className="flex h-full w-72 flex-col border-r border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
      <div className="p-4">
        <a
          href="/chat"
          className="flex w-full items-center justify-center gap-2 rounded-lg border border-zinc-200 px-4 py-2.5 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
        >
          <Plus className="h-4 w-4" />
          New chat
        </a>
      </div>

      <nav className="flex-1 overflow-y-auto px-2 pb-4">
        {conversations.length === 0 ? (
          <p className="px-3 py-8 text-center text-xs text-zinc-400">
            No conversations yet
          </p>
        ) : (
          <ul className="space-y-0.5">
            {conversations.map((conv) => {
              const isActive = pathname === `/chat/${conv.id}`;
              return (
                <li key={conv.id}>
                  <a
                    href={`/chat/${conv.id}`}
                    className={`flex items-start gap-2 rounded-lg px-3 py-2 text-sm transition-colors ${
                      isActive
                        ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50"
                        : "text-zinc-600 hover:bg-zinc-50 dark:text-zinc-400 dark:hover:bg-zinc-800/50"
                    }`}
                  >
                    <MessageSquare className="mt-0.5 h-4 w-4 shrink-0" />
                    <div className="min-w-0">
                      <p className="truncate font-medium">
                        {conv.title || "New chat"}
                      </p>
                      <p className="truncate text-xs text-zinc-400">
                        {conv.bot.name}
                      </p>
                    </div>
                  </a>
                </li>
              );
            })}
          </ul>
        )}
      </nav>

      <div
        className="relative border-t border-zinc-200 p-3 dark:border-zinc-800"
        ref={menuRef}
      >
        {settingsOpen && (
          <div className="absolute bottom-full left-3 right-3 mb-2 overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
            <div className="border-b border-zinc-100 px-3 py-2 dark:border-zinc-700">
              <p className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
                {userPlan ? userPlan.name : "Free"} plan
              </p>
            </div>

            <div className="p-1">
              {cheapestPlan && (
                <a
                  href={cheapestPlan.checkoutUrl}
                  target="_blank"
                  rel="noopener noreferrer"
                  className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-amber-700 transition-colors hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950/30"
                >
                  <Zap className="h-4 w-4" />
                  Upgrade to {cheapestPlan.name} - $
                  {(cheapestPlan.price / 100).toFixed(0)}/mo
                </a>
              )}

              {isAdmin && (
                <>
                  <a
                    href="/admin/bots"
                    className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
                  >
                    <Bot className="h-4 w-4" />
                    Manage bots
                  </a>
                  <a
                    href="/admin/plans"
                    className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
                  >
                    <CreditCard className="h-4 w-4" />
                    Manage plans
                  </a>
                </>
              )}

              <form action="/api/auth/logout" method="post">
                <button
                  type="submit"
                  className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
                >
                  <LogOut className="h-4 w-4" />
                  Sign out
                </button>
              </form>
            </div>
          </div>
        )}

        <button
          onClick={() => setSettingsOpen(!settingsOpen)}
          className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-zinc-600 transition-colors hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800"
        >
          {user.avatarUrl ? (
            <img
              src={user.avatarUrl}
              alt={user.name ?? "Avatar"}
              className="h-6 w-6 rounded-full"
            />
          ) : (
            <Settings className="h-4 w-4" />
          )}
          <span className="truncate">{user.name ?? "Settings"}</span>
        </button>
      </div>
    </aside>
  );
}

Conversation titles are the first 50 characters of the user's opening message - no LLM-generated titles, which would add an extra API call and cost tokens on every new conversation.

The chat area

This is the main client component - it handles the bot selector, message list, streaming, and input form.

Go to src/app/chat/_components and create a file called chat-area.tsx with the content:

chat-area.tsx
"use client";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useRouter } from "next/navigation";
import { useState, useRef, useEffect, useMemo } from "react";
import { Send, Lock, Bot as BotIcon, User, ChevronDown } from "lucide-react";

type BotPlan = {
  price: number;
  name: string;
  checkoutUrl: string;
};

type Bot = {
  id: string;
  name: string;
  description: string;
  type: string;
  planId: string | null;
  model: string | null;
  plan: BotPlan | null;
};

type UserPlan = {
  price: number;
  name: string;
} | null;

type DBMessage = {
  id: string;
  role: "USER" | "ASSISTANT";
  content: string;
};

function dbToUIMessages(msgs: DBMessage[]) {
  return msgs.map((m) => ({
    id: m.id,
    role: m.role.toLowerCase() as "user" | "assistant",
    parts: [{ type: "text" as const, text: m.content }],
  }));
}

function canAccessBot(bot: Bot, userPlan: UserPlan): boolean {
  if (bot.type === "MODEL") return !!userPlan;
  if (!bot.planId) return true;
  if (!userPlan) return false;
  return userPlan.price >= (bot.plan?.price ?? 0);
}

export function ChatArea({
  bots,
  initialConversationId,
  initialMessages,
  initialBotId,
  conversationBotId,
  userPlan,
}: {
  bots: Bot[];
  initialConversationId: string | null;
  initialMessages: DBMessage[];
  initialBotId: string | null;
  conversationBotId: string | null;
  userPlan: UserPlan;
}) {
  const router = useRouter();
  const scrollRef = useRef<HTMLDivElement>(null);
  const dropdownRef = useRef<HTMLDivElement>(null);
  const [conversationId, setConversationId] = useState(initialConversationId);
  const [input, setInput] = useState("");
  const [selectedBotId, setSelectedBotId] = useState(() => {
    if (initialBotId) return initialBotId;
    if (conversationBotId) return conversationBotId;
    if (userPlan) {
      const firstModel = bots.find((b) => b.type === "MODEL");
      if (firstModel) return firstModel.id;
    }
    const firstFreeSystem = bots.find((b) => b.type === "SYSTEM" && !b.planId);
    return firstFreeSystem?.id || bots[0]?.id || "";
  });
  const [dropdownOpen, setDropdownOpen] = useState(false);

  const modelBots = useMemo(() => bots.filter((b) => b.type === "MODEL"), [bots]);
  const systemBots = useMemo(() => bots.filter((b) => b.type === "SYSTEM"), [bots]);

  const selectedBot = bots.find((b) => b.id === selectedBotId);
  const isSwitchingBot =
    conversationBotId && selectedBotId !== conversationBotId;
  const canUseBot = selectedBot ? canAccessBot(selectedBot, userPlan) : false;

  useEffect(() => {
    function handleClick(e: MouseEvent) {
      if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
        setDropdownOpen(false);
      }
    }
    if (dropdownOpen) document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, [dropdownOpen]);

  const conversationIdRef = useRef(conversationId);
  conversationIdRef.current = conversationId;

  const customFetch: typeof globalThis.fetch = async (input, init) => {
    const response = await globalThis.fetch(input, init);
    const newId = response.headers.get("X-Conversation-Id");
    if (newId && !conversationIdRef.current) {
      setConversationId(newId);
      window.history.replaceState(null, "", `/chat/${newId}`);
    }
    return response;
  };

  const transport = useMemo(
    () => new DefaultChatTransport({ fetch: customFetch }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  );

  const { messages, sendMessage, status, error } = useChat({
    id: conversationId || undefined,
    messages: dbToUIMessages(initialMessages),
    transport,
    onFinish: () => {
      router.refresh();
    },
    onError: (error) => {
      console.error("Chat error:", error);
    },
  });

  const isLoading = status === "streaming" || status === "submitted";

  useEffect(() => {
    if (scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [messages]);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim() || isLoading || !canUseBot) return;

    const text = input;
    setInput("");

    await sendMessage(
      { text },
      {
        body: {
          botId: selectedBotId,
          conversationId: isSwitchingBot ? undefined : conversationId,
        },
      }
    );
  };

  function getTextContent(message: (typeof messages)[0]): string {
    return message.parts
      .filter((p): p is { type: "text"; text: string } => p.type === "text")
      .map((p) => p.text)
      .join("");
  }

  const requiredPlan = selectedBot?.plan;

  return (
    <div className="flex h-full flex-col">
      {/* Bot selector */}
      <div className="flex items-center gap-3 border-b border-zinc-200 px-6 py-3 dark:border-zinc-800">
        <div className="relative" ref={dropdownRef}>
          <button
            onClick={() => setDropdownOpen(!dropdownOpen)}
            className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
          >
            {selectedBot?.name || "Select a bot"}
            <ChevronDown className="h-3.5 w-3.5 text-zinc-400" />
          </button>

          {dropdownOpen && (
            <div className="absolute left-0 top-full z-10 mt-1 w-64 overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
              <div className="max-h-80 overflow-y-auto p-1">
                {modelBots.length > 0 && (
                  <>
                    <p className="px-3 py-1 text-xs font-medium text-zinc-400">
                      Models
                    </p>
                    {modelBots.map((bot) => {
                      const locked = !canAccessBot(bot, userPlan);
                      return (
                        <button
                          key={bot.id}
                          onClick={() => {
                            if (!locked) {
                              setSelectedBotId(bot.id);
                              setDropdownOpen(false);
                            }
                          }}
                          disabled={locked}
                          className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
                            locked
                              ? "cursor-not-allowed text-zinc-400 dark:text-zinc-500"
                              : bot.id === selectedBotId
                                ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
                                : "text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700/50"
                          }`}
                        >
                          <span className="truncate">{bot.name}</span>
                          {locked && (
                            <Lock className="ml-auto h-3.5 w-3.5 shrink-0 text-zinc-400" />
                          )}
                        </button>
                      );
                    })}
                  </>
                )}

                {systemBots.length > 0 && (
                  <>
                    {modelBots.length > 0 && (
                      <div className="mx-2 my-1 border-t border-zinc-200 dark:border-zinc-700" />
                    )}
                    <p className="px-3 py-1 text-xs font-medium text-zinc-400">
                      System Bots
                    </p>
                    {systemBots.map((bot) => (
                      <button
                        key={bot.id}
                        onClick={() => {
                          setSelectedBotId(bot.id);
                          setDropdownOpen(false);
                        }}
                        className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
                          bot.id === selectedBotId
                            ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
                            : "text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700/50"
                        }`}
                      >
                        <span className="truncate">{bot.name}</span>
                        {!canAccessBot(bot, userPlan) && (
                          <Lock className="ml-auto h-3.5 w-3.5 shrink-0 text-zinc-400" />
                        )}
                      </button>
                    ))}
                  </>
                )}
              </div>
            </div>
          )}
        </div>

        {selectedBot && (
          <span className="text-xs text-zinc-400">
            {selectedBot.description}
          </span>
        )}
      </div>

      {/* Bot switch warning */}
      {isSwitchingBot && (
        <div className="border-b border-amber-200 bg-amber-50 px-6 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-200">
          Sending a message will start a new chat with{" "}
          <strong>{selectedBot?.name}</strong>
        </div>
      )}

      {/* Messages */}
      <div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-4">
        {messages.length === 0 ? (
          <div className="flex h-full flex-col items-center justify-center text-center">
            <BotIcon className="mb-4 h-12 w-12 text-zinc-300 dark:text-zinc-600" />
            <h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
              {selectedBot?.name || "Select a bot"}
            </h2>
            <p className="mt-1 max-w-sm text-sm text-zinc-500">
              {selectedBot?.description || "Choose a bot to start chatting"}
            </p>
          </div>
        ) : (
          <div className="mx-auto max-w-3xl space-y-6">
            {messages.map((message) => (
              <div key={message.id} className="flex gap-3">
                <div
                  className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${
                    message.role === "user"
                      ? "bg-zinc-200 dark:bg-zinc-700"
                      : "bg-zinc-900 dark:bg-zinc-100"
                  }`}
                >
                  {message.role === "user" ? (
                    <User className="h-4 w-4 text-zinc-600 dark:text-zinc-300" />
                  ) : (
                    <BotIcon className="h-4 w-4 text-white dark:text-zinc-900" />
                  )}
                </div>
                <div className="min-w-0 flex-1 pt-1">
                  <p className="mb-1 text-xs font-medium text-zinc-500">
                    {message.role === "user"
                      ? "You"
                      : selectedBot?.name || "Bot"}
                  </p>
                  <div className="prose prose-sm prose-zinc max-w-none dark:prose-invert">
                    <p className="whitespace-pre-wrap">
                      {getTextContent(message)}
                    </p>
                  </div>
                </div>
              </div>
            ))}
            {isLoading &&
              messages[messages.length - 1]?.role === "user" && (
                <div className="flex gap-3">
                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-900 dark:bg-zinc-100">
                    <BotIcon className="h-4 w-4 text-white dark:text-zinc-900" />
                  </div>
                  <div className="pt-1">
                    <p className="mb-1 text-xs font-medium text-zinc-500">
                      {selectedBot?.name || "Bot"}
                    </p>
                    <div className="flex gap-1">
                      <span className="h-2 w-2 animate-bounce rounded-full bg-zinc-400 [animation-delay:0ms]" />
                      <span className="h-2 w-2 animate-bounce rounded-full bg-zinc-400 [animation-delay:150ms]" />
                      <span className="h-2 w-2 animate-bounce rounded-full bg-zinc-400 [animation-delay:300ms]" />
                    </div>
                  </div>
                </div>
              )}
          </div>
        )}
      </div>

      {/* Error display */}
      {status === "error" && (
        <div className="border-t border-red-200 bg-red-50 px-6 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300">
          {error?.message?.includes("Daily message limit")
            ? "You've hit your daily message limit. Upgrade for more messages."
            : error?.message?.includes("Upgrade required")
              ? "This bot requires a paid plan."
              : "Something went wrong. Please try again."}
        </div>
      )}

      {/* Input */}
      <div className="border-t border-zinc-200 px-6 py-4 dark:border-zinc-800">
        {!canUseBot ? (
          <a
            href={requiredPlan?.checkoutUrl || "#"}
            target="_blank"
            rel="noopener noreferrer"
            className="flex items-center justify-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-800 transition-colors hover:bg-amber-100 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-200 dark:hover:bg-amber-950"
          >
            <Lock className="h-4 w-4" />
            Upgrade to {requiredPlan?.name || "a paid plan"} to chat with{" "}
            {selectedBot?.name}
          </a>
        ) : (
          <form
            onSubmit={handleSubmit}
            className="mx-auto flex max-w-3xl items-end gap-2"
          >
            <textarea
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Enter" && !e.shiftKey) {
                  e.preventDefault();
                  handleSubmit(e);
                }
              }}
              placeholder="Send a message..."
              rows={1}
              className="flex-1 resize-none rounded-lg border border-zinc-200 bg-white px-4 py-2.5 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-400 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
            />
            <button
              type="submit"
              disabled={isLoading || !input.trim()}
              className="rounded-lg bg-zinc-900 p-2.5 text-white transition-colors hover:bg-zinc-800 disabled:opacity-40 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
            >
              <Send className="h-4 w-4" />
            </button>
          </form>
        )}
      </div>
    </div>
  );
}

The bot selector is a custom dropdown split into two sections: Models (raw LLM access, locked for free users with a lock icon) and System Bots (admin-created bots with optional plan gating).

Model bots are grayed out and unclickable for free users, while the upgrade prompt in the input area directs them to the cheapest plan. The smart default selects the first Model bot for paid users and the first free System bot for everyone else.

The chat pages

We need two pages: one for starting new conversations, and one for resuming existing ones.

Go to src/app/chat and create a file called page.tsx with the content:

page.tsx
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getUserPlan } from "@/lib/membership";
import { ChatArea } from "./_components/chat-area";

export default async function NewChatPage({
  searchParams,
}: {
  searchParams: Promise<{ bot?: string }>;
}) {
  const user = await requireAuth();
  if (!user) return null;

  const { bot: botParam } = await searchParams;
  const userPlan = await getUserPlan(user.id);

  const bots = await prisma.bot.findMany({
    where: { type: { in: ["SYSTEM", "MODEL"] } },
    orderBy: { createdAt: "asc" },
    select: {
      id: true,
      name: true,
      description: true,
      type: true,
      planId: true,
      model: true,
      plan: { select: { price: true, name: true, checkoutUrl: true } },
    },
  });

  return (
    <ChatArea
      bots={bots}
      initialConversationId={null}
      initialMessages={[]}
      initialBotId={botParam || null}
      conversationBotId={null}
      userPlan={userPlan}
    />
  );
}

The new chat page accepts an optional ?bot= query parameter so we can deep-link to a specific bot. The query fetches both System and Model bots - Model bots give users raw LLM access (Claude, GPT), while System bots are the admin-created personalities.

Go to src/app/chat/[conversationId] and create a file called page.tsx with the content:

page.tsx
import { redirect } from "next/navigation";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getUserPlan } from "@/lib/membership";
import { ChatArea } from "../_components/chat-area";

export default async function ConversationPage({
  params,
}: {
  params: Promise<{ conversationId: string }>;
}) {
  const user = await requireAuth();
  if (!user) return null;

  const { conversationId } = await params;

  const conversation = await prisma.conversation.findUnique({
    where: { id: conversationId },
    include: {
      messages: {
        orderBy: { createdAt: "asc" },
        select: { id: true, role: true, content: true },
      },
      bot: { select: { id: true } },
    },
  });

  if (!conversation || conversation.userId !== user.id) {
    redirect("/chat");
  }

  const userPlan = await getUserPlan(user.id);

  const bots = await prisma.bot.findMany({
    where: { type: { in: ["SYSTEM", "MODEL"] } },
    orderBy: { createdAt: "asc" },
    select: {
      id: true,
      name: true,
      description: true,
      type: true,
      planId: true,
      model: true,
      plan: { select: { price: true, name: true, checkoutUrl: true } },
    },
  });

  return (
    <ChatArea
      bots={bots}
      initialConversationId={conversationId}
      initialMessages={conversation.messages}
      initialBotId={conversation.bot.id}
      conversationBotId={conversation.bot.id}
      userPlan={userPlan}
    />
  );
}

The conversation page verifies ownership - if the conversation doesn't exist or belongs to a different user, we redirect to /chat. Messages are loaded from the database and passed as initialMessages to hydrate the chat.

Update the home page

Now that the sidebar handles navigation, user info, admin links, and sign out, the home page becomes a simple redirect to /chat.

Go to src/app and update the page.tsx file with the content:

page.tsx
import { redirect } from "next/navigation";

export default function HomePage() {
  redirect("/chat");
}

Checkpoint: streaming chat

  1. Run npm run dev and visit http://localhost:3000 - the page redirects to /chat and the chat interface loads with the bot selector and an empty conversation area
  2. Open the bot dropdown - it shows two sections: "Models" (Claude Haiku 4.5, GPT-4o mini) and "System Bots" (the admin-created bots from Part 2). The MODEL bots show lock icons since we have no paid plans yet
  3. Select a free-tier system bot (Code Tutor or Creative Storyteller) and send a message - the response streams in word by word
  4. The conversation appears in the sidebar with the first message as the title
  5. Click "New chat" in the sidebar, select a different bot, and send a message - a separate conversation is created
  6. Click a previous conversation in the sidebar - the full message history loads from the database
  7. Refresh the page - the conversation persists with all messages intact
  8. Click the user avatar (or "Settings") at the bottom of the sidebar - the settings popover opens showing the current plan, admin links (if applicable), and sign out
  9. Push to GitHub - Vercel auto-deploys. Add ANTHROPIC_API_KEY and OPENAI_API_KEY to Vercel environment variables if you haven't already, and verify the chat works on the production URL

Right now all SYSTEM bots are free since we haven't created any plans yet, and MODEL bots are locked because they require any paid plan.

In Part 4, we build the plan management system and wire up Whop Payments Network - admins will create paid plans, assign bots to them, and once a user subscribes, the MODEL bots unlock and the lock icons on paid SYSTEM bots disappear.

Part 4: Payments and access gating

We have a working chat interface where every bot is free. The Plan model exists in the schema, but no plans have been created - there's no way to pay and no reason to gate anything.

In this part we build the plan management system, wire up Whop webhooks, and turn ChatForge into a real SaaS where admins create paid tiers and users pay to unlock premium bots.

The architecture follows a pattern common to every SaaS that uses a third-party payment processor: the admin creates a plan (which provisions a product on Whop), the user pays on Whop's hosted checkout page, Whop fires a webhook to our server, and our server updates the database.

The app never touches payment details - no card numbers, no PCI scope, no billing logic. We just need to know which user subscribed to which plan, and webhooks give us exactly that.

Set up the webhook

Go to the developer tab on sandbox.whop.com - this is the base developer tab, not the one inside your app's dashboard. Click Create Webhook in the top right corner and configure:

  • URL: https://your-app.vercel.app/api/webhooks/whop (use your production Vercel URL from Part 1)
  • Events: Select membership_activated and membership_deactivated

After saving, Whop shows a webhook secret (starts with ws_). Copy it - we need it for signature verification.

Add the webhook secret to Vercel via the Environment Variables page of your project settings as WHOP_WEBHOOK_SECRET, then pull everything locally:

BASH
vercel env pull .env.local
For local testing, use a tunnel like ngrok or Cloudflare Tunnel to expose your localhost to the internet. Set the webhook URL to the tunnel URL temporarily, test, then switch it back to production.

Update env validation

Now, let's add the webhook secret to the Zod schema. Go to src/lib and update the env.ts file content with:

env.ts
import { z } from "zod";

const envSchema = z.object({
  WHOP_APP_ID: z.string().startsWith("app_"),
  WHOP_API_KEY: z.string().startsWith("apik_"),
  WHOP_COMPANY_ID: z.string().startsWith("biz_"),
  WHOP_CLIENT_ID: z.string().min(1),
  WHOP_CLIENT_SECRET: z.string().min(1),

  DATABASE_URL: z.string().url(),
  DATABASE_URL_UNPOOLED: z.string().url(),

  SESSION_SECRET: z.string().min(32),

  NEXT_PUBLIC_APP_URL: z.string().url(),

  ANTHROPIC_API_KEY: z.string().min(1),
  OPENAI_API_KEY: z.string().min(1),

  WHOP_WEBHOOK_SECRET: z.string().min(1),

  WHOP_COMPANY_API_KEY: z.string().startsWith("apik_"),

  WHOP_SANDBOX: z.string().optional(),
});

let _env: z.infer<typeof envSchema> | null = null;

export function getEnv() {
  if (!_env) {
    _env = envSchema.parse(process.env);
  }
  return _env;
}

export const env = new Proxy({} as z.infer<typeof envSchema>, {
  get(_target, prop: string) {
    return getEnv()[prop as keyof z.infer<typeof envSchema>];
  },
});

Add the company API key

Go to sandbox.whop.com and navigate to Business Settings > API Keys (or the equivalent in the dashboard). Copy the company API key - it starts with apik_, just like the app API key, but is scoped to the company rather than a single app.

Then, go to Vercel and add the variable via the Environment Variables page of your project settings as WHOP_COMPANY_API_KEY and pull everything locally:

BASH
vercel env pull .env.local

Alternatively, you can add WHOP_COMPANY_API_KEY=apik_... directly to .env.local for local development.

Update the Whop SDK client

We need a second Whop SDK client using the company API key, since the app API key doesn't have permission to create products and plans. Go to src/lib and update the whop.ts file with the content:

whop.ts
import Whop from "@whop/sdk";

let _whop: Whop | null = null;
export function getWhop(): Whop {
  if (!_whop) {
    _whop = new Whop({
      appID: process.env.WHOP_APP_ID!,
      apiKey: process.env.WHOP_API_KEY!,
      webhookKey: Buffer.from(process.env.WHOP_WEBHOOK_SECRET || "").toString("base64"),
      ...(process.env.WHOP_SANDBOX === "true" && {
        baseURL: "https://sandbox-api.whop.com/api/v1",
      }),
    });
  }
  return _whop;
}

let _companyWhop: Whop | null = null;
export function getCompanyWhop(): Whop {
  if (!_companyWhop) {
    _companyWhop = new Whop({
      apiKey: process.env.WHOP_COMPANY_API_KEY!,
      ...(process.env.WHOP_SANDBOX === "true" && {
        baseURL: "https://sandbox-api.whop.com/api/v1",
      }),
    });
  }
  return _companyWhop;
}

const isSandbox = () => process.env.WHOP_SANDBOX === "true";
const whopDomain = () => (isSandbox() ? "sandbox.whop.com" : "whop.com");
const whopApiDomain = () =>
  isSandbox() ? "sandbox-api.whop.com" : "api.whop.com";

export const WHOP_OAUTH = {
  get authorizationUrl() {
    return `https://${whopApiDomain()}/oauth/authorize`;
  },
  get tokenUrl() {
    return `https://${whopApiDomain()}/oauth/token`;
  },
  get userInfoUrl() {
    return `https://${whopApiDomain()}/oauth/userinfo`;
  },
  get clientId() {
    return process.env.WHOP_CLIENT_ID!;
  },
  get clientSecret() {
    return process.env.WHOP_CLIENT_SECRET!;
  },
  scopes: ["openid", "profile", "email"],
  get redirectUri() {
    return `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback`;
  },
};

export async function generatePKCE() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  const verifier = base64UrlEncode(array);

  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest("SHA-256", data);
  const challenge = base64UrlEncode(new Uint8Array(digest));

  return { verifier, challenge };
}

function base64UrlEncode(buffer: Uint8Array): string {
  let binary = "";
  for (const byte of buffer) {
    binary += String.fromCharCode(byte);
  }
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

The webhook handler

When a user pays on Whop, Whop sends a webhook to our server so we can update the database. The handler verifies the signature, records the event ID for idempotency (so duplicate deliveries are ignored), then looks up the matching plan and creates or updates the user's membership.

Go to src/app/api/webhooks/whop and create a file called route.ts with the content:

route.ts
import type { NextRequest } from "next/server";
import { getWhop } from "@/lib/whop";
import { prisma } from "@/lib/prisma";

export async function POST(request: NextRequest): Promise<Response> {
  const body = await request.text();
  const headers = Object.fromEntries(request.headers);

  let event;
  try {
    event = getWhop().webhooks.unwrap(body, { headers });
  } catch {
    return new Response("Invalid signature", { status: 401 });
  }

  try {
    await prisma.webhookEvent.create({ data: { id: event.id } });
  } catch {
    return new Response("Already processed", { status: 200 });
  }

  if (event.type === "membership.activated") {
    await handleMembershipActivated(event.data, event.id);
  }

  if (event.type === "membership.deactivated") {
    await handleMembershipDeactivated(event.data, event.id);
  }

  return new Response("OK", { status: 200 });
}

async function handleMembershipActivated(
  membership: {
    id: string;
    user?: { id: string } | null;
    product: { id: string };
    renewal_period_start: string | null;
    renewal_period_end: string | null;
  },
  eventId: string
) {
  const whopUserId = membership.user?.id;
  if (!whopUserId) return;

  const user = await prisma.user.findUnique({ where: { whopUserId } });
  if (!user) return;

  const plan = await prisma.plan.findUnique({
    where: { whopProductId: membership.product.id },
  });
  if (!plan) return;

  await prisma.membership.upsert({
    where: { userId: user.id },
    create: {
      userId: user.id,
      planId: plan.id,
      status: "ACTIVE",
      whopMembershipId: membership.id,
      periodStart: parseTimestamp(membership.renewal_period_start),
      periodEnd: parseTimestamp(membership.renewal_period_end),
      lastWebhookEventId: eventId,
    },
    update: {
      planId: plan.id,
      status: "ACTIVE",
      whopMembershipId: membership.id,
      periodStart: parseTimestamp(membership.renewal_period_start),
      periodEnd: parseTimestamp(membership.renewal_period_end),
      lastWebhookEventId: eventId,
    },
  });
}

async function handleMembershipDeactivated(
  membership: { user?: { id: string } | null },
  eventId: string
) {
  const whopUserId = membership.user?.id;
  if (!whopUserId) return;

  const user = await prisma.user.findUnique({ where: { whopUserId } });
  if (!user) return;

  await prisma.membership.updateMany({
    where: { userId: user.id },
    data: {
      status: "CANCELLED",
      lastWebhookEventId: eventId,
    },
  });
}

function parseTimestamp(value: string | null): Date | null {
  if (!value) return null;
  const num = Number(value);
  if (!isNaN(num) && num > 0) return new Date(num * 1000);
  return new Date(value);
}

Plan management admin page

This is where admins create paid plans. Each plan is provisioned on Whop automatically - the admin enters a name, price, and whether the plan allows custom bot creation. The server calls the Whop API to create a product and billing plan, stores the IDs and checkout URL, and the plan is immediately available for purchase.

Go to src/app/admin/plans and create a file called actions.ts with the content:

actions.ts
"use server";

import { redirect } from "next/navigation";
import { isAdmin } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import { getCompanyWhop } from "@/lib/whop";

export async function createPlan(formData: FormData) {
  if (!(await isAdmin())) throw new Error("Unauthorized");

  const name = (formData.get("name") as string)?.trim();
  const priceStr = (formData.get("price") as string)?.trim();
  const allowCustomBots = formData.get("allowCustomBots") === "on";

  if (!name || !priceStr) {
    throw new Error("Name and price are required.");
  }

  const priceDollars = parseFloat(priceStr);
  if (isNaN(priceDollars) || priceDollars <= 0) {
    throw new Error("Price must be a positive number.");
  }

  const whop = getCompanyWhop();
  const companyId = process.env.WHOP_COMPANY_ID!;

  const product = await whop.products.create({
    company_id: companyId,
    title: name,
  });

  const whopPlan = await whop.plans.create({
    company_id: companyId,
    product_id: product.id,
    renewal_price: priceDollars,
    plan_type: "renewal",
    billing_period: 30,
  });

  await prisma.plan.create({
    data: {
      name,
      price: Math.round(priceDollars * 100),
      whopProductId: product.id,
      whopPlanId: whopPlan.id,
      checkoutUrl: whopPlan.purchase_url,
      allowCustomBots,
    },
  });

  redirect("/admin/plans");
}

export async function togglePlanActive(formData: FormData) {
  if (!(await isAdmin())) throw new Error("Unauthorized");

  const planId = formData.get("planId") as string;
  if (!planId) throw new Error("Plan ID is required.");

  const plan = await prisma.plan.findUnique({ where: { id: planId } });
  if (!plan) throw new Error("Plan not found.");

  await prisma.plan.update({
    where: { id: planId },
    data: { isActive: !plan.isActive },
  });

  redirect("/admin/plans");
}

export async function deletePlan(formData: FormData) {
  if (!(await isAdmin())) throw new Error("Unauthorized");

  const planId = formData.get("planId") as string;
  if (!planId) throw new Error("Plan ID is required.");

  const activeMemberships = await prisma.membership.count({
    where: { planId, status: "ACTIVE" },
  });

  if (activeMemberships > 0) {
    throw new Error(
      "Cannot delete a plan with active memberships. Deactivate it instead."
    );
  }

  await prisma.bot.updateMany({
    where: { planId },
    data: { planId: null },
  });

  await prisma.plan.delete({ where: { id: planId } });

  redirect("/admin/plans");
}

Go to src/app/admin/plans and create a file called page.tsx with the content:

page.tsx
import { redirect } from "next/navigation";
import { isAdmin } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import { createPlan, togglePlanActive, deletePlan } from "./actions";
import { Plus, ArrowLeft, Zap, Trash2, Eye, EyeOff } from "lucide-react";

export default async function AdminPlansPage() {
  if (!(await isAdmin())) redirect("/");

  const plans = await prisma.plan.findMany({
    orderBy: { price: "asc" },
    include: {
      _count: { select: { bots: true, memberships: true } },
    },
  });

  return (
    <div className="min-h-screen bg-zinc-50 p-8 dark:bg-zinc-950">
      <div className="mx-auto max-w-3xl">
        <a
          href="/"
          className="mb-6 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
        >
          <ArrowLeft className="h-4 w-4" />
          Back
        </a>

        <h1 className="mb-8 text-2xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
          Plans
        </h1>

        <form
          action={createPlan}
          className="mb-8 rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
        >
          <h2 className="mb-4 text-lg font-semibold text-zinc-900 dark:text-zinc-50">
            Create a plan
          </h2>
          <p className="mb-4 text-sm text-zinc-500 dark:text-zinc-400">
            This creates a product and checkout link on Whop automatically.
          </p>

          <div className="space-y-4">
            <div>
              <label
                htmlFor="name"
                className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                Plan name
              </label>
              <input
                type="text"
                id="name"
                name="name"
                required
                maxLength={40}
                className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
                placeholder="e.g. Pro"
              />
            </div>

            <div>
              <label
                htmlFor="price"
                className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                Monthly price (USD)
              </label>
              <input
                type="number"
                id="price"
                name="price"
                required
                min="1"
                step="0.01"
                className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
                placeholder="9.00"
              />
            </div>

            <div className="flex items-center gap-2">
              <input
                type="checkbox"
                id="allowCustomBots"
                name="allowCustomBots"
                className="rounded border-zinc-300"
              />
              <label
                htmlFor="allowCustomBots"
                className="text-sm text-zinc-700 dark:text-zinc-300"
              >
                Allow custom bot creation
              </label>
            </div>

            <button
              type="submit"
              className="flex items-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
            >
              <Plus className="h-4 w-4" />
              Create plan
            </button>
          </div>
        </form>

        {plans.length === 0 ? (
          <p className="py-12 text-center text-sm text-zinc-500 dark:text-zinc-400">
            No plans yet. Create your first one above.
          </p>
        ) : (
          <div className="space-y-3">
            {plans.map((plan) => (
              <div
                key={plan.id}
                className={`rounded-xl border bg-white px-5 py-4 shadow-sm dark:bg-zinc-900 ${
                  plan.isActive
                    ? "border-zinc-200 dark:border-zinc-800"
                    : "border-zinc-200/50 opacity-60 dark:border-zinc-800/50"
                }`}
              >
                <div className="flex items-center justify-between">
                  <div className="flex items-center gap-3">
                    <Zap className="h-5 w-5 text-amber-500" />
                    <div>
                      <p className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
                        {plan.name}
                        <span className="ml-2 text-zinc-500">
                          ${(plan.price / 100).toFixed(2)}/mo
                        </span>
                        {plan.allowCustomBots && (
                          <span className="ml-2 rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
                            Custom bots
                          </span>
                        )}
                        {!plan.isActive && (
                          <span className="ml-2 rounded-full bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-500 dark:bg-zinc-800">
                            Inactive
                          </span>
                        )}
                      </p>
                      <p className="text-xs text-zinc-500 dark:text-zinc-400">
                        {plan._count.bots} bots &middot;{" "}
                        {plan._count.memberships} members
                      </p>
                    </div>
                  </div>

                  <div className="flex items-center gap-1">
                    <form action={togglePlanActive}>
                      <input type="hidden" name="planId" value={plan.id} />
                      <button
                        type="submit"
                        title={plan.isActive ? "Deactivate" : "Activate"}
                        className="rounded-lg p-2 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
                      >
                        {plan.isActive ? (
                          <EyeOff className="h-4 w-4" />
                        ) : (
                          <Eye className="h-4 w-4" />
                        )}
                      </button>
                    </form>
                    <form action={deletePlan}>
                      <input type="hidden" name="planId" value={plan.id} />
                      <button
                        type="submit"
                        title="Delete"
                        className="rounded-lg p-2 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-red-500 dark:hover:bg-zinc-800"
                      >
                        <Trash2 className="h-4 w-4" />
                      </button>
                    </form>
                  </div>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

Update the bot admin page

Now that plans exist, we need to let admins assign bots to plans and pick which model powers each bot. We add a plan selector and model selector to the bot creation form, show plan badges on existing bots, and give admins a way to edit the default system prompt on MODEL bots (the seeded Claude and GPT entries).

Go tosrc/app/admin/bots and create the actions.ts file with the content:

actions.ts
"use server";

import { redirect } from "next/navigation";
import { isAdmin } from "@/lib/admin";
import { prisma } from "@/lib/prisma";

export async function createBot(formData: FormData) {
  if (!(await isAdmin())) throw new Error("Unauthorized");

  const name = (formData.get("name") as string)?.trim();
  const description = (formData.get("description") as string)?.trim();
  const systemPrompt = (formData.get("systemPrompt") as string)?.trim();
  const knowledge = (formData.get("knowledge") as string)?.trim() || null;
  const planId = (formData.get("planId") as string)?.trim() || null;
  const model = (formData.get("model") as string)?.trim() || null;

  if (!name || !description || !systemPrompt) {
    throw new Error("Name, description, and system prompt are required.");
  }

  await prisma.bot.create({
    data: { name, description, systemPrompt, knowledge, planId, model, type: "SYSTEM" },
  });

  redirect("/admin/bots");
}

export async function updateBotPrompt(formData: FormData) {
  if (!(await isAdmin())) throw new Error("Unauthorized");

  const botId = formData.get("botId") as string;
  const systemPrompt = (formData.get("systemPrompt") as string)?.trim();
  const model = (formData.get("model") as string)?.trim() || null;

  if (!botId || !systemPrompt) {
    throw new Error("Bot ID and system prompt are required.");
  }

  await prisma.bot.update({
    where: { id: botId },
    data: { systemPrompt, model },
  });

  redirect("/admin/bots");
}

export async function deleteBot(formData: FormData) {
  if (!(await isAdmin())) throw new Error("Unauthorized");

  const botId = formData.get("botId") as string;
  if (!botId) throw new Error("Bot ID is required.");

  await prisma.bot.delete({ where: { id: botId } });
  redirect("/admin/bots");
}

The createBot action now accepts a model field so admins can pick which LLM powers a new system bot. We also add updateBotPrompt where model bots (Claude, GPT) are seeded and should not be deleted, but admins can tweak their default system prompts. Bots with no planId are free. Bots with a planId require that plan (or a higher-priced one) to access.

Go to src/app/admin/bots and update the page.tsx file with the content:

page.tsx
import { redirect } from "next/navigation";
import { isAdmin } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import { createBot, deleteBot, updateBotPrompt } from "./actions";
import { SUPPORTED_MODELS } from "@/lib/ai";
import { Trash2, Plus, Bot as BotIcon, ArrowLeft } from "lucide-react";

export default async function AdminBotsPage() {
  if (!(await isAdmin())) redirect("/");

  const [bots, plans] = await Promise.all([
    prisma.bot.findMany({
      where: { type: { in: ["SYSTEM", "MODEL"] } },
      orderBy: { createdAt: "asc" },
      include: { plan: { select: { name: true, price: true } } },
    }),
    prisma.plan.findMany({
      where: { isActive: true },
      orderBy: { price: "asc" },
    }),
  ]);

  return (
    <div className="min-h-screen bg-zinc-50 p-8 dark:bg-zinc-950">
      <div className="mx-auto max-w-3xl">
        <a
          href="/"
          className="mb-6 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
        >
          <ArrowLeft className="h-4 w-4" />
          Back
        </a>

        <h1 className="mb-8 text-2xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
          Bot Catalog
        </h1>

        <form
          action={createBot}
          className="mb-8 rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
        >
          <h2 className="mb-4 text-lg font-semibold text-zinc-900 dark:text-zinc-50">
            Create a bot
          </h2>

          <div className="space-y-4">
            <div>
              <label
                htmlFor="name"
                className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                Name
              </label>
              <input
                type="text"
                id="name"
                name="name"
                required
                className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
                placeholder="e.g. Code Tutor"
              />
            </div>

            <div>
              <label
                htmlFor="description"
                className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                Description
              </label>
              <input
                type="text"
                id="description"
                name="description"
                required
                className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
                placeholder="A one-sentence summary of what this bot does"
              />
            </div>

            <div>
              <label
                htmlFor="systemPrompt"
                className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                System prompt
              </label>
              <textarea
                id="systemPrompt"
                name="systemPrompt"
                required
                rows={6}
                className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
                placeholder="Define the bot's role, constraints, tone, and format preferences..."
              />
            </div>

            <div>
              <label
                htmlFor="knowledge"
                className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                Knowledge{" "}
                <span className="font-normal text-zinc-400">(optional)</span>
              </label>
              <textarea
                id="knowledge"
                name="knowledge"
                rows={3}
                className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
                placeholder="Plain text that the bot can reference during conversations..."
              />
            </div>

            <div>
              <label
                htmlFor="model"
                className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                Model
              </label>
              <select
                id="model"
                name="model"
                required
                className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
              >
                {SUPPORTED_MODELS.map((m) => (
                  <option key={m.id} value={m.id}>
                    {m.label}
                  </option>
                ))}
              </select>
            </div>

            <div>
              <label
                htmlFor="planId"
                className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
              >
                Required plan{" "}
                <span className="font-normal text-zinc-400">
                  (leave empty for free access)
                </span>
              </label>
              <select
                id="planId"
                name="planId"
                className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
              >
                <option value="">Free (no plan required)</option>
                {plans.map((plan) => (
                  <option key={plan.id} value={plan.id}>
                    {plan.name} - ${(plan.price / 100).toFixed(2)}/mo
                  </option>
                ))}
              </select>
            </div>

            <button
              type="submit"
              className="flex items-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
            >
              <Plus className="h-4 w-4" />
              Create bot
            </button>
          </div>
        </form>

        {bots.length === 0 ? (
          <p className="py-12 text-center text-sm text-zinc-500 dark:text-zinc-400">
            No bots yet. Create your first one above.
          </p>
        ) : (
          <div className="space-y-3">
            {bots.map((bot) => {
              const modelLabel = SUPPORTED_MODELS.find(
                (m) => m.id === bot.model
              )?.label;
              return (
                <div
                  key={bot.id}
                  className="rounded-xl border border-zinc-200 bg-white px-5 py-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
                >
                  <div className="flex items-center justify-between">
                    <div className="flex items-center gap-3">
                      <BotIcon className="h-5 w-5 text-zinc-400" />
                      <div>
                        <p className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
                          {bot.name}
                          {bot.type === "MODEL" ? (
                            <span className="ml-2 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
                              Raw Model
                            </span>
                          ) : bot.plan ? (
                            <span className="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
                              {bot.plan.name} ($
                              {(bot.plan.price / 100).toFixed(2)})
                            </span>
                          ) : (
                            <span className="ml-2 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
                              Free
                            </span>
                          )}
                          {modelLabel && (
                            <span className="ml-2 text-xs text-zinc-400">
                              {modelLabel}
                            </span>
                          )}
                        </p>
                        <p className="text-xs text-zinc-500 dark:text-zinc-400">
                          {bot.description}
                        </p>
                      </div>
                    </div>

                    {bot.type !== "MODEL" && (
                      <form action={deleteBot}>
                        <input type="hidden" name="botId" value={bot.id} />
                        <button
                          type="submit"
                          className="rounded-lg p-2 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-red-500 dark:hover:bg-zinc-800"
                        >
                          <Trash2 className="h-4 w-4" />
                        </button>
                      </form>
                    )}
                  </div>

                  {bot.type === "MODEL" && (
                    <form action={updateBotPrompt} className="mt-3">
                      <input type="hidden" name="botId" value={bot.id} />
                      <textarea
                        name="systemPrompt"
                        rows={2}
                        defaultValue={bot.systemPrompt}
                        className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-700 placeholder:text-zinc-400 focus:border-zinc-400 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
                        placeholder="Default system prompt for this model (optional)..."
                      />
                      <button
                        type="submit"
                        className="mt-1 rounded-md bg-zinc-100 px-3 py-1 text-xs font-medium text-zinc-600 transition-colors hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
                      >
                        Update prompt
                      </button>
                    </form>
                  )}
                </div>
              );
            })}
          </div>
        )}
      </div>
    </div>
  );
}

Update the home page

The welcome card we built in Part 1 served its purpose while we were setting things up, but now the chat interface has a sidebar with admin links, plan info, and upgrade prompts - everything the home page used to offer. We can drop the old page and send visitors straight to /chat. Go to src/app and update the page.tsx with the content:

page.tsx
import { redirect } from "next/navigation";

export default function HomePage() {
  redirect("/chat");
}

Checkpoint: payments and access gating

  1. Run npm run dev and visit http://localhost:3000 - the app redirects to /chat. Click our name in the bottom-left of the sidebar - the settings popover shows "Free plan"
  2. Navigate to /admin/plans - create a plan (e.g., "Pro" at $9/month). The plan appears in the list with its checkout URL
  3. Navigate to /admin/bots - the plan selector dropdown now shows the Pro plan. Edit a bot (or create a new one) and assign it to the Pro plan
  4. Go to /chat and select a locked bot (either a plan-gated system bot or a MODEL bot like Claude Haiku 4.5) - the input area shows "Upgrade to Pro to chat with [Bot Name]" as a clickable link to the Whop checkout page
  5. Click the upgrade link - a new tab opens to the Whop sandbox checkout page
  6. Complete the checkout (sandbox payments are instant, no real charges)
  7. Return to the app and refresh - click our name in the sidebar and the settings popover now shows "Pro plan" instead of "Free plan"
  8. Select the previously locked bot - the lock icon is gone, the text input appears, and we can chat normally. Both MODEL bots (Claude, GPT) and plan-gated system bots are now accessible
  9. Send messages - paid users get 50 messages per day (free users get 20)
  10. Check the database - the Membership table has a row with our user ID, the plan ID, status: ACTIVE, and the Whop membership ID
  11. Push to GitHub - Vercel auto-deploys. Verify the webhook URL in the Whop dashboard points to production, and that WHOP_WEBHOOK_SECRET and WHOP_COMPANY_API_KEY are set on Vercel. Test the checkout flow on production
If the webhook isn't firing in local development, make sure we've set up a tunnel (ngrok or Cloudflare Tunnel) and updated the webhook URL in the Whop dashboard to point to our tunnel URL. Sandbox webhooks fire to whatever URL is configured - they don't automatically route to localhost.

In Part 5, we build the custom bot builder - subscribers whose plan has allowCustomBots enabled can create their own bots with personalized system prompts and knowledge bases.

Part 5: Custom bot builder

We have authentication, a catalog of system bots, streaming chat, and a payment system that gates premium bots behind plans. The one premium feature we promised but haven't built yet is custom bot creation - letting paying users build their own private chatbots with personalized system prompts and knowledge bases.

The Prisma schema already supports everything we need. The Bot model has a type field (SYSTEM or USER), a createdById foreign key to the User table, and the Plan model has an allowCustomBots boolean. No migration needed. We're building the user-facing UI and wiring it into the existing chat infrastructure.

Update access control

User bots are strictly private - only the creator can see and use them. We add an ownership guard to canAccessBot that matches createdById against the current user.

Callout: We use a limit of 2 custom bots for this tutorial's demo. Change USER_BOT_LIMIT to whatever fits our product.

Go to src/lib and update the membership.ts file with the content:

membership.ts
import { prisma } from "./prisma";

type UserPlan = {
  id: string;
  name: string;
  price: number;
  checkoutUrl: string;
  allowCustomBots: boolean;
};

export async function getUserPlan(userId: string): Promise<UserPlan | null> {
  const membership = await prisma.membership.findUnique({
    where: { userId },
    include: { plan: true },
  });

  if (!membership || membership.status !== "ACTIVE" || !membership.plan) {
    return null;
  }

  return {
    id: membership.plan.id,
    name: membership.plan.name,
    price: membership.plan.price,
    checkoutUrl: membership.plan.checkoutUrl,
    allowCustomBots: membership.plan.allowCustomBots,
  };
}

export function canAccessBot(
  bot: { planId: string | null; type?: string; createdById?: string | null; plan?: { price: number } | null },
  userPlan: { price: number } | null,
  currentUserId?: string
): boolean {
  if (bot.type === "MODEL") {
    return !!userPlan;
  }
  if (bot.type === "USER") {
    return !!currentUserId && bot.createdById === currentUserId;
  }
  if (!bot.planId) return true; // free bot
  if (!userPlan) return false; // free user, paid bot
  return userPlan.price >= (bot.plan?.price ?? 0);
}

export const USER_BOT_LIMIT = 2;
export const MAX_KNOWLEDGE_LENGTH = 50_000;

const FREE_DAILY_LIMIT = 20;
const PAID_DAILY_LIMIT = 50;
const FREE_CONVERSATION_LIMIT = 10;

export async function getMessageCountToday(userId: string): Promise<number> {
  const startOfDay = new Date();
  startOfDay.setHours(0, 0, 0, 0);

  return prisma.message.count({
    where: {
      conversation: { userId },
      role: "USER",
      createdAt: { gte: startOfDay },
    },
  });
}

export function isOverMessageLimit(
  count: number,
  userPlan: { price: number } | null
): boolean {
  const limit = userPlan ? PAID_DAILY_LIMIT : FREE_DAILY_LIMIT;
  return count >= limit;
}

export function getConversationLimit(
  userPlan: { price: number } | null
): number | undefined {
  return userPlan ? undefined : FREE_CONVERSATION_LIMIT;
}

Bot server actions

Unlike the admin bot actions, these validate all input with Zod since any authenticated user can call them. Deleting a bot cascades to its conversations and messages through onDelete: Cascade in the schema.

Go to src/app/bots and create a file called actions.ts with the content:

actions.ts
"use server";

import { z } from "zod";
import { redirect } from "next/navigation";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getUserPlan, USER_BOT_LIMIT, MAX_KNOWLEDGE_LENGTH } from "@/lib/membership";
import { SUPPORTED_MODELS } from "@/lib/ai";

const modelIds = SUPPORTED_MODELS.map((m) => m.id) as [string, ...string[]];

const botSchema = z.object({
  name: z.string().min(1).max(50),
  description: z.string().min(1).max(200),
  systemPrompt: z.string().min(1).max(5000),
  knowledge: z.string().max(MAX_KNOWLEDGE_LENGTH).optional(),
  model: z.enum(modelIds),
});

export async function createUserBot(formData: FormData) {
  const user = await requireAuth();
  if (!user) throw new Error("Unauthorized");

  const userPlan = await getUserPlan(user.id);
  if (!userPlan?.allowCustomBots) throw new Error("Custom bots not available on your plan");

  const count = await prisma.bot.count({
    where: { type: "USER", createdById: user.id },
  });
  if (count >= USER_BOT_LIMIT) throw new Error("Bot limit reached");

  const parsed = botSchema.parse({
    name: (formData.get("name") as string)?.trim(),
    description: (formData.get("description") as string)?.trim(),
    systemPrompt: (formData.get("systemPrompt") as string)?.trim(),
    knowledge: (formData.get("knowledge") as string)?.trim() || undefined,
    model: (formData.get("model") as string)?.trim(),
  });

  await prisma.bot.create({
    data: {
      ...parsed,
      knowledge: parsed.knowledge || null,
      type: "USER",
      createdById: user.id,
    },
  });

  redirect("/bots");
}

export async function updateUserBot(formData: FormData) {
  const user = await requireAuth();
  if (!user) throw new Error("Unauthorized");

  const botId = formData.get("botId") as string;
  if (!botId) throw new Error("Bot ID is required");

  const bot = await prisma.bot.findUnique({ where: { id: botId } });
  if (!bot || bot.type !== "USER" || bot.createdById !== user.id) {
    throw new Error("Bot not found");
  }

  const parsed = botSchema.parse({
    name: (formData.get("name") as string)?.trim(),
    description: (formData.get("description") as string)?.trim(),
    systemPrompt: (formData.get("systemPrompt") as string)?.trim(),
    knowledge: (formData.get("knowledge") as string)?.trim() || undefined,
    model: (formData.get("model") as string)?.trim(),
  });

  await prisma.bot.update({
    where: { id: botId },
    data: { ...parsed, knowledge: parsed.knowledge || null },
  });

  redirect("/bots");
}

export async function deleteUserBot(formData: FormData) {
  const user = await requireAuth();
  if (!user) throw new Error("Unauthorized");

  const botId = formData.get("botId") as string;
  if (!botId) throw new Error("Bot ID is required");

  const bot = await prisma.bot.findUnique({ where: { id: botId } });
  if (!bot || bot.type !== "USER" || bot.createdById !== user.id) {
    throw new Error("Bot not found");
  }

  await prisma.bot.delete({ where: { id: botId } });
  redirect("/bots");
}

Bot management page

Go to src/app/bots and create a file called page.tsx with the content:

page.tsx
import { redirect } from "next/navigation";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getUserPlan, USER_BOT_LIMIT } from "@/lib/membership";
import { deleteUserBot } from "./actions";
import { ArrowLeft, Plus, Bot as BotIcon, Pencil, Trash2 } from "lucide-react";

export default async function UserBotsPage() {
  const user = await requireAuth();
  if (!user) return null;

  const userPlan = await getUserPlan(user.id);
  if (!userPlan?.allowCustomBots) redirect("/chat");

  const bots = await prisma.bot.findMany({
    where: { type: "USER", createdById: user.id },
    orderBy: { createdAt: "desc" },
  });

  return (
    <div className="min-h-screen bg-zinc-50 p-8 dark:bg-zinc-950">
      <div className="mx-auto max-w-3xl">
        <a
          href="/chat"
          className="mb-6 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
        >
          <ArrowLeft className="h-4 w-4" />
          Back to chat
        </a>

        <div className="mb-8 flex items-center justify-between">
          <div>
            <h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
              My Bots
            </h1>
            <p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
              {bots.length} / {USER_BOT_LIMIT} bots
            </p>
          </div>
          {bots.length < USER_BOT_LIMIT && (
            <a
              href="/bots/new"
              className="flex items-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
            >
              <Plus className="h-4 w-4" />
              Create bot
            </a>
          )}
        </div>

        {bots.length === 0 ? (
          <div className="rounded-2xl border border-dashed border-zinc-300 py-16 text-center dark:border-zinc-700">
            <BotIcon className="mx-auto mb-3 h-10 w-10 text-zinc-300 dark:text-zinc-600" />
            <p className="text-sm text-zinc-500 dark:text-zinc-400">
              No custom bots yet. Create your first one!
            </p>
          </div>
        ) : (
          <div className="space-y-3">
            {bots.map((bot) => (
              <div
                key={bot.id}
                className="flex items-center justify-between rounded-xl border border-zinc-200 bg-white px-5 py-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
              >
                <div className="flex items-center gap-3">
                  <BotIcon className="h-5 w-5 text-zinc-400" />
                  <div>
                    <p className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
                      {bot.name}
                    </p>
                    <p className="text-xs text-zinc-500 dark:text-zinc-400">
                      {bot.description}
                    </p>
                  </div>
                </div>

                <div className="flex items-center gap-1">
                  <a
                    href={`/bots/${bot.id}/edit`}
                    className="rounded-lg p-2 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
                  >
                    <Pencil className="h-4 w-4" />
                  </a>
                  <form action={deleteUserBot}>
                    <input type="hidden" name="botId" value={bot.id} />
                    <button
                      type="submit"
                      className="rounded-lg p-2 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-red-500 dark:hover:bg-zinc-800"
                    >
                      <Trash2 className="h-4 w-4" />
                    </button>
                  </form>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

Bot creation form

Subscribers choose which model powers their custom bot. Claude for analytical tasks or ChatGPT for creative conversations. The form validates the selected model against SUPPORTED_MODELS from ai.ts, so adding a new provider later means updating one array.

Go to src/app/bots/new and create a file called page.tsx with the content:

page.tsx
import { redirect } from "next/navigation";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getUserPlan, USER_BOT_LIMIT, MAX_KNOWLEDGE_LENGTH } from "@/lib/membership";
import { createUserBot } from "../actions";
import { SUPPORTED_MODELS } from "@/lib/ai";
import { ArrowLeft } from "lucide-react";

export default async function NewBotPage() {
  const user = await requireAuth();
  if (!user) return null;

  const userPlan = await getUserPlan(user.id);
  if (!userPlan?.allowCustomBots) redirect("/chat");

  const count = await prisma.bot.count({
    where: { type: "USER", createdById: user.id },
  });

  if (count >= USER_BOT_LIMIT) {
    return (
      <div className="min-h-screen bg-zinc-50 p-8 dark:bg-zinc-950">
        <div className="mx-auto max-w-2xl">
          <a
            href="/bots"
            className="mb-6 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
          >
            <ArrowLeft className="h-4 w-4" />
            Back
          </a>
          <div className="rounded-2xl border border-amber-200 bg-amber-50 p-8 text-center dark:border-amber-900 dark:bg-amber-950/50">
            <p className="text-sm font-medium text-amber-800 dark:text-amber-200">
              You&apos;ve reached the limit of {USER_BOT_LIMIT} custom bots.
              Delete an existing bot to create a new one.
            </p>
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-zinc-50 p-8 dark:bg-zinc-950">
      <div className="mx-auto max-w-2xl">
        <a
          href="/bots"
          className="mb-6 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
        >
          <ArrowLeft className="h-4 w-4" />
          Back
        </a>

        <h1 className="mb-8 text-2xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
          Create a Bot
        </h1>

        <form
          action={createUserBot}
          className="space-y-6 rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
        >
          <div>
            <label
              htmlFor="name"
              className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
            >
              Name <span className="font-normal text-zinc-400">(max 50)</span>
            </label>
            <input
              type="text"
              id="name"
              name="name"
              required
              maxLength={50}
              className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
              placeholder="e.g. My Study Buddy"
            />
          </div>

          <div>
            <label
              htmlFor="description"
              className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
            >
              Description{" "}
              <span className="font-normal text-zinc-400">(max 200)</span>
            </label>
            <input
              type="text"
              id="description"
              name="description"
              required
              maxLength={200}
              className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
              placeholder="A one-sentence summary of what this bot does"
            />
          </div>

          <div>
            <label
              htmlFor="systemPrompt"
              className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
            >
              System prompt{" "}
              <span className="font-normal text-zinc-400">(max 5,000)</span>
            </label>
            <textarea
              id="systemPrompt"
              name="systemPrompt"
              required
              rows={6}
              maxLength={5000}
              className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
              placeholder="Define the bot's role, tone, and how it should respond..."
            />
          </div>

          <div>
            <label
              htmlFor="model"
              className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
            >
              Model
            </label>
            <select
              id="model"
              name="model"
              required
              className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
            >
              {SUPPORTED_MODELS.map((m) => (
                <option key={m.id} value={m.id}>
                  {m.label}
                </option>
              ))}
            </select>
          </div>

          <div>
            <label
              htmlFor="knowledge"
              className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
            >
              Knowledge{" "}
              <span className="font-normal text-zinc-400">
                (optional, max {MAX_KNOWLEDGE_LENGTH.toLocaleString()} chars)
              </span>
            </label>
            <textarea
              id="knowledge"
              name="knowledge"
              rows={4}
              maxLength={MAX_KNOWLEDGE_LENGTH}
              className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
              placeholder="Paste reference text the bot can draw from - notes, docs, FAQs..."
            />
          </div>

          <button
            type="submit"
            className="rounded-lg bg-zinc-900 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
          >
            Create bot
          </button>
        </form>
      </div>
    </div>
  );
}

Bot edit form

We need a way to let creators edit their bots after creation. Go to src/app/bots/[botId]/edit and create a file called page.tsx with the content:

page.tsx
import { redirect } from "next/navigation";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getUserPlan, MAX_KNOWLEDGE_LENGTH } from "@/lib/membership";
import { updateUserBot } from "../../actions";
import { SUPPORTED_MODELS } from "@/lib/ai";
import { ArrowLeft } from "lucide-react";

export default async function EditBotPage({
  params,
}: {
  params: Promise<{ botId: string }>;
}) {
  const user = await requireAuth();
  if (!user) return null;

  const userPlan = await getUserPlan(user.id);
  if (!userPlan?.allowCustomBots) redirect("/chat");

  const { botId } = await params;

  const bot = await prisma.bot.findUnique({ where: { id: botId } });
  if (!bot || bot.type !== "USER" || bot.createdById !== user.id) {
    redirect("/bots");
  }

  return (
    <div className="min-h-screen bg-zinc-50 p-8 dark:bg-zinc-950">
      <div className="mx-auto max-w-2xl">
        <a
          href="/bots"
          className="mb-6 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
        >
          <ArrowLeft className="h-4 w-4" />
          Back
        </a>

        <h1 className="mb-8 text-2xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
          Edit Bot
        </h1>

        <form
          action={updateUserBot}
          className="space-y-6 rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
        >
          <input type="hidden" name="botId" value={bot.id} />

          <div>
            <label
              htmlFor="name"
              className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
            >
              Name <span className="font-normal text-zinc-400">(max 50)</span>
            </label>
            <input
              type="text"
              id="name"
              name="name"
              required
              maxLength={50}
              defaultValue={bot.name}
              className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
            />
          </div>

          <div>
            <label
              htmlFor="description"
              className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
            >
              Description{" "}
              <span className="font-normal text-zinc-400">(max 200)</span>
            </label>
            <input
              type="text"
              id="description"
              name="description"
              required
              maxLength={200}
              defaultValue={bot.description}
              className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
            />
          </div>

          <div>
            <label
              htmlFor="systemPrompt"
              className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
            >
              System prompt{" "}
              <span className="font-normal text-zinc-400">(max 5,000)</span>
            </label>
            <textarea
              id="systemPrompt"
              name="systemPrompt"
              required
              rows={6}
              maxLength={5000}
              defaultValue={bot.systemPrompt}
              className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
            />
          </div>

          <div>
            <label
              htmlFor="model"
              className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
            >
              Model
            </label>
            <select
              id="model"
              name="model"
              required
              defaultValue={bot.model || "claude-haiku-4-5-20251001"}
              className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
            >
              {SUPPORTED_MODELS.map((m) => (
                <option key={m.id} value={m.id}>
                  {m.label}
                </option>
              ))}
            </select>
          </div>

          <div>
            <label
              htmlFor="knowledge"
              className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
            >
              Knowledge{" "}
              <span className="font-normal text-zinc-400">
                (optional, max {MAX_KNOWLEDGE_LENGTH.toLocaleString()} chars)
              </span>
            </label>
            <textarea
              id="knowledge"
              name="knowledge"
              rows={4}
              maxLength={MAX_KNOWLEDGE_LENGTH}
              defaultValue={bot.knowledge ?? ""}
              className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
            />
          </div>

          <button
            type="submit"
            className="rounded-lg bg-zinc-900 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
          >
            Save changes
          </button>
        </form>
      </div>
    </div>
  );
}

Integrate user bots into the chat

The chat pages need to fetch USER and MODEL bots alongside SYSTEM bots. MODEL bots give subscribers direct access to raw LLMs. Go to src/app/chat and update the page.tsx content with:

page.tsx
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getUserPlan } from "@/lib/membership";
import { ChatArea } from "./_components/chat-area";

export default async function NewChatPage({
  searchParams,
}: {
  searchParams: Promise<{ bot?: string }>;
}) {
  const user = await requireAuth();
  if (!user) return null;

  const { bot: botParam } = await searchParams;
  const userPlan = await getUserPlan(user.id);

  const bots = await prisma.bot.findMany({
    where: {
      OR: [
        { type: "MODEL" },
        { type: "SYSTEM" },
        { type: "USER", createdById: user.id },
      ],
    },
    orderBy: { createdAt: "asc" },
    select: {
      id: true,
      name: true,
      description: true,
      type: true,
      createdById: true,
      planId: true,
      model: true,
      plan: { select: { price: true, name: true, checkoutUrl: true } },
    },
  });

  return (
    <ChatArea
      bots={bots}
      initialConversationId={null}
      initialMessages={[]}
      initialBotId={botParam || null}
      conversationBotId={null}
      userPlan={userPlan}
      userId={user.id}
      allowCustomBots={!!userPlan?.allowCustomBots}
    />
  );
}

Apply the same changes to src/app/chat/[conversationId]/page.tsx:

page.tsx
import { redirect } from "next/navigation";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getUserPlan } from "@/lib/membership";
import { ChatArea } from "../_components/chat-area";

export default async function ConversationPage({
  params,
}: {
  params: Promise<{ conversationId: string }>;
}) {
  const user = await requireAuth();
  if (!user) return null;

  const { conversationId } = await params;

  const conversation = await prisma.conversation.findUnique({
    where: { id: conversationId },
    include: {
      messages: {
        orderBy: { createdAt: "asc" },
        select: { id: true, role: true, content: true },
      },
      bot: { select: { id: true } },
    },
  });

  if (!conversation || conversation.userId !== user.id) {
    redirect("/chat");
  }

  const userPlan = await getUserPlan(user.id);

  const bots = await prisma.bot.findMany({
    where: {
      OR: [
        { type: "MODEL" },
        { type: "SYSTEM" },
        { type: "USER", createdById: user.id },
      ],
    },
    orderBy: { createdAt: "asc" },
    select: {
      id: true,
      name: true,
      description: true,
      type: true,
      createdById: true,
      planId: true,
      model: true,
      plan: { select: { price: true, name: true, checkoutUrl: true } },
    },
  });

  return (
    <ChatArea
      bots={bots}
      initialConversationId={conversationId}
      initialMessages={conversation.messages}
      initialBotId={conversation.bot.id}
      conversationBotId={conversation.bot.id}
      userPlan={userPlan}
      userId={user.id}
      allowCustomBots={!!userPlan?.allowCustomBots}
    />
  );
}

Replace the bot selector with a grouped dropdown

The native <select> can't render grouped sections with dividers or action links, so we switch to a custom dropdown. The dropdown now has three sections: Models (raw LLM access, locked for free users), System Bots, and My Bots. Go to src/app/chat/_components and update the chat-area.tsx with the content:

chat-area.tsx
"use client";

interface SpeechRecognitionEvent extends Event {
  results: SpeechRecognitionResultList;
  resultIndex: number;
}

interface SpeechRecognitionErrorEvent extends Event {
  error: string;
}

interface SpeechRecognition extends EventTarget {
  continuous: boolean;
  interimResults: boolean;
  lang: string;
  start(): void;
  stop(): void;
  abort(): void;
  onresult: ((event: SpeechRecognitionEvent) => void) | null;
  onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
  onend: (() => void) | null;
}

declare global {
  interface Window {
    SpeechRecognition: new () => SpeechRecognition;
    webkitSpeechRecognition: new () => SpeechRecognition;
  }
}

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useRouter } from "next/navigation";
import { useState, useRef, useEffect, useMemo } from "react";
import { Send, Lock, Bot as BotIcon, User, ChevronDown, Plus, Mic, MicOff } from "lucide-react";

type BotPlan = {
  price: number;
  name: string;
  checkoutUrl: string;
};

type Bot = {
  id: string;
  name: string;
  description: string;
  type: string;
  createdById: string | null;
  planId: string | null;
  model: string | null;
  plan: BotPlan | null;
};

type UserPlan = {
  price: number;
  name: string;
} | null;

type DBMessage = {
  id: string;
  role: "USER" | "ASSISTANT";
  content: string;
};

function dbToUIMessages(msgs: DBMessage[]) {
  return msgs.map((m) => ({
    id: m.id,
    role: m.role.toLowerCase() as "user" | "assistant",
    parts: [{ type: "text" as const, text: m.content }],
  }));
}

function canAccessBot(bot: Bot, userPlan: UserPlan, userId?: string): boolean {
  if (bot.type === "MODEL") return !!userPlan;
  if (bot.type === "USER") {
    return !!userId && bot.createdById === userId;
  }
  if (!bot.planId) return true;
  if (!userPlan) return false;
  return userPlan.price >= (bot.plan?.price ?? 0);
}

export function ChatArea({
  bots,
  initialConversationId,
  initialMessages,
  initialBotId,
  conversationBotId,
  userPlan,
  userId,
  allowCustomBots,
}: {
  bots: Bot[];
  initialConversationId: string | null;
  initialMessages: DBMessage[];
  initialBotId: string | null;
  conversationBotId: string | null;
  userPlan: UserPlan;
  userId: string;
  allowCustomBots: boolean;
}) {
  const router = useRouter();
  const scrollRef = useRef<HTMLDivElement>(null);
  const dropdownRef = useRef<HTMLDivElement>(null);
  const [conversationId, setConversationId] = useState(initialConversationId);
  const [input, setInput] = useState("");
  const [selectedBotId, setSelectedBotId] = useState(() => {
    if (initialBotId) return initialBotId;
    if (conversationBotId) return conversationBotId;
    if (userPlan) {
      const firstModel = bots.find((b) => b.type === "MODEL");
      if (firstModel) return firstModel.id;
    }
    const firstFreeSystem = bots.find((b) => b.type === "SYSTEM" && !b.planId);
    return firstFreeSystem?.id || bots[0]?.id || "";
  });
  const [dropdownOpen, setDropdownOpen] = useState(false);
  const [pendingMessage, setPendingMessage] = useState<string | null>(null);
  const [isRecording, setIsRecording] = useState(false);
  const [speechSupported, setSpeechSupported] = useState(false);
  const recognitionRef = useRef<SpeechRecognition | null>(null);
  const inputBeforeSpeechRef = useRef("");
  const wantsRecordingRef = useRef(false);

  const modelBots = useMemo(() => bots.filter((b) => b.type === "MODEL"), [bots]);
  const systemBots = useMemo(() => bots.filter((b) => b.type === "SYSTEM"), [bots]);
  const userBots = useMemo(() => bots.filter((b) => b.type === "USER"), [bots]);

  const selectedBot = bots.find((b) => b.id === selectedBotId);
  const isSwitchingBot =
    conversationBotId && selectedBotId !== conversationBotId;
  const canUseBot = selectedBot ? canAccessBot(selectedBot, userPlan, userId) : false;

  useEffect(() => {
    function handleClick(e: MouseEvent) {
      if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
        setDropdownOpen(false);
      }
    }
    if (dropdownOpen) document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, [dropdownOpen]);

  const conversationIdRef = useRef(conversationId);
  conversationIdRef.current = conversationId;

  const customFetch: typeof globalThis.fetch = async (input, init) => {
    const response = await globalThis.fetch(input, init);
    const newId = response.headers.get("X-Conversation-Id");
    if (newId && !conversationIdRef.current) {
      setConversationId(newId);
      window.history.replaceState(null, "", `/chat/${newId}`);
    }
    return response;
  };

  const transport = useMemo(
    () => new DefaultChatTransport({ fetch: customFetch }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  );

  const { messages, sendMessage, status, error } = useChat({
    id: conversationId || undefined,
    messages: dbToUIMessages(initialMessages),
    transport,
    onFinish: () => {
      router.refresh();
    },
    onError: (error) => {
      console.error("Chat error:", error);
    },
  });

  const isLoading = status === "streaming" || status === "submitted";

  useEffect(() => {
    if (scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [messages, pendingMessage]);

  useEffect(() => {
    if (pendingMessage && messages.length > 0) {
      const last = messages[messages.length - 1];
      if (last.role === "user") {
        setPendingMessage(null);
      }
    }
  }, [messages, pendingMessage]);

  useEffect(() => {
    setSpeechSupported(
      typeof window !== "undefined" &&
        !!(window.SpeechRecognition || window.webkitSpeechRecognition)
    );
  }, []);

  useEffect(() => {
    return () => {
      wantsRecordingRef.current = false;
      recognitionRef.current?.abort();
    };
  }, []);

  function toggleRecording() {
    if (isRecording) {
      wantsRecordingRef.current = false;
      recognitionRef.current?.stop();
      return;
    }

    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
    const recognition = new SR() as any;
    recognition.continuous = true;
    recognition.interimResults = true;
    recognition.lang = "en-US";
    recognitionRef.current = recognition;
    inputBeforeSpeechRef.current = input;
    wantsRecordingRef.current = true;

    recognition.addEventListener("result", (e: any) => {
      let final = "";
      let interim = "";
      for (let i = 0; i < e.results.length; i++) {
        if (e.results[i].isFinal) {
          final += e.results[i][0].transcript;
        } else {
          interim += e.results[i][0].transcript;
        }
      }
      const prefix = inputBeforeSpeechRef.current;
      const space = prefix && !prefix.endsWith(" ") ? " " : "";
      setInput(prefix + space + final + interim);
    });

    recognition.addEventListener("error", (e: any) => {
      if (e.error === "no-speech") return;
      console.error("Speech error:", e.error);
      wantsRecordingRef.current = false;
      setIsRecording(false);
    });

    recognition.addEventListener("end", () => {
      if (wantsRecordingRef.current) {
        recognition.start();
      } else {
        setIsRecording(false);
      }
    });

    recognition.start();
    setIsRecording(true);
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim() || isLoading || !canUseBot) return;
    if (isRecording) {
      wantsRecordingRef.current = false;
      recognitionRef.current?.stop();
    }

    const text = input;
    setInput("");
    setPendingMessage(text);

    await sendMessage(
      { text },
      {
        body: {
          botId: selectedBotId,
          conversationId: isSwitchingBot ? undefined : conversationId,
        },
      }
    );
  };

  function getTextContent(message: (typeof messages)[0]): string {
    return message.parts
      .filter((p): p is { type: "text"; text: string } => p.type === "text")
      .map((p) => p.text)
      .join("");
  }

  const requiredPlan = selectedBot?.plan;

  return (
    <div className="flex h-full flex-col">
      {/* Bot selector */}
      <div className="flex items-center gap-3 border-b border-zinc-200 px-6 py-3 dark:border-zinc-800">
        <div className="relative" ref={dropdownRef}>
          <button
            onClick={() => setDropdownOpen(!dropdownOpen)}
            className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
          >
            {selectedBot?.name || "Select a bot"}
            <ChevronDown className="h-3.5 w-3.5 text-zinc-400" />
          </button>

          {dropdownOpen && (
            <div className="absolute left-0 top-full z-10 mt-1 w-64 overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
              <div className="max-h-80 overflow-y-auto p-1">
                {modelBots.length > 0 && (
                  <>
                    <p className="px-3 py-1 text-xs font-medium text-zinc-400">
                      Models
                    </p>
                    {modelBots.map((bot) => {
                      const locked = !canAccessBot(bot, userPlan, userId);
                      return (
                        <button
                          key={bot.id}
                          onClick={() => {
                            if (!locked) {
                              setSelectedBotId(bot.id);
                              setDropdownOpen(false);
                            }
                          }}
                          disabled={locked}
                          className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
                            locked
                              ? "cursor-not-allowed text-zinc-400 dark:text-zinc-500"
                              : bot.id === selectedBotId
                                ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
                                : "text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700/50"
                          }`}
                        >
                          <span className="truncate">{bot.name}</span>
                          {locked && (
                            <Lock className="ml-auto h-3.5 w-3.5 shrink-0 text-zinc-400" />
                          )}
                        </button>
                      );
                    })}
                  </>
                )}

                {systemBots.length > 0 && (
                  <>
                    {modelBots.length > 0 && (
                      <div className="mx-2 my-1 border-t border-zinc-200 dark:border-zinc-700" />
                    )}
                    <p className="px-3 py-1 text-xs font-medium text-zinc-400">
                      System Bots
                    </p>
                    {systemBots.map((bot) => (
                      <button
                        key={bot.id}
                        onClick={() => {
                          setSelectedBotId(bot.id);
                          setDropdownOpen(false);
                        }}
                        className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
                          bot.id === selectedBotId
                            ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
                            : "text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700/50"
                        }`}
                      >
                        <span className="truncate">{bot.name}</span>
                        {!canAccessBot(bot, userPlan, userId) && (
                          <Lock className="ml-auto h-3.5 w-3.5 shrink-0 text-zinc-400" />
                        )}
                      </button>
                    ))}
                  </>
                )}

                {userBots.length > 0 && (
                  <>
                    <div className="mx-2 my-1 border-t border-zinc-200 dark:border-zinc-700" />
                    <p className="px-3 py-1 text-xs font-medium text-zinc-400">
                      My Bots
                    </p>
                    {userBots.map((bot) => (
                      <button
                        key={bot.id}
                        onClick={() => {
                          setSelectedBotId(bot.id);
                          setDropdownOpen(false);
                        }}
                        className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
                          bot.id === selectedBotId
                            ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
                            : "text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700/50"
                        }`}
                      >
                        <span className="truncate">{bot.name}</span>
                      </button>
                    ))}
                  </>
                )}

                {allowCustomBots && (
                  <>
                    <div className="mx-2 my-1 border-t border-zinc-200 dark:border-zinc-700" />
                    <a
                      href="/bots/new"
                      className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-indigo-600 transition-colors hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-950/30"
                    >
                      <Plus className="h-3.5 w-3.5" />
                      Create bot
                    </a>
                  </>
                )}
              </div>
            </div>
          )}
        </div>

        {selectedBot && (
          <span className="text-xs text-zinc-400">
            {selectedBot.description}
          </span>
        )}
      </div>

      {/* Bot switch warning */}
      {isSwitchingBot && (
        <div className="border-b border-amber-200 bg-amber-50 px-6 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-200">
          Sending a message will start a new chat with{" "}
          <strong>{selectedBot?.name}</strong>
        </div>
      )}

      {/* Messages */}
      <div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-4">
        {messages.length === 0 && !pendingMessage ? (
          <div className="flex h-full flex-col items-center justify-center text-center">
            <BotIcon className="mb-4 h-12 w-12 text-zinc-300 dark:text-zinc-600" />
            <h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
              {selectedBot?.name || "Select a bot"}
            </h2>
            <p className="mt-1 max-w-sm text-sm text-zinc-500">
              {selectedBot?.description || "Choose a bot to start chatting"}
            </p>
          </div>
        ) : (
          <div className="mx-auto max-w-3xl space-y-6">
            {messages.map((message) => (
              <div key={message.id} className="flex gap-3">
                <div
                  className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${
                    message.role === "user"
                      ? "bg-zinc-200 dark:bg-zinc-700"
                      : "bg-zinc-900 dark:bg-zinc-100"
                  }`}
                >
                  {message.role === "user" ? (
                    <User className="h-4 w-4 text-zinc-600 dark:text-zinc-300" />
                  ) : (
                    <BotIcon className="h-4 w-4 text-white dark:text-zinc-900" />
                  )}
                </div>
                <div className="min-w-0 flex-1 pt-1">
                  <p className="mb-1 text-xs font-medium text-zinc-500">
                    {message.role === "user"
                      ? "You"
                      : selectedBot?.name || "Bot"}
                  </p>
                  <div className="prose prose-sm prose-zinc max-w-none dark:prose-invert">
                    <p className="whitespace-pre-wrap">
                      {getTextContent(message)}
                    </p>
                  </div>
                </div>
              </div>
            ))}
            {pendingMessage && (
              <div className="flex gap-3">
                <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 dark:bg-zinc-700">
                  <User className="h-4 w-4 text-zinc-600 dark:text-zinc-300" />
                </div>
                <div className="min-w-0 flex-1 pt-1">
                  <p className="mb-1 text-xs font-medium text-zinc-500">You</p>
                  <div className="prose prose-sm prose-zinc max-w-none dark:prose-invert">
                    <p className="whitespace-pre-wrap">{pendingMessage}</p>
                  </div>
                </div>
              </div>
            )}
            {(pendingMessage || (isLoading &&
              messages[messages.length - 1]?.role === "user")) && (
                <div className="flex gap-3">
                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-900 dark:bg-zinc-100">
                    <BotIcon className="h-4 w-4 text-white dark:text-zinc-900" />
                  </div>
                  <div className="pt-1">
                    <p className="mb-1 text-xs font-medium text-zinc-500">
                      {selectedBot?.name || "Bot"}
                    </p>
                    <div className="flex gap-1">
                      <span className="h-2 w-2 animate-bounce rounded-full bg-zinc-400 [animation-delay:0ms]" />
                      <span className="h-2 w-2 animate-bounce rounded-full bg-zinc-400 [animation-delay:150ms]" />
                      <span className="h-2 w-2 animate-bounce rounded-full bg-zinc-400 [animation-delay:300ms]" />
                    </div>
                  </div>
                </div>
              )}
          </div>
        )}
      </div>

      {/* Error display */}
      {status === "error" && (
        <div className="border-t border-red-200 bg-red-50 px-6 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-300">
          {error?.message?.includes("Daily message limit")
            ? "You've hit your daily message limit. Upgrade for more messages."
            : error?.message?.includes("Upgrade required")
              ? "This bot requires a paid plan."
              : "Something went wrong. Please try again."}
        </div>
      )}

      {/* Input */}
      <div className="border-t border-zinc-200 px-6 py-4 dark:border-zinc-800">
        {!canUseBot ? (
          <a
            href={requiredPlan?.checkoutUrl || "#"}
            target="_blank"
            rel="noopener noreferrer"
            className="flex items-center justify-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-800 transition-colors hover:bg-amber-100 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-200 dark:hover:bg-amber-950"
          >
            <Lock className="h-4 w-4" />
            Upgrade to {requiredPlan?.name || "a paid plan"} to chat with{" "}
            {selectedBot?.name}
          </a>
        ) : (
          <form
            onSubmit={handleSubmit}
            className="mx-auto flex max-w-3xl items-center gap-2"
          >
            <div className="flex flex-1 items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-800 dark:focus-within:border-zinc-500">
              <textarea
                value={input}
                onChange={(e) => setInput(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === "Enter" && !e.shiftKey) {
                    e.preventDefault();
                    handleSubmit(e);
                  }
                }}
                placeholder="Send a message..."
                rows={1}
                className="flex-1 resize-none bg-transparent px-4 py-2.5 text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none dark:text-zinc-100"
              />
              {speechSupported && (
                <button
                  type="button"
                  onClick={toggleRecording}
                  className="mr-2 shrink-0 rounded-md p-1.5 transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-700"
                >
                  {isRecording ? (
                    <MicOff className="h-4 w-4 animate-pulse text-red-500" />
                  ) : (
                    <Mic className="h-4 w-4 text-zinc-400" />
                  )}
                </button>
              )}
            </div>
            <button
              type="submit"
              disabled={isLoading || !input.trim()}
              className="self-stretch rounded-lg bg-zinc-900 px-2.5 text-white transition-colors hover:bg-zinc-800 disabled:opacity-40 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
            >
              <Send className="h-4 w-4" />
            </button>
          </form>
        )}
      </div>
    </div>
  );
}

Update the API route

In src/app/api/chat/route.ts, pass the user's ID to the access check so it can handle ownership for custom bots. Change:

route.ts
if (!canAccessBot(bot, userPlan)) {

to:

route.ts
if (!canAccessBot(bot, userPlan, user.id)) {

Go to src/app/chat and update the layout.tsx content with:

layout.tsx
import { requireAuth } from "@/lib/auth";
import { isAdmin } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import { getUserPlan, getConversationLimit } from "@/lib/membership";
import { Sidebar } from "./_components/sidebar";

export default async function ChatLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await requireAuth();
  if (!user) return null;

  const [userPlan, admin] = await Promise.all([
    getUserPlan(user.id),
    isAdmin(),
  ]);

  const limit = getConversationLimit(userPlan);

  const [conversations, cheapestPlan] = await Promise.all([
    prisma.conversation.findMany({
      where: { userId: user.id },
      orderBy: { updatedAt: "desc" },
      take: limit,
      select: {
        id: true,
        title: true,
        updatedAt: true,
        bot: { select: { name: true } },
      },
    }),
    !userPlan
      ? prisma.plan.findFirst({
          where: { isActive: true },
          orderBy: { price: "asc" },
          select: { name: true, price: true, checkoutUrl: true },
        })
      : null,
  ]);

  const serialized = conversations.map((c) => ({
    ...c,
    updatedAt: c.updatedAt.toISOString(),
  }));

  return (
    <div className="flex h-screen bg-zinc-50 dark:bg-zinc-950">
      <Sidebar
        conversations={serialized}
        user={{ name: user.name, avatarUrl: user.avatarUrl }}
        userPlan={userPlan ? { name: userPlan.name, price: userPlan.price, allowCustomBots: userPlan.allowCustomBots } : null}
        isAdmin={admin}
        cheapestPlan={cheapestPlan}
      />
      <main className="flex-1">{children}</main>
    </div>
  );
}

Go to src/app/chat/_components and update the sidebar.tsx content with:

sidebar.tsx
"use client";

import { useState, useRef, useEffect } from "react";
import { usePathname } from "next/navigation";
import {
  Plus,
  MessageSquare,
  Settings,
  LogOut,
  Zap,
  Bot,
  CreditCard,
  Sparkles,
} from "lucide-react";

type ConversationItem = {
  id: string;
  title: string | null;
  bot: { name: string };
  updatedAt: string;
};

type SidebarProps = {
  conversations: ConversationItem[];
  user: { name: string | null; avatarUrl: string | null };
  userPlan: { name: string; price: number; allowCustomBots?: boolean } | null;
  isAdmin: boolean;
  cheapestPlan: { name: string; price: number; checkoutUrl: string } | null;
};

export function Sidebar({
  conversations,
  user,
  userPlan,
  isAdmin,
  cheapestPlan,
}: SidebarProps) {
  const pathname = usePathname();
  const [settingsOpen, setSettingsOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    function handleClick(e: MouseEvent) {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        setSettingsOpen(false);
      }
    }
    if (settingsOpen) document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, [settingsOpen]);

  return (
    <aside className="flex h-full w-72 flex-col border-r border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
      <div className="p-4">
        <a
          href="/chat"
          className="flex w-full items-center justify-center gap-2 rounded-lg border border-zinc-200 px-4 py-2.5 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
        >
          <Plus className="h-4 w-4" />
          New chat
        </a>
      </div>

      <nav className="flex-1 overflow-y-auto px-2 pb-4">
        {conversations.length === 0 ? (
          <p className="px-3 py-8 text-center text-xs text-zinc-400">
            No conversations yet
          </p>
        ) : (
          <ul className="space-y-0.5">
            {conversations.map((conv) => {
              const isActive = pathname === `/chat/${conv.id}`;
              return (
                <li key={conv.id}>
                  <a
                    href={`/chat/${conv.id}`}
                    className={`flex items-start gap-2 rounded-lg px-3 py-2 text-sm transition-colors ${
                      isActive
                        ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50"
                        : "text-zinc-600 hover:bg-zinc-50 dark:text-zinc-400 dark:hover:bg-zinc-800/50"
                    }`}
                  >
                    <MessageSquare className="mt-0.5 h-4 w-4 shrink-0" />
                    <div className="min-w-0">
                      <p className="truncate font-medium">
                        {conv.title || "New chat"}
                      </p>
                      <p className="truncate text-xs text-zinc-400">
                        {conv.bot.name}
                      </p>
                    </div>
                  </a>
                </li>
              );
            })}
          </ul>
        )}
      </nav>

      <div
        className="relative border-t border-zinc-200 p-3 dark:border-zinc-800"
        ref={menuRef}
      >
        {settingsOpen && (
          <div className="absolute bottom-full left-3 right-3 mb-2 overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
            <div className="border-b border-zinc-100 px-3 py-2 dark:border-zinc-700">
              <p className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
                {userPlan ? userPlan.name : "Free"} plan
              </p>
            </div>

            <div className="p-1">
              {cheapestPlan && (
                <a
                  href={cheapestPlan.checkoutUrl}
                  target="_blank"
                  rel="noopener noreferrer"
                  className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-amber-700 transition-colors hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950/30"
                >
                  <Zap className="h-4 w-4" />
                  Upgrade to {cheapestPlan.name} - $
                  {(cheapestPlan.price / 100).toFixed(0)}/mo
                </a>
              )}

              {userPlan?.allowCustomBots && (
                <a
                  href="/bots"
                  className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
                >
                  <Sparkles className="h-4 w-4" />
                  My Bots
                </a>
              )}

              {isAdmin && (
                <>
                  <a
                    href="/admin/bots"
                    className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
                  >
                    <Bot className="h-4 w-4" />
                    Manage bots
                  </a>
                  <a
                    href="/admin/plans"
                    className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
                  >
                    <CreditCard className="h-4 w-4" />
                    Manage plans
                  </a>
                </>
              )}

              <form action="/api/auth/logout" method="post">
                <button
                  type="submit"
                  className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
                >
                  <LogOut className="h-4 w-4" />
                  Sign out
                </button>
              </form>
            </div>
          </div>
        )}

        <button
          onClick={() => setSettingsOpen(!settingsOpen)}
          className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-zinc-600 transition-colors hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800"
        >
          {user.avatarUrl ? (
            <img
              src={user.avatarUrl}
              alt={user.name ?? "Avatar"}
              className="h-6 w-6 rounded-full"
            />
          ) : (
            <Settings className="h-4 w-4" />
          )}
          <span className="truncate">{user.name ?? "Settings"}</span>
        </button>
      </div>
    </aside>
  );
}

Checkpoint: custom bot builder

  1. Sign in as a user whose plan has allowCustomBots enabled - "My Bots" appears in the sidebar popover
  2. Create a bot with a name, description, system prompt, and some knowledge text
  3. Open the bot dropdown in /chat - our bot appears under a "My Bots" section and responds using its system prompt and knowledge
  4. Edit the bot's knowledge, start a new conversation - responses reflect the update
  5. Delete the bot - it disappears from the list, the dropdown, and the sidebar
  6. Sign in as a free user - "My Bots" doesn't appear, and /bots redirects to /chat
  7. Push to GitHub and test on production

In Part 6, we open the front door to unauthenticated visitors, add conversation management, make the UI mobile-friendly, and deploy to production.

Part 6: Polish and production

We have authentication, streaming chat, paid plans, custom bots - a complete SaaS. But every page hides behind a login wall. A visitor who's never heard of ChatForge lands on the site and immediately hits "Sign in with Whop" before seeing a single bot. That's not how you sell a product.

In this final part we open the front door. Unauthenticated visitors can browse bots, read descriptions, and see the full interface - the gate is on sending a message, not viewing the page.

We also add conversation management (rename and delete), make the sidebar responsive on mobile, add error boundaries, and walk through the production deploy.

Unauthenticated browsing

Most SaaS apps hide everything behind login. The assumption is that authentication comes first, value comes second. But for a chat product, the interface is the pitch.

Letting visitors browse bots and see the UI before committing lowers the barrier to entry. The signup prompt appears when they try to do something meaningful - send a message - not when they first arrive.

Open the chat routes

The middleware currently redirects unauthenticated users to /sign-in for every non-public path. We need /chat and all its sub-routes in the public list. Individual conversation pages still call requireAuth() with a redirect, so conversations remain protected - the middleware just stops blocking the initial page load.

Go to src and update the middleware.ts file with the content:

middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const publicPaths = ["/sign-in", "/api/auth/", "/api/webhooks/", "/chat"];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  if (publicPaths.some((p) => pathname.startsWith(p))) {
    return NextResponse.next();
  }

  const session = request.cookies.get("chatforge_session");
  if (!session) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

While we're updating auth redirects, the logout route should send users back to /chat instead of /sign-in since the chat page is now publicly accessible.

Go to src/app/api/auth/logout and update the route.ts file with the content:

route.ts
import { NextResponse, NextRequest } from "next/server";
import { getSession } from "@/lib/session";

export async function POST(request: NextRequest) {
  const session = await getSession();
  session.destroy();
  return NextResponse.redirect(new URL("/chat", request.url), 303);
}

Sign-in modal

When an unauthenticated visitor clicks the message input or the "New chat" button, we show a modal dialog prompting them to sign in. The visual pattern matches the plans modal from the sidebar - fixed overlay, backdrop blur, centered card.

Go to src/app/chat/_components and create a file called sign-in-modal.tsx with the content:

sign-in-modal.tsx
"use client";

import { X, LogIn } from "lucide-react";

export function SignInModal({
  isOpen,
  onClose,
}: {
  isOpen: boolean;
  onClose: () => void;
}) {
  if (!isOpen) return null;

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div
        className="absolute inset-0 bg-black/40 backdrop-blur-sm"
        onClick={onClose}
      />
      <div className="relative mx-4 w-full max-w-sm rounded-2xl border border-zinc-200 bg-white p-8 shadow-2xl dark:border-zinc-700 dark:bg-zinc-900">
        <button
          onClick={onClose}
          className="absolute right-4 top-4 rounded-lg p-1 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
        >
          <X className="h-5 w-5" />
        </button>

        <div className="mb-6 text-center">
          <h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
            Sign in to start chatting
          </h2>
          <p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
            Create an account or sign in to send messages and save
            conversations.
          </p>
        </div>

        <a
          href="/api/auth/login"
          className="flex w-full items-center justify-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
        >
          <LogIn className="h-4 w-4" />
          Sign in with Whop
        </a>
      </div>
    </div>
  );
}

Update the layout for optional auth

The layout needs to work for both signed-in and anonymous visitors. Signed-in users get the full experience; anonymous visitors still see plans so they can browse pricing before committing.

Go to src/app/chat and update the layout.tsx file with the content:

layout.tsx
import { requireAuth } from "@/lib/auth";
import { isAdmin } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import { getUserPlan, getConversationLimit } from "@/lib/membership";
import { Sidebar } from "./_components/sidebar";
import { ChatShell } from "./_components/chat-shell";

export default async function ChatLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await requireAuth({ redirect: false });

  let userPlan: Awaited<ReturnType<typeof getUserPlan>> = null;
  let admin = false;
  let conversations: {
    id: string;
    title: string | null;
    updatedAt: string;
    bot: { name: string };
  }[] = [];
  let plans: {
    name: string;
    price: number;
    checkoutUrl: string;
    allowCustomBots: boolean;
    whopPlanId: string;
  }[] = [];

  if (user) {
    [userPlan, admin] = await Promise.all([
      getUserPlan(user.id),
      isAdmin(),
    ]);

    const limit = getConversationLimit(userPlan);

    const [rawConversations, rawPlans] = await Promise.all([
      prisma.conversation.findMany({
        where: { userId: user.id },
        orderBy: { updatedAt: "desc" },
        take: limit,
        select: {
          id: true,
          title: true,
          updatedAt: true,
          bot: { select: { name: true } },
        },
      }),
      !userPlan
        ? prisma.plan.findMany({
            where: { isActive: true },
            orderBy: { price: "asc" },
            select: { name: true, price: true, checkoutUrl: true, allowCustomBots: true, whopPlanId: true },
          })
        : [],
    ]);

    conversations = rawConversations.map((c) => ({
      ...c,
      updatedAt: c.updatedAt.toISOString(),
    }));
    plans = rawPlans;
  } else {
    plans = await prisma.plan.findMany({
      where: { isActive: true },
      orderBy: { price: "asc" },
      select: { name: true, price: true, checkoutUrl: true, allowCustomBots: true, whopPlanId: true },
    });
  }

  return (
    <ChatShell
      sidebar={
        <Sidebar
          conversations={conversations}
          user={user ? { name: user.name, avatarUrl: user.avatarUrl } : null}
          userPlan={userPlan ? { name: userPlan.name, price: userPlan.price, allowCustomBots: userPlan.allowCustomBots } : null}
          isAdmin={admin}
          plans={plans}
          isAuthenticated={!!user}
        />
      }
    >
      {children}
    </ChatShell>
  );
}

Update the chat page

The chat page follows the same optional auth pattern - system and model bots visible to everyone, user-specific data only loaded when signed in.

The [conversationId]/page.tsx route still calls requireAuth() with a redirect, so conversation URLs remain fully protected. The only change is adding MODEL to the bot query and model to the select.

Go to src/app/chat/[conversationId] and update the page.tsx file with the content:

page.tsx
import { redirect } from "next/navigation";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getUserPlan } from "@/lib/membership";
import { ChatArea } from "../_components/chat-area";

export default async function ConversationPage({
  params,
}: {
  params: Promise<{ conversationId: string }>;
}) {
  const user = await requireAuth();
  if (!user) return null;

  const { conversationId } = await params;

  const conversation = await prisma.conversation.findUnique({
    where: { id: conversationId },
    include: {
      messages: {
        orderBy: { createdAt: "asc" },
        select: { id: true, role: true, content: true },
      },
      bot: { select: { id: true } },
    },
  });

  if (!conversation || conversation.userId !== user.id) {
    redirect("/chat");
  }

  const userPlan = await getUserPlan(user.id);

  const bots = await prisma.bot.findMany({
    where: {
      OR: [
        { type: "MODEL" },
        { type: "SYSTEM" },
        { type: "USER", createdById: user.id },
      ],
    },
    orderBy: { createdAt: "asc" },
    select: {
      id: true,
      name: true,
      description: true,
      type: true,
      createdById: true,
      planId: true,
      model: true,
      plan: { select: { price: true, name: true, checkoutUrl: true } },
    },
  });

  return (
    <ChatArea
      bots={bots}
      initialConversationId={conversationId}
      initialMessages={conversation.messages}
      initialBotId={conversation.bot.id}
      conversationBotId={conversation.bot.id}
      userPlan={userPlan}
      userId={user.id}
      allowCustomBots={!!userPlan?.allowCustomBots}
    />
  );
}

Go to src/app/chat and update the page.tsx file with the content:

page.tsx
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getUserPlan } from "@/lib/membership";
import { ChatArea } from "./_components/chat-area";

export default async function NewChatPage({
  searchParams,
}: {
  searchParams: Promise<{ bot?: string }>;
}) {
  const user = await requireAuth({ redirect: false });
  const { bot: botParam } = await searchParams;

  const userPlan = user ? await getUserPlan(user.id) : null;

  const bots = await prisma.bot.findMany({
    where: user
      ? {
          OR: [
            { type: "MODEL" },
            { type: "SYSTEM" },
            { type: "USER", createdById: user.id },
          ],
        }
      : { type: { in: ["SYSTEM", "MODEL"] } },
    orderBy: { createdAt: "asc" },
    select: {
      id: true,
      name: true,
      description: true,
      type: true,
      createdById: true,
      planId: true,
      model: true,
      plan: { select: { price: true, name: true, checkoutUrl: true } },
    },
  });

  return (
    <ChatArea
      bots={bots}
      initialConversationId={null}
      initialMessages={[]}
      initialBotId={botParam || null}
      conversationBotId={null}
      userPlan={userPlan}
      userId={user?.id ?? null}
      allowCustomBots={!!userPlan?.allowCustomBots}
    />
  );
}

Update the chat area

ChatArea needs several changes: userId becomes nullable, the message input shows a sign-in prompt for unauthenticated users, the bot dropdown hides user-specific sections, and model bots appear in their own "Models" section with a lock icon for free users.

Go to src/app/chat/_components and update the chat-area.tsx file with the content:

chat-area.tsx
"use client";

interface SpeechRecognitionEvent extends Event {
  results: SpeechRecognitionResultList;
  resultIndex: number;
}

interface SpeechRecognitionErrorEvent extends Event {
  error: string;
}

interface SpeechRecognition extends EventTarget {
  continuous: boolean;
  interimResults: boolean;
  lang: string;
  start(): void;
  stop(): void;
  abort(): void;
  onresult: ((event: SpeechRecognitionEvent) => void) | null;
  onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
  onend: (() => void) | null;
}

declare global {
  interface Window {
    SpeechRecognition: new () => SpeechRecognition;
    webkitSpeechRecognition: new () => SpeechRecognition;
  }
}

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useRouter } from "next/navigation";
import { useState, useRef, useEffect, useMemo } from "react";
import { Send, Lock, Bot as BotIcon, User, ChevronDown, Plus, AlertCircle, Menu, Clock, Mic, MicOff } from "lucide-react";
import Markdown from "react-markdown";
import { useSidebarToggle } from "./chat-shell";
import { SignInModal } from "./sign-in-modal";

type BotPlan = {
  price: number;
  name: string;
  checkoutUrl: string;
};

type Bot = {
  id: string;
  name: string;
  description: string;
  type: string;
  createdById: string | null;
  planId: string | null;
  model: string | null;
  plan: BotPlan | null;
};

type UserPlan = {
  price: number;
  name: string;
} | null;

type DBMessage = {
  id: string;
  role: "USER" | "ASSISTANT";
  content: string;
};

function dbToUIMessages(msgs: DBMessage[]) {
  return msgs.map((m) => ({
    id: m.id,
    role: m.role.toLowerCase() as "user" | "assistant",
    parts: [{ type: "text" as const, text: m.content }],
  }));
}

function canAccessBot(bot: Bot, userPlan: UserPlan, userId: string | null): boolean {
  if (bot.type === "MODEL") return !!userPlan;
  if (bot.type === "USER") {
    return !!userId && bot.createdById === userId;
  }
  if (!bot.planId) return true;
  if (!userPlan) return false;
  return userPlan.price >= (bot.plan?.price ?? 0);
}

export function ChatArea({
  bots,
  initialConversationId,
  initialMessages,
  initialBotId,
  conversationBotId,
  userPlan,
  userId,
  allowCustomBots,
}: {
  bots: Bot[];
  initialConversationId: string | null;
  initialMessages: DBMessage[];
  initialBotId: string | null;
  conversationBotId: string | null;
  userPlan: UserPlan;
  userId: string | null;
  allowCustomBots: boolean;
}) {
  const router = useRouter();
  const toggleSidebar = useSidebarToggle();
  const scrollRef = useRef<HTMLDivElement>(null);
  const dropdownRef = useRef<HTMLDivElement>(null);
  const [conversationId, setConversationId] = useState(initialConversationId);
  const [input, setInput] = useState("");
  const [selectedBotId, setSelectedBotId] = useState(() => {
    if (initialBotId) return initialBotId;
    if (conversationBotId) return conversationBotId;
    if (userPlan) {
      const firstModel = bots.find((b) => b.type === "MODEL");
      if (firstModel) return firstModel.id;
    }
    const firstFreeSystem = bots.find((b) => b.type === "SYSTEM" && !b.planId);
    return firstFreeSystem?.id || bots[0]?.id || "";
  });
  const [dropdownOpen, setDropdownOpen] = useState(false);
  const [pendingMessage, setPendingMessage] = useState<string | null>(null);
  const [signInOpen, setSignInOpen] = useState(false);
  const [hitDailyLimit, setHitDailyLimit] = useState(false);
  const [countdown, setCountdown] = useState("");
  const [messagesRemaining, setMessagesRemaining] = useState<number | null>(null);
  const [isRecording, setIsRecording] = useState(false);
  const [speechSupported, setSpeechSupported] = useState(false);
  const recognitionRef = useRef<SpeechRecognition | null>(null);
  const inputBeforeSpeechRef = useRef("");
  const wantsRecordingRef = useRef(false);

  const modelBots = useMemo(() => bots.filter((b) => b.type === "MODEL"), [bots]);
  const systemBots = useMemo(() => bots.filter((b) => b.type === "SYSTEM"), [bots]);
  const userBots = useMemo(() => bots.filter((b) => b.type === "USER"), [bots]);

  const selectedBot = bots.find((b) => b.id === selectedBotId);
  const isSwitchingBot =
    conversationBotId && selectedBotId !== conversationBotId;
  const canUseBot = selectedBot ? canAccessBot(selectedBot, userPlan, userId) : false;

  useEffect(() => {
    function handleClick(e: MouseEvent) {
      if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
        setDropdownOpen(false);
      }
    }
    if (dropdownOpen) document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, [dropdownOpen]);

  // Intercept fetch to capture X-Conversation-Id from the response
  const conversationIdRef = useRef(conversationId);
  conversationIdRef.current = conversationId;

  const customFetch: typeof globalThis.fetch = async (input, init) => {
    const response = await globalThis.fetch(input, init);
    const newId = response.headers.get("X-Conversation-Id");
    if (newId && !conversationIdRef.current) {
      setConversationId(newId);
      window.history.replaceState(null, "", `/chat/${newId}`);
    }
    const remaining = response.headers.get("X-Messages-Remaining");
    if (remaining !== null) {
      setMessagesRemaining(parseInt(remaining, 10));
    }
    return response;
  };

  const transport = useMemo(
    () => new DefaultChatTransport({ fetch: customFetch }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  );

  const { messages, sendMessage, status, error } = useChat({
    id: conversationId || undefined,
    messages: dbToUIMessages(initialMessages),
    transport,
    onFinish: () => {
      router.refresh();
    },
    onError: (error) => {
      console.error("Chat error:", error);
    },
  });

  const isLoading = status === "streaming" || status === "submitted";

  useEffect(() => {
    if (scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [messages, pendingMessage]);

  // Clear pending message once the hook picks it up
  useEffect(() => {
    if (pendingMessage && messages.length > 0) {
      const last = messages[messages.length - 1];
      if (last.role === "user") {
        setPendingMessage(null);
      }
    }
  }, [messages, pendingMessage]);

  useEffect(() => {
    if (error?.message?.includes("Daily message limit")) {
      setHitDailyLimit(true);
    }
  }, [error]);

  useEffect(() => {
    if (!hitDailyLimit) return;
    function update() {
      const now = new Date();
      const midnight = new Date(now);
      midnight.setUTCHours(24, 0, 0, 0);
      const diff = midnight.getTime() - now.getTime();
      const h = Math.floor(diff / 3_600_000);
      const m = Math.floor((diff % 3_600_000) / 60_000);
      const s = Math.floor((diff % 60_000) / 1000);
      setCountdown(
        `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
      );
    }
    update();
    const id = setInterval(update, 1000);
    return () => clearInterval(id);
  }, [hitDailyLimit]);

  useEffect(() => {
    setSpeechSupported(
      typeof window !== "undefined" &&
        !!(window.SpeechRecognition || window.webkitSpeechRecognition)
    );
  }, []);

  useEffect(() => {
    return () => {
      wantsRecordingRef.current = false;
      recognitionRef.current?.abort();
    };
  }, []);

  function toggleRecording() {
    if (isRecording) {
      wantsRecordingRef.current = false;
      recognitionRef.current?.stop();
      return;
    }

    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const recognition = new SR() as any;
    recognition.continuous = true;
    recognition.interimResults = true;
    recognition.lang = "en-US";
    recognitionRef.current = recognition;
    inputBeforeSpeechRef.current = input;
    wantsRecordingRef.current = true;

    recognition.addEventListener("result", (e: any) => {
      let final = "";
      let interim = "";
      for (let i = 0; i < e.results.length; i++) {
        if (e.results[i].isFinal) {
          final += e.results[i][0].transcript;
        } else {
          interim += e.results[i][0].transcript;
        }
      }
      const prefix = inputBeforeSpeechRef.current;
      const space = prefix && !prefix.endsWith(" ") ? " " : "";
      setInput(prefix + space + final + interim);
    });

    recognition.addEventListener("error", (e: any) => {
      if (e.error === "no-speech") return;
      console.error("Speech error:", e.error);
      wantsRecordingRef.current = false;
      setIsRecording(false);
    });

    recognition.addEventListener("end", () => {
      if (wantsRecordingRef.current) {
        recognition.start();
      } else {
        setIsRecording(false);
      }
    });

    recognition.start();
    setIsRecording(true);
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!userId) {
      setSignInOpen(true);
      return;
    }
    if (!input.trim() || isLoading || !canUseBot) return;
    if (isRecording) {
      wantsRecordingRef.current = false;
      recognitionRef.current?.stop();
    }

    const text = input;
    setInput("");
    setPendingMessage(text);

    await sendMessage(
      { text },
      {
        body: {
          botId: selectedBotId,
          conversationId: isSwitchingBot ? undefined : conversationId,
        },
      }
    );
  };

  function getTextContent(message: (typeof messages)[0]): string {
    return message.parts
      .filter((p): p is { type: "text"; text: string } => p.type === "text")
      .map((p) => p.text)
      .join("");
  }

  // Find the cheapest plan that would unlock the selected bot
  const requiredPlan = selectedBot?.plan;

  return (
    <div className="flex h-full flex-col">
      {/* Bot selector */}
      <div className="flex items-center gap-3 border-b border-zinc-200 px-4 py-3 md:px-6 dark:border-zinc-800">
        <button
          onClick={toggleSidebar}
          className="rounded-lg p-1.5 text-zinc-500 transition-colors hover:bg-zinc-100 md:hidden dark:text-zinc-400 dark:hover:bg-zinc-800"
        >
          <Menu className="h-5 w-5" />
        </button>

        <div className="relative" ref={dropdownRef}>
          <button
            onClick={() => setDropdownOpen(!dropdownOpen)}
            className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
          >
            {selectedBot?.name || "Select a bot"}
            <ChevronDown className="h-3.5 w-3.5 text-zinc-400" />
          </button>

          {dropdownOpen && (
            <div className="absolute left-0 top-full z-10 mt-1 w-64 overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
              <div className="max-h-80 overflow-y-auto p-1">
                {modelBots.length > 0 && (
                  <>
                    <p className="px-3 py-1 text-xs font-medium text-zinc-400">
                      Models
                    </p>
                    {modelBots.map((bot) => {
                      const locked = !canAccessBot(bot, userPlan, userId);
                      return (
                        <button
                          key={bot.id}
                          onClick={() => {
                            if (!locked) {
                              setSelectedBotId(bot.id);
                              setDropdownOpen(false);
                            }
                          }}
                          disabled={locked}
                          className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
                            locked
                              ? "cursor-not-allowed text-zinc-400 dark:text-zinc-500"
                              : bot.id === selectedBotId
                                ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
                                : "text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700/50"
                          }`}
                        >
                          <span className="truncate">{bot.name}</span>
                          {locked && (
                            <Lock className="ml-auto h-3.5 w-3.5 shrink-0 text-zinc-400" />
                          )}
                        </button>
                      );
                    })}
                  </>
                )}

                {systemBots.length > 0 && (
                  <>
                    {modelBots.length > 0 && (
                      <div className="mx-2 my-1 border-t border-zinc-200 dark:border-zinc-700" />
                    )}
                    <p className="px-3 py-1 text-xs font-medium text-zinc-400">
                      System Bots
                    </p>
                    {systemBots.map((bot) => (
                      <button
                        key={bot.id}
                        onClick={() => {
                          setSelectedBotId(bot.id);
                          setDropdownOpen(false);
                        }}
                        className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
                          bot.id === selectedBotId
                            ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
                            : "text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700/50"
                        }`}
                      >
                        <span className="truncate">{bot.name}</span>
                        {!canAccessBot(bot, userPlan, userId) && (
                          <Lock className="ml-auto h-3.5 w-3.5 shrink-0 text-zinc-400" />
                        )}
                      </button>
                    ))}
                  </>
                )}

                {userId && userBots.length > 0 && (
                  <>
                    <div className="mx-2 my-1 border-t border-zinc-200 dark:border-zinc-700" />
                    <p className="px-3 py-1 text-xs font-medium text-zinc-400">
                      My Bots
                    </p>
                    {userBots.map((bot) => (
                      <button
                        key={bot.id}
                        onClick={() => {
                          setSelectedBotId(bot.id);
                          setDropdownOpen(false);
                        }}
                        className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
                          bot.id === selectedBotId
                            ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
                            : "text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700/50"
                        }`}
                      >
                        <span className="truncate">{bot.name}</span>
                      </button>
                    ))}
                  </>
                )}

                {userId && allowCustomBots && (
                  <>
                    <div className="mx-2 my-1 border-t border-zinc-200 dark:border-zinc-700" />
                    <a
                      href="/bots/new"
                      className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-indigo-600 transition-colors hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-950/30"
                    >
                      <Plus className="h-3.5 w-3.5" />
                      Create bot
                    </a>
                  </>
                )}
              </div>
            </div>
          )}
        </div>

        {selectedBot && (
          <span className="hidden text-xs text-zinc-400 sm:inline">
            {selectedBot.description}
          </span>
        )}
      </div>

      {/* Bot switch warning */}
      {isSwitchingBot && (
        <div className="border-b border-amber-200 bg-amber-50 px-6 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-200">
          Sending a message will start a new chat with{" "}
          <strong>{selectedBot?.name}</strong>
        </div>
      )}

      {/* Messages */}
      <div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 md:px-6">
        {messages.length === 0 && !pendingMessage ? (
          <div className="flex h-full flex-col items-center justify-center text-center">
            <BotIcon className="mb-4 h-12 w-12 text-zinc-300 dark:text-zinc-600" />
            <h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
              {selectedBot?.name || "Select a bot"}
            </h2>
            <p className="mt-1 max-w-sm text-sm text-zinc-500">
              {selectedBot?.description || "Choose a bot to start chatting"}
            </p>
          </div>
        ) : (
          <div className="mx-auto max-w-3xl space-y-6">
            {messages.map((message) => (
              <div key={message.id} className="flex gap-3">
                <div
                  className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${
                    message.role === "user"
                      ? "bg-zinc-200 dark:bg-zinc-700"
                      : "bg-zinc-900 dark:bg-zinc-100"
                  }`}
                >
                  {message.role === "user" ? (
                    <User className="h-4 w-4 text-zinc-600 dark:text-zinc-300" />
                  ) : (
                    <BotIcon className="h-4 w-4 text-white dark:text-zinc-900" />
                  )}
                </div>
                <div className="min-w-0 flex-1 pt-1">
                  <p className="mb-1 text-xs font-medium text-zinc-500">
                    {message.role === "user"
                      ? "You"
                      : selectedBot?.name || "Bot"}
                  </p>
                  {message.role === "user" ? (
                    <div className="prose prose-sm prose-zinc max-w-none dark:prose-invert">
                      <p className="whitespace-pre-wrap">
                        {getTextContent(message)}
                      </p>
                    </div>
                  ) : (
                    <div className="prose prose-sm prose-zinc max-w-none dark:prose-invert">
                      <Markdown>{getTextContent(message)}</Markdown>
                    </div>
                  )}
                </div>
              </div>
            ))}
            {pendingMessage && (
              <div className="flex gap-3">
                <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 dark:bg-zinc-700">
                  <User className="h-4 w-4 text-zinc-600 dark:text-zinc-300" />
                </div>
                <div className="min-w-0 flex-1 pt-1">
                  <p className="mb-1 text-xs font-medium text-zinc-500">You</p>
                  <div className="prose prose-sm prose-zinc max-w-none dark:prose-invert">
                    <p className="whitespace-pre-wrap">{pendingMessage}</p>
                  </div>
                </div>
              </div>
            )}
            {(pendingMessage || (isLoading &&
              messages[messages.length - 1]?.role === "user")) && (
                <div className="flex gap-3">
                  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-900 dark:bg-zinc-100">
                    <BotIcon className="h-4 w-4 text-white dark:text-zinc-900" />
                  </div>
                  <div className="pt-1">
                    <p className="mb-1 text-xs font-medium text-zinc-500">
                      {selectedBot?.name || "Bot"}
                    </p>
                    <div className="flex gap-1">
                      <span className="h-2 w-2 animate-bounce rounded-full bg-zinc-400 [animation-delay:0ms]" />
                      <span className="h-2 w-2 animate-bounce rounded-full bg-zinc-400 [animation-delay:150ms]" />
                      <span className="h-2 w-2 animate-bounce rounded-full bg-zinc-400 [animation-delay:300ms]" />
                    </div>
                  </div>
                </div>
              )}
            {status === "error" && (
              <div className="flex gap-3">
                <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/50">
                  <AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
                </div>
                <div className="min-w-0 flex-1 pt-1">
                  <p className="mb-1 text-xs font-medium text-red-500">Error</p>
                  <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/50 dark:text-red-300">
                    {error?.message?.includes("Daily message limit")
                      ? "You've hit your daily message limit. Upgrade for more messages."
                      : error?.message?.includes("Upgrade required")
                        ? "This bot requires a paid plan."
                        : "Something went wrong. Please try again."}
                  </div>
                </div>
              </div>
            )}
          </div>
        )}
      </div>

      {/* Input */}
      <div className="border-t border-zinc-200 px-4 py-4 md:px-6 dark:border-zinc-800">
        {!userId ? (
          <button
            onClick={() => setSignInOpen(true)}
            className="flex w-full items-center justify-center gap-2 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800/50 dark:text-zinc-400 dark:hover:bg-zinc-800"
          >
            Sign in to start chatting
          </button>
        ) : hitDailyLimit ? (
          <div className="mx-auto max-w-3xl">
            <div className="rounded-xl border border-amber-200 bg-amber-50 px-5 py-4 dark:border-amber-800 dark:bg-amber-950/30">
              <div className="flex items-center gap-3">
                <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50">
                  <Clock className="h-5 w-5 text-amber-600 dark:text-amber-400" />
                </div>
                <div>
                  <p className="text-sm font-semibold text-amber-900 dark:text-amber-100">
                    Demo limit reached
                  </p>
                  <p className="text-sm text-amber-700 dark:text-amber-300">
                    This demo caps messages at {userPlan ? "50" : "20"}/day.
                    Resets in{" "}
                    <span className="font-mono font-semibold">{countdown}</span>{" "}(UTC)
                  </p>
                </div>
              </div>
            </div>
          </div>
        ) : !canUseBot ? (
          <a
            href={requiredPlan?.checkoutUrl || "#"}
            target="_blank"
            rel="noopener noreferrer"
            className="flex items-center justify-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-800 transition-colors hover:bg-amber-100 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-200 dark:hover:bg-amber-950"
          >
            <Lock className="h-4 w-4" />
            Upgrade to {requiredPlan?.name || "a paid plan"} to chat with{" "}
            {selectedBot?.name}
          </a>
        ) : (
          <>
            {messagesRemaining !== null && userId && (
              <p className="mb-2 text-center text-xs text-zinc-400">
                {messagesRemaining} messages remaining today
              </p>
            )}
            <form
              onSubmit={handleSubmit}
              className="mx-auto flex max-w-3xl items-center gap-2"
            >
              <div className="flex flex-1 items-center rounded-lg border border-zinc-200 bg-white focus-within:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-800 dark:focus-within:border-zinc-500">
                <textarea
                  value={input}
                  onChange={(e) => setInput(e.target.value)}
                  onKeyDown={(e) => {
                    if (e.key === "Enter" && !e.shiftKey) {
                      e.preventDefault();
                      handleSubmit(e);
                    }
                  }}
                  placeholder="Send a message..."
                  rows={1}
                  className="flex-1 resize-none bg-transparent px-4 py-2.5 text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none dark:text-zinc-100"
                />
                {speechSupported && (
                  <button
                    type="button"
                    onClick={toggleRecording}
                    className="mr-2 shrink-0 rounded-md p-1.5 transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-700"
                  >
                    {isRecording ? (
                      <MicOff className="h-4 w-4 animate-pulse text-red-500" />
                    ) : (
                      <Mic className="h-4 w-4 text-zinc-400" />
                    )}
                  </button>
                )}
              </div>
              <button
                type="submit"
                disabled={isLoading || !input.trim()}
                className="self-stretch rounded-lg bg-zinc-900 px-2.5 text-white transition-colors hover:bg-zinc-800 disabled:opacity-40 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
              >
                <Send className="h-4 w-4" />
              </button>
            </form>
          </>
        )}
      </div>

      {/* Sign in modal */}
      <SignInModal isOpen={signInOpen} onClose={() => setSignInOpen(false)} />
    </div>
  );
}

Conversation management

Users accumulate conversations but have no way to organize them. We add rename and delete actions - small features that make the difference between a demo and a product.

Server actions

Both actions authenticate, verify conversation ownership, and perform the operation. Rename validates the new title with Zod.

Go to src/app/chat and create a file called actions.ts with the content:

actions.ts
"use server";

import { z } from "zod";
import { redirect } from "next/navigation";
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";

export async function deleteConversation(conversationId: string) {
  const user = await requireAuth();
  if (!user) throw new Error("Unauthorized");

  const conversation = await prisma.conversation.findUnique({
    where: { id: conversationId },
  });

  if (!conversation || conversation.userId !== user.id) {
    throw new Error("Conversation not found");
  }

  await prisma.conversation.delete({ where: { id: conversationId } });
  redirect("/chat");
}

const renameSchema = z.object({
  title: z.string().min(1).max(100),
});

export async function renameConversation(
  conversationId: string,
  title: string
) {
  const user = await requireAuth();
  if (!user) throw new Error("Unauthorized");

  const parsed = renameSchema.parse({ title: title.trim() });

  const conversation = await prisma.conversation.findUnique({
    where: { id: conversationId },
  });

  if (!conversation || conversation.userId !== user.id) {
    throw new Error("Conversation not found");
  }

  await prisma.conversation.update({
    where: { id: conversationId },
    data: { title: parsed.title },
  });
}

Update the sidebar

The sidebar now handles both authenticated and unauthenticated states, and adds a context menu for renaming and deleting conversations.

Go to src/app/chat/_components and update the sidebar.tsx file with the content:

sidebar.tsx
"use client";

import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { usePathname, useRouter } from "next/navigation";
import {
  Plus,
  MessageSquare,
  Settings,
  LogOut,
  Zap,
  Bot,
  CreditCard,
  Sparkles,
  X,
  Check,
  MoreHorizontal,
  Pencil,
  Trash2,
  LogIn,
} from "lucide-react";
import { createCheckoutUrl } from "@/app/checkout-action";
import { deleteConversation, renameConversation } from "@/app/chat/actions";
import { SignInModal } from "./sign-in-modal";

type ConversationItem = {
  id: string;
  title: string | null;
  bot: { name: string };
  updatedAt: string;
};

type PlanItem = {
  name: string;
  price: number;
  checkoutUrl: string;
  allowCustomBots: boolean;
  whopPlanId: string;
};

type SidebarProps = {
  conversations: ConversationItem[];
  user: { name: string | null; avatarUrl: string | null } | null;
  userPlan: { name: string; price: number; allowCustomBots?: boolean } | null;
  isAdmin: boolean;
  plans: PlanItem[];
  isAuthenticated: boolean;
};

export function Sidebar({
  conversations,
  user,
  userPlan,
  isAdmin,
  plans,
  isAuthenticated,
}: SidebarProps) {
  const pathname = usePathname();
  const router = useRouter();
  const [settingsOpen, setSettingsOpen] = useState(false);
  const [plansOpen, setPlansOpen] = useState(false);
  const [upgrading, setUpgrading] = useState(false);
  const [signInOpen, setSignInOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);

  const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
  const [editingId, setEditingId] = useState<string | null>(null);
  const [editTitle, setEditTitle] = useState("");
  const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
  const contextMenuRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    function handleClick(e: MouseEvent) {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        setSettingsOpen(false);
      }
    }
    if (settingsOpen) document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, [settingsOpen]);

  useEffect(() => {
    function handleClick(e: MouseEvent) {
      if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
        setMenuOpenId(null);
      }
    }
    if (menuOpenId) document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, [menuOpenId]);

  useEffect(() => {
    if (!confirmingDeleteId) return;
    const timer = setTimeout(() => setConfirmingDeleteId(null), 3000);
    return () => clearTimeout(timer);
  }, [confirmingDeleteId]);

  async function handleUpgrade(plan: PlanItem) {
    setUpgrading(true);
    const url = await createCheckoutUrl(plan.whopPlanId);
    window.location.href = url;
  }

  async function handleRename(conversationId: string) {
    if (!editTitle.trim()) {
      setEditingId(null);
      return;
    }
    await renameConversation(conversationId, editTitle);
    setEditingId(null);
    router.refresh();
  }

  async function handleDelete(conversationId: string) {
    await deleteConversation(conversationId);
  }

  return (
    <>
      <aside className="flex h-full w-72 flex-col border-r border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
        <div className="p-4">
          {isAuthenticated ? (
            <a
              href="/chat"
              className="flex w-full items-center justify-center gap-2 rounded-lg border border-zinc-200 px-4 py-2.5 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
            >
              <Plus className="h-4 w-4" />
              New chat
            </a>
          ) : (
            <button
              onClick={() => setSignInOpen(true)}
              className="flex w-full items-center justify-center gap-2 rounded-lg border border-zinc-200 px-4 py-2.5 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
            >
              <Plus className="h-4 w-4" />
              New chat
            </button>
          )}
        </div>

        <nav className="flex-1 overflow-y-auto px-2 pb-4">
          {!isAuthenticated ? (
            <p className="px-3 py-8 text-center text-xs text-zinc-400">
              Sign in to see previous chats
            </p>
          ) : conversations.length === 0 ? (
            <p className="px-3 py-8 text-center text-xs text-zinc-400">
              No conversations yet
            </p>
          ) : (
            <ul className="space-y-0.5">
              {conversations.map((conv) => {
                const isActive = pathname === `/chat/${conv.id}`;
                return (
                  <li key={conv.id} className="group relative">
                    {editingId === conv.id ? (
                      <div className="flex items-center gap-1 rounded-lg px-3 py-2">
                        <input
                          autoFocus
                          value={editTitle}
                          onChange={(e) => setEditTitle(e.target.value)}
                          onKeyDown={(e) => {
                            if (e.key === "Enter") handleRename(conv.id);
                            if (e.key === "Escape") setEditingId(null);
                          }}
                          onBlur={() => handleRename(conv.id)}
                          className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm text-zinc-900 focus:border-zinc-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100"
                        />
                      </div>
                    ) : (
                      <a
                        href={`/chat/${conv.id}`}
                        className={`flex items-start gap-2 rounded-lg px-3 py-2 text-sm transition-colors ${
                          isActive
                            ? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50"
                            : "text-zinc-600 hover:bg-zinc-50 dark:text-zinc-400 dark:hover:bg-zinc-800/50"
                        }`}
                      >
                        <MessageSquare className="mt-0.5 h-4 w-4 shrink-0" />
                        <div className="min-w-0 flex-1">
                          <p className="truncate font-medium">
                            {conv.title || "New chat"}
                          </p>
                          <p className="truncate text-xs text-zinc-400">
                            {conv.bot.name}
                          </p>
                        </div>
                      </a>
                    )}

                    {editingId !== conv.id && (
                      <div className="absolute right-2 top-2" ref={menuOpenId === conv.id ? contextMenuRef : undefined}>
                        <button
                          onClick={(e) => {
                            e.preventDefault();
                            setMenuOpenId(menuOpenId === conv.id ? null : conv.id);
                          }}
                          className="rounded p-1 text-zinc-400 opacity-0 transition-opacity hover:bg-zinc-200 hover:text-zinc-600 group-hover:opacity-100 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
                        >
                          <MoreHorizontal className="h-4 w-4" />
                        </button>

                        {menuOpenId === conv.id && (
                          <div className="absolute right-0 top-full z-10 mt-1 w-36 overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
                            <button
                              onClick={(e) => {
                                e.preventDefault();
                                setEditTitle(conv.title || "");
                                setEditingId(conv.id);
                                setMenuOpenId(null);
                              }}
                              className="flex w-full items-center gap-2 px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
                            >
                              <Pencil className="h-3.5 w-3.5" />
                              Rename
                            </button>
                            <button
                              onClick={(e) => {
                                e.preventDefault();
                                if (confirmingDeleteId === conv.id) {
                                  handleDelete(conv.id);
                                } else {
                                  setConfirmingDeleteId(conv.id);
                                }
                              }}
                              className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors ${
                                confirmingDeleteId === conv.id
                                  ? "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30"
                                  : "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
                              }`}
                            >
                              <Trash2 className="h-3.5 w-3.5" />
                              {confirmingDeleteId === conv.id ? "Are you sure?" : "Delete"}
                            </button>
                          </div>
                        )}
                      </div>
                    )}
                  </li>
                );
              })}
            </ul>
          )}
        </nav>

        <div
          className="relative border-t border-zinc-200 p-3 dark:border-zinc-800"
          ref={menuRef}
        >
          {isAuthenticated ? (
            <>
              {settingsOpen && (
                <div className="absolute bottom-full left-3 right-3 mb-2 overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
                  <div className="border-b border-zinc-100 px-3 py-2 dark:border-zinc-700">
                    <p className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
                      {userPlan ? userPlan.name : "Free"} plan
                    </p>
                  </div>

                  <div className="p-1">
                    {!userPlan && plans.length > 0 && (
                      <button
                        onClick={() => {
                          setPlansOpen(true);
                          setSettingsOpen(false);
                        }}
                        className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-amber-700 transition-colors hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950/30"
                      >
                        <Zap className="h-4 w-4" />
                        Upgrade plan
                      </button>
                    )}

                    {userPlan?.allowCustomBots && (
                      <a
                        href="/bots"
                        className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
                      >
                        <Sparkles className="h-4 w-4" />
                        My Bots
                      </a>
                    )}

                    {isAdmin && (
                      <>
                        <a
                          href="/admin/bots"
                          className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
                        >
                          <Bot className="h-4 w-4" />
                          Manage bots
                        </a>
                        <a
                          href="/admin/plans"
                          className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
                        >
                          <CreditCard className="h-4 w-4" />
                          Manage plans
                        </a>
                      </>
                    )}

                    <form action="/api/auth/logout" method="post">
                      <button
                        type="submit"
                        className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
                      >
                        <LogOut className="h-4 w-4" />
                        Sign out
                      </button>
                    </form>
                  </div>
                </div>
              )}

              <button
                onClick={() => setSettingsOpen(!settingsOpen)}
                className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-zinc-600 transition-colors hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800"
              >
                {user?.avatarUrl ? (
                  <img
                    src={user.avatarUrl}
                    alt={user.name ?? "Avatar"}
                    className="h-6 w-6 rounded-full"
                  />
                ) : (
                  <Settings className="h-4 w-4" />
                )}
                <div className="min-w-0 text-left">
                  <span className="block truncate">
                    {user?.name ?? "Settings"}
                  </span>
                  <span className="block truncate text-xs text-zinc-400">
                    {userPlan ? userPlan.name : "Free membership"}
                  </span>
                </div>
              </button>
            </>
          ) : (
            <a
              href="/api/auth/login"
              className="flex w-full items-center justify-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
            >
              <LogIn className="h-4 w-4" />
              Sign in
            </a>
          )}
        </div>
      </aside>

      {/* Plans modal */}
      {plansOpen && createPortal(
        <div className="fixed inset-0 z-50 flex items-center justify-center">
          <div
            className="absolute inset-0 bg-black/40 backdrop-blur-sm"
            onClick={() => setPlansOpen(false)}
          />
          <div className="relative mx-4 w-full max-w-2xl rounded-2xl border border-zinc-200 bg-white p-6 shadow-2xl dark:border-zinc-700 dark:bg-zinc-900">
            <button
              onClick={() => setPlansOpen(false)}
              className="absolute right-4 top-4 rounded-lg p-1 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
            >
              <X className="h-5 w-5" />
            </button>

            <h2 className="mb-1 text-lg font-semibold text-zinc-900 dark:text-zinc-50">
              Choose a plan
            </h2>
            <p className="mb-6 text-sm text-zinc-500 dark:text-zinc-400">
              Unlock premium bots, more messages, and more.
            </p>

            <div className="grid gap-4 sm:grid-cols-2">
              {/* Free tier card */}
              <div className="rounded-xl border border-zinc-200 p-5 dark:border-zinc-700">
                <p className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
                  Free
                </p>
                <p className="mt-1 text-2xl font-bold text-zinc-900 dark:text-zinc-50">
                  $0
                  <span className="text-sm font-normal text-zinc-400">
                    /mo
                  </span>
                </p>
                <ul className="mt-4 space-y-2">
                  <li className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-400">
                    <Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
                    Free bots only
                  </li>
                  <li className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-400">
                    <Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
                    20 messages per day
                  </li>
                  <li className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-400">
                    <Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
                    10 conversations
                  </li>
                </ul>
                <div className="mt-5 rounded-lg border border-zinc-200 px-4 py-2 text-center text-sm font-medium text-zinc-400 dark:border-zinc-700">
                  Current plan
                </div>
              </div>

              {/* Paid plan cards */}
              {plans.map((plan) => (
                <div
                  key={plan.name}
                  className="rounded-xl border-2 border-amber-300 bg-amber-50/50 p-5 dark:border-amber-700 dark:bg-amber-950/20"
                >
                  <p className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
                    {plan.name}
                  </p>
                  <p className="mt-1 text-2xl font-bold text-zinc-900 dark:text-zinc-50">
                    ${(plan.price / 100).toFixed(0)}
                    <span className="text-sm font-normal text-zinc-400">
                      /mo
                    </span>
                  </p>
                  <ul className="mt-4 space-y-2">
                    <li className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-400">
                      <Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
                      All bots up to ${(plan.price / 100).toFixed(0)}/mo tier
                    </li>
                    <li className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-400">
                      <Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
                      50 messages per day
                    </li>
                    <li className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-400">
                      <Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
                      Unlimited conversations
                    </li>
                    {plan.allowCustomBots && (
                      <li className="flex items-start gap-2 text-sm text-zinc-600 dark:text-zinc-400">
                        <Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
                        Create custom bots
                      </li>
                    )}
                  </ul>
                  {isAuthenticated ? (
                    <button
                      onClick={() => handleUpgrade(plan)}
                      disabled={upgrading}
                      className="mt-5 flex w-full items-center justify-center gap-2 rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-600 disabled:opacity-50 dark:bg-amber-600 dark:hover:bg-amber-500"
                    >
                      <Zap className="h-4 w-4" />
                      {upgrading ? "Redirecting..." : `Upgrade to ${plan.name}`}
                    </button>
                  ) : (
                    <a
                      href="/api/auth/login"
                      className="mt-5 flex w-full items-center justify-center gap-2 rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-500"
                    >
                      <LogIn className="h-4 w-4" />
                      Sign in to upgrade
                    </a>
                  )}
                </div>
              ))}
            </div>
          </div>
        </div>,
        document.body
      )}

      {/* Sign in modal */}
      <SignInModal isOpen={signInOpen} onClose={() => setSignInOpen(false)} />
    </>
  );
}

Mobile responsive sidebar

We need the sidebar to stay visible on desktop but slide in as an overlay on mobile. A client wrapper component will manage the toggle state and share it with both the sidebar and the hamburger button via React context.

Go to src/app/chat/_components and create a file called chat-shell.tsx with the content:

chat-shell.tsx
"use client";

import { createContext, useContext, useState } from "react";

const SidebarToggleContext = createContext<() => void>(() => {});
export const useSidebarToggle = () => useContext(SidebarToggleContext);

export function ChatShell({
  sidebar,
  children,
}: {
  sidebar: React.ReactNode;
  children: React.ReactNode;
}) {
  const [sidebarOpen, setSidebarOpen] = useState(false);

  return (
    <SidebarToggleContext.Provider value={() => setSidebarOpen((o) => !o)}>
      <div className="flex h-screen bg-zinc-50 dark:bg-zinc-950">
        {sidebarOpen && (
          <div
            className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm md:hidden"
            onClick={() => setSidebarOpen(false)}
          />
        )}

        <div
          className={`fixed inset-y-0 left-0 z-50 w-72 transition-transform duration-200 md:relative md:z-auto md:translate-x-0 ${
            sidebarOpen ? "translate-x-0" : "-translate-x-full"
          }`}
        >
          {sidebar}
        </div>

        <main className="min-w-0 flex-1">{children}</main>
      </div>
    </SidebarToggleContext.Provider>
  );
}

Error boundaries and loading states

If something goes wrong, we want users to see a friendly "Try again" button instead of a blank screen.

Go to src/app and create a file called error.tsx with the content:

error.tsx
"use client";

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950">
      <div className="mx-4 w-full max-w-sm text-center">
        <h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
          Something went wrong
        </h1>
        <p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
          An unexpected error occurred. Please try again.
        </p>
        <button
          onClick={reset}
          className="mt-6 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
        >
          Try again
        </button>
      </div>
    </div>
  );
}

If someone hits a route that doesn't exist, Next.js renders not-found.tsx. We'll point them back to /chat.

Go to src/app and create a file called not-found.tsx with the content:

not-found.tsx
import Link from "next/link";

export default function NotFound() {
  return (
    <div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950">
      <div className="mx-4 w-full max-w-sm text-center">
        <h1 className="text-6xl font-bold text-zinc-200 dark:text-zinc-800">
          404
        </h1>
        <p className="mt-4 text-lg font-semibold text-zinc-900 dark:text-zinc-50">
          Page not found
        </p>
        <p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
          The page you&apos;re looking for doesn&apos;t exist.
        </p>
        <Link
          href="/chat"
          className="mt-6 inline-block rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
        >
          Back to chat
        </Link>
      </div>
    </div>
  );
}

While the chat layout fetches session and plan data, Next.js shows loading.tsx as a placeholder. A simple bouncing-dots animation keeps things feeling responsive.

Go to src/app/chat and create a file called loading.tsx with the content:

loading.tsx
export default function ChatLoading() {
  return (
    <div className="flex h-full items-center justify-center">
      <div className="flex gap-1">
        <span className="h-2.5 w-2.5 animate-bounce rounded-full bg-zinc-400 [animation-delay:0ms]" />
        <span className="h-2.5 w-2.5 animate-bounce rounded-full bg-zinc-400 [animation-delay:150ms]" />
        <span className="h-2.5 w-2.5 animate-bounce rounded-full bg-zinc-400 [animation-delay:300ms]" />
      </div>
    </div>
  );
}

Production deploy

Everything works in sandbox. Moving to production involves updating the Whop environment, deploying, and verifying the full flow.

Switch the Whop environment

In the Whop developer dashboard, create a new production app (or promote the sandbox app) and copy the credentials. Production apps use whop.com instead of sandbox.whop.com - the SDK handles this automatically when WHOP_SANDBOX is not set.

Update Vercel environment variables

In the Vercel dashboard, update these variables for the production environment:

  • WHOP_CLIENT_ID — from production app
  • WHOP_CLIENT_SECRET — from production app
  • WHOP_API_KEY — production app API key
  • WHOP_COMPANY_API_KEY — production company API key
  • WHOP_WEBHOOK_SECRET — production webhook secret (base64-encoded)
  • ANTHROPIC_API_KEY — Anthropic API key for Claude Haiku 4.5
  • OPENAI_API_KEY — OpenAI API key for GPT-4o mini

Remove WHOP_SANDBOX - leaving it unset makes the Whop SDK use production endpoints. Make sure NEXT_PUBLIC_APP_URL points to the production Vercel URL.

Update redirect URIs

In the Whop app settings, add the production Vercel URL as an authorized redirect URI for OAuth. Both the callback URL (https://your-app.vercel.app/api/auth/callback) and any other redirect targets need to be registered.

Re-create plans and seed bots

Sandbox data doesn't carry over to production. After deploying:

  1. Sign in as the admin (the Whop account that owns the app)
  2. Go to /admin/plans and create the same plans - the server actions create products and billing plans on Whop's production environment
  3. Go to /admin/bots and create the system bots - same names, descriptions, and system prompts. Assign premium bots to their plans

Test the full flow

  1. Visit /chat without signing in - browse bots, click the input, see the sign-in modal
  2. Sign in via Whop OAuth - full functionality, send messages, rename/delete conversations
  3. Resize to mobile - sidebar collapses behind a hamburger button
  4. Upgrade via the sidebar settings > complete checkout > premium bots unlock
  5. Create a custom bot and chat with it
  6. Sign out > back to the public chat page
Both Anthropic and OpenAI charge per token. Monitor usage through each provider's console and set spending alerts to avoid surprises. The daily message limits (20 free, 50 paid) and the 2-bot cap are set conservatively, adjust FREE_DAILY_LIMIT, PAID_DAILY_LIMIT, and USER_BOT_LIMIT in membership.ts to match your business model.

Checkpoint: polish and production

  1. Visit /chat without signing in - bots are browsable, clicking the input opens the sign-in modal
  2. Sign in - full functionality works (send messages, rename/delete conversations)
  3. Resize to mobile - sidebar hides behind a hamburger, slides in as an overlay
  4. Visit a nonexistent URL - 404 page with "Back to chat"
  5. Update Vercel env vars for production, remove WHOP_SANDBOX, re-create plans and bots
  6. Push to GitHub - Vercel auto-deploys. Test the full flow on production

That's ChatForge - a multi-bot AI chat SaaS with Whop authentication, streaming conversations, dynamic paid plans, custom bot creation, unauthenticated browsing, and a mobile-responsive interface.

The chat page is the product, the login wall is gone, and the production deploy is a Vercel push away.

Ready to build your own chatbot SaaS with Whop?

So far, we've covered everything from creating a Next.js project from scratch to streamlining chats and taking payments using the Whop Payments Network. There are many more projects you can do with Whop's infrastructure like building a Substack or a Patreon clone. You can find all of our guides in our Tutorials category.

If you want to learn more about the Whop infrastructure, check out the Whop developer documentation.