--- name: product-ai-layer description: Embed AI features in Next.js product apps using Vercel AI SDK. Covers streaming chat, tool calling with Zod, rate limiting, and auth — integrated into the product-stack 9-layer architecture. Use when adding chat, copilots, AI search, or agent features to a SaaS product. topics: [ai, architecture, react-patterns] --- # Product AI Layer How to add AI features to a `product-stack` app without bolting on a separate architecture. Same layers: endpoints, services, hooks, components — plus AI-specific server routes and streaming UI. Built on **Vercel AI SDK** (`ai` package), **Zod** for tool schemas, and **shadcn/ui** for chat UI. --- ## Where AI Fits in product-stack ``` src/ ├── app/ │ └── api/ │ └── ai/ │ ├── chat/route.ts # Streaming chat endpoint │ └── generate/route.ts # One-shot structured output ├── config/ │ └── api-endpoints.ts # AI_ENDPOINTS + QUERY_KEYS ├── services/ │ └── ai.service.ts # Client calls to AI routes ├── hooks/ │ └── use-ai-chat.ts # useChat wrapper ├── schemas/ │ └── ai.ts # Tool input schemas ├── lib/ │ └── ai/ │ ├── tools.ts # Tool definitions + execute fns │ ├── rate-limit.ts # Per-user rate limiting │ └── prompts.ts # System prompts per feature └── components/ └── ai/ ├── chat-panel.tsx ├── chat-message.tsx └── tool-result.tsx ``` **Rule:** AI routes follow the same `protectedApi` middleware as CRUD routes. Never expose unauthenticated AI endpoints. --- ## Layer 1: Endpoints ```typescript // config/api-endpoints.ts export const API_ENDPOINTS = { // ...existing endpoints AI: { CHAT: "/api/ai/chat", GENERATE: "/api/ai/generate", }, }; export const QUERY_KEYS = { // ...existing keys AI_CONVERSATIONS: ["ai", "conversations"], AI_CONVERSATION: (id: string) => ["ai", "conversations", id], }; ``` --- ## Layer 2: Chat Route Handler ```typescript // app/api/ai/chat/route.ts import { streamText, convertToModelMessages, UIMessage } from "ai"; import { openai } from "@ai-sdk/openai"; import { protectedApi } from "@/lib/middleware/api-middleware"; import { productTools } from "@/lib/ai/tools"; import { checkRateLimit } from "@/lib/ai/rate-limit"; import { getSystemPrompt } from "@/lib/ai/prompts"; import { Errors } from "@/lib/response/server-response"; export const POST = protectedApi(async (request, user) => { const { allowed, remaining } = await checkRateLimit(user.id); if (!allowed) { return Errors.tooManyRequests(`Rate limit exceeded. ${remaining} requests remaining.`); } const { messages }: { messages: UIMessage[] } = await request.json(); const result = streamText({ model: openai("gpt-4o"), system: getSystemPrompt("product-assistant"), messages: await convertToModelMessages(messages), tools: productTools(user.id), maxSteps: 5, }); return result.toUIMessageStreamResponse(); }); ``` **Rules:** - `maxSteps` prevents infinite tool loops (default 5 is safe for product features) - `convertToModelMessages` handles the `UIMessage` → model message conversion - Rate limit before model call, not after - Pass `user.id` into tools for row-level security --- ## Layer 3: Tool Definitions Tools use the same Zod schemas as your CRUD layer: ```typescript // lib/ai/tools.ts import { tool } from "ai"; import { z } from "zod"; import { db } from "@/db"; import { projects } from "@/db/schema/projects"; import { eq } from "drizzle-orm"; export function productTools(userId: string) { return { listProjects: tool({ description: "List the user's projects. Use when the user asks about their projects.", parameters: z.object({ status: z.enum(["active", "draft", "archived"]).optional(), }), execute: async ({ status }) => { const rows = await db .select({ id: projects.id, name: projects.name, status: projects.status }) .from(projects) .where(eq(projects.userId, userId)) .limit(20); return status ? rows.filter((r) => r.status === status) : rows; }, }), createProject: tool({ description: "Create a new project for the user.", parameters: z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), }), execute: async ({ name, description }) => { const [project] = await db .insert(projects) .values({ name, description, userId }) .returning(); return project; }, }), }; } ``` **Rules:** - Every tool `execute` scopes queries to `userId` — never trust the model for auth - Return minimal data (no passwords, tokens, internal IDs the user shouldn't see) - Tool descriptions are prompts — write them for the model, not humans - Reuse Zod schemas from `schemas/` where possible --- ## Layer 4: Rate Limiting ```typescript // lib/ai/rate-limit.ts import { Ratelimit } from "@upstash/ratelimit"; import { Redis } from "@upstash/redis"; const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(20, "1 m"), prefix: "ai-chat", }); export async function checkRateLimit(userId: string) { const { success, remaining } = await ratelimit.limit(userId); return { allowed: success, remaining }; } ``` For simpler setups without Redis, use an in-memory Map with TTL (dev only — not production-safe). --- ## Layer 5: Client Hook ```typescript // hooks/use-ai-chat.ts "use client"; import { useChat } from "@ai-sdk/react"; import { API_ENDPOINTS } from "@/config/api-endpoints"; import type { UIMessage } from "ai"; export function useAiChat(initialMessages?: UIMessage[]) { return useChat({ api: API_ENDPOINTS.AI.CHAT, initialMessages, onError: (error) => { console.error("AI chat error:", error); }, }); } ``` --- ## Layer 6: Chat UI Components Render `message.parts` — not `message.content`. Parts support text, tool calls, and tool results simultaneously. ```tsx // components/ai/chat-message.tsx "use client"; import type { UIMessage } from "ai"; export function ChatMessage({ message }: { message: UIMessage }) { const isUser = message.role === "user"; return (
{message.parts.map((part, i) => { switch (part.type) { case "text": return

{part.text}

; case "tool-invocation": return ( ); default: return null; } })}
); } ``` ```tsx // components/ai/chat-panel.tsx "use client"; import { useAiChat } from "@/hooks/use-ai-chat"; import { ChatMessage } from "./chat-message"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; export function ChatPanel() { const { messages, input, setInput, handleSubmit, isLoading, stop } = useAiChat(); return (
{messages.map((message) => ( ))}