AI
Learn how to leverage the built-in AI features including chatbots and LLM integration.
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.
Chatbot
Prompting
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.
| Feature | Technology | Reason |
|---|---|---|
| Streaming responses | API Route | tRPC doesn't support streaming |
| Chat CRUD | tRPC | Type-safe, cached queries |
| State management | Vercel AI SDK | useChat hook handles streaming |
Configuration
Add your OpenAI API key to the .env file to enable the AI features.
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.
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.
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.
'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.
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}%`)
});
}
});