Skip to main content
General

AI

Learn how to leverage the built-in AI features including chatbots and LLM integration.

Open MarkdownFull AI corpusFeedback

The Pro Next.js Drizzle starter kit ships an organization-scoped chat built with AI SDK 7, the direct OpenAI provider, tRPC history and usage-based credits. This page describes the code in the repository. Sections labeled as custom examples are additions you can build yourself.

Overview

The AI system is built with a hybrid architecture to support high-performance streaming while maintaining a type-safe tRPC API for CRUD operations.

FeatureTechnologyReason
Streaming responsesAPI RoutetRPC doesn't support streaming
Chat CRUDtRPCType-safe, cached queries
State managementVercel AI SDKuseChat hook handles streaming

Configuration

Add your OpenAI API key to the .env file to enable the AI features.

.env
OPENAI_API_KEY=sk-...

Streaming Endpoint

The complete shipped route lives at app/api/ai/chat/route.ts. It authenticates the request, validates the selected model, checks organization access and credits, persists the response and returns a text stream. The reduced example below shows the same message normalization and stream protocol without the product-specific billing flow.

app/api/ai/example/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText, type ModelMessage } from 'ai';
import { z } from 'zod/v4';

const messageSchema = z
  .object({
    role: z.enum(['user', 'assistant', 'system']),
    content: z.string().optional(),
    parts: z
      .array(
        z.object({
          type: z.string(),
          text: z.string().optional()
        })
      )
      .optional()
  })
  .passthrough();

const requestSchema = z.object({
  messages: z.array(messageSchema)
});

function toModelMessages(
  messages: z.infer<typeof messageSchema>[]
): ModelMessage[] {
  return messages.map((message) => {
    const content =
      message.content ??
      message.parts?.find((part) => part.type === 'text')?.text ??
      '';

    switch (message.role) {
      case 'system':
        return { role: 'system', content };
      case 'assistant':
        return { role: 'assistant', content };
      default:
        return { role: 'user', content };
    }
  });
}

export async function POST(req: Request) {
  const body = requestSchema.parse(await req.json());

  const result = streamText({
    model: openai('gpt-4o-mini'),
    messages: toModelMessages(body.messages)
  });

  return result.toUIMessageStreamResponse({
    onError: () => 'AI is temporarily unavailable. Please try again later.'
  });
}

DefaultChatTransport and toUIMessageStreamResponse() are the matched pair used by the shipped chat. The UI message protocol carries sanitized failures as well as generated text. Normalize message parts before passing them to streamText, as the shipped route does.

UI Components

We provide a complete suite of components to build a premium AI chat experience.

Main Chat Component

The AiChat component provides a full conversation interface with a history sidebar.

app/(saas)/dashboard/(sidebar)/organization/chatbot/page.tsx
import { redirect } from 'next/navigation';

import { AiChat } from '@/components/ai/ai-chat';
import { getOrganizationById, getSession } from '@/lib/auth/server';

export default async function ChatbotPage() {
  const session = await getSession();
  const organizationId = session?.session.activeOrganizationId;
  if (!organizationId) redirect('/dashboard');

  const organization = await getOrganizationById(organizationId);
  if (!organization) redirect('/dashboard');

  return <AiChat organizationId={organization.id} />;
}

Custom Hook

For more control, you can use the useChat hook directly from the Vercel AI SDK.

components/my-custom-ai.tsx
'use client';

import { useState, type FormEvent } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';

type MyCustomAIProps = {
  chatId: string;
  organizationId: string;
};

export function MyCustomAI({ chatId, organizationId }: MyCustomAIProps) {
  const [input, setInput] = useState('');
  const { messages, sendMessage, status } = useChat({
    id: chatId,
    transport: new DefaultChatTransport({
      api: '/api/ai/chat',
      body: { chatId, organizationId }
    })
  });
  const isSending = status === 'submitted' || status === 'streaming';

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const text = input.trim();
    if (!text) return;

    setInput('');
    sendMessage({
      role: 'user',
      parts: [{ type: 'text', text }]
    });
  }

  return (
    <form onSubmit={handleSubmit}>
      <p>{messages.length} messages</p>
      <input
        value={input}
        onChange={(event) => setInput(event.target.value)}
        disabled={isSending}
      />
      <button
        type="submit"
        disabled={isSending}
      >
        Send
      </button>
    </form>
  );
}

Custom Example: Tool Calling

The shipped route does not register tools. You can add a tool definition like this and pass it to streamText. The UI message protocol can carry tool parts, but the client must render and handle each tool state.

lib/ai/find-leads-tool.ts
import { tool } from 'ai';
import { ilike } from 'drizzle-orm';
import { z } from 'zod/v4';

import { db } from '@/lib/db';
import { leadTable } from '@/lib/db/schema';

export const findLeadsTool = tool({
  description: 'Find leads in the database',
  inputSchema: z.object({ query: z.string() }),
  execute: async ({ query }) => {
    return await db.query.leadTable.findMany({
      where: ilike(leadTable.name, `%${query}%`)
    });
  }
});