Code Patterns and Best Practices¶
Comprehensive code patterns and best practices for Next.js applications with TypeScript, Supabase, and AI integrations.
Prerequisites: - Next.js 16+ with App Router - TypeScript configured (strict mode) - Supabase clients set up
See Also: - App Conventions - Overall application structure - Supabase Integration - Database patterns - Development Environment - Local setup
Import Organization¶
Order imports consistently:
// 1. React and Next.js
import { useState } from 'react'
import Link from 'next/link'
// 2. External libraries
import { createClient } from '@supabase/supabase-js'
// 3. Internal modules
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
// 4. Types
import type { Database } from '@/types/database'
Async Component Pattern¶
Server Components (default):
export default async function DashboardPage() {
const supabase = await createClient() // Server client
const { data } = await supabase.from('items').select()
return <div>{/* render */}</div>
}
Client Components:
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function ClientComponent() {
const [data, setData] = useState(null)
const supabase = createClient() // Browser client
useEffect(() => {
// Fetch data
}, [])
return <div>{/* render */}</div>
}
Error Handling¶
Always handle Supabase errors:
const { data, error } = await supabase.from('items').select()
if (error) {
console.error('Database error:', error)
// Handle error (show toast, redirect, etc.)
return
}
// Use data safely
API Route Patterns¶
Next.js API routes live in app/api/ and follow specific conventions.
Standard API Route Structure¶
// app/api/items/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const supabase = await createClient()
// Verify authentication
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
// Query data
const { data, error } = await supabase.from('items').select()
if (error) {
return Response.json({ error: error.message }, { status: 500 })
}
return Response.json({ data })
}
export async function POST(request: NextRequest) {
const supabase = await createClient()
const body = await request.json()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase.from('items').insert(body).select()
if (error) {
return Response.json({ error: error.message }, { status: 500 })
}
return Response.json({ data }, { status: 201 })
}
AI SDK v5 Chat Route Pattern¶
CRITICAL: AI SDK v5 Breaking Changes
AI SDK v5 introduced significant changes to message handling. Understanding these is essential to avoid "messages must be ModelMessage[]" errors and parsing issues.
Key Concepts:
| Concept | Description |
|---|---|
UIMessage |
Frontend format with parts[] array - used by useChat hook |
ModelMessage |
Backend format with content string - required by streamText() |
convertToModelMessages() |
Converts UIMessage[] to ModelMessage[] |
toUIMessageStreamResponse() |
Returns SSE format for DefaultChatTransport |
Required packages:
Server-side API Route:
// app/api/chat/route.ts
import { anthropic } from '@ai-sdk/anthropic'
import { streamText, convertToModelMessages } from 'ai' // ← CRITICAL: import convertToModelMessages
import { createClient } from '@/lib/supabase/server'
export async function POST(req: Request) {
try {
const { messages, contextId } = await req.json()
const supabase = await createClient()
// Verify auth
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return new Response('Unauthorized', { status: 401 })
}
// Get context from database if needed
let systemContext = ''
if (contextId) {
const { data } = await supabase
.from('context_table')
.select()
.eq('id', contextId)
.single()
systemContext = data ? `Context: ${JSON.stringify(data)}` : ''
}
// Save user message - extract text from parts array
if (contextId && messages.length > 0) {
const lastMessage = messages[messages.length - 1]
const content = lastMessage.parts
?.filter((part: { type: string }) => part.type === 'text')
.map((part: { text: string }) => part.text)
.join('') || ''
await supabase.from('messages').insert({
context_id: contextId,
role: lastMessage.role,
content: content,
})
}
// CRITICAL: Convert UIMessage[] to ModelMessage[] before passing to streamText
const modelMessages = convertToModelMessages(messages)
// Stream response
const result = await streamText({
model: anthropic('claude-sonnet-4-5-20250929'), // Use current model ID
system: `You are a helpful assistant. ${systemContext}`,
messages: modelMessages, // ← Pass converted messages
onFinish: async ({ text }) => {
// Save to database
if (contextId) {
await supabase.from('messages').insert({
context_id: contextId,
role: 'assistant',
content: text,
})
}
},
})
// CRITICAL: Use toUIMessageStreamResponse() for DefaultChatTransport
return result.toUIMessageStreamResponse() // ← NOT toDataStreamResponse() or toTextStreamResponse()
} catch (error) {
console.error('Chat API error:', error)
return new Response('Internal server error', { status: 500 })
}
}
Client-side usage with AI SDK v5:
'use client'
import { useState } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
export function ChatComponent({ contextId }: { contextId: string }) {
const [input, setInput] = useState('')
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
body: { contextId },
credentials: 'include' // For auth cookies
})
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!input.trim()) return
await sendMessage({ text: input }) // ← v5 uses { text: } not { content: }
setInput('')
}
const isLoading = status === 'submitted' || status === 'streaming'
return (
<div>
{messages.map(m => (
<div key={m.id}>
<strong>{m.role}:</strong>{' '}
{/* CRITICAL: Extract text from parts array, not content */}
{m.parts
.filter((part) => part.type === 'text')
.map((part, i) => (
<span key={i}>{(part as { text: string }).text}</span>
))}
</div>
))}
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isLoading}
className="text-gray-900 placeholder:text-gray-400"
/>
<button type="submit" disabled={isLoading}>Send</button>
</form>
</div>
)
}
Common Errors and Fixes:
| Error | Cause | Fix |
|---|---|---|
AI_InvalidPromptError: messages must be ModelMessage[] |
Passing UIMessage[] directly to streamText | Use convertToModelMessages() |
| Frontend shows "Thinking..." but no response | Using wrong response method | Use toUIMessageStreamResponse() |
| Messages display as empty/undefined | Using message.content instead of parts |
Extract from message.parts array |
| 404 model not found | Invalid model ID | Use claude-sonnet-4-5-20250929 |
Message Format Reference:
// UIMessage (frontend) - has parts array
{
id: "abc123",
role: "user",
parts: [
{ type: "text", text: "Hello" }
]
}
// ModelMessage (backend) - has content string
{
role: "user",
content: "Hello"
}
AI SDK Tool Use Pattern¶
When adding tools to your chat API that can execute actions (database updates, API calls, etc.), use the tool() wrapper with inputSchema.
CRITICAL: Use inputSchema, NOT parameters
The Anthropic API requires proper JSON Schema format. The AI SDK tool() function with inputSchema (using Zod) handles this automatically.
Required imports:
import { anthropic } from "@ai-sdk/anthropic"
import { streamText, convertToModelMessages, tool } from "ai" // ← Import tool
import { z } from "zod" // ← For schema definition
Tool definition pattern:
const result = await streamText({
model: anthropic("claude-sonnet-4-5-20250929"),
system: "Your system prompt...",
messages: modelMessages,
tools: {
updateBudget: tool({
description: "Update the trip budget. Use when user wants to change budget.",
inputSchema: z.object({ // ← CRITICAL: use inputSchema, NOT parameters
amount: z.number().describe("The new budget amount"),
currency: z.string().describe("The currency code (e.g., USD, EUR)"),
}),
execute: async ({ amount, currency }) => {
// Access parameters with full type safety
const { error } = await supabase
.from("trips")
.update({ budget_amount: amount, budget_currency: currency })
.eq("id", tripId)
if (error) {
return { success: false, message: `Failed: ${error.message}` }
}
return { success: true, message: `Budget updated to ${amount} ${currency}` }
},
}),
addItem: tool({
description: "Add a new item with optional fields.",
inputSchema: z.object({
name: z.string().describe("Item name"),
type: z.enum(["type_a", "type_b", "type_c"]).describe("Item type"),
cost: z.number().optional().describe("Optional cost"),
notes: z.string().optional().describe("Optional notes"),
}),
execute: async ({ name, type, cost, notes }) => {
const { error } = await supabase.from("items").insert({
name,
type,
cost: cost || null,
notes: notes || null,
})
if (error) {
return { success: false, message: error.message }
}
return { success: true, message: `"${name}" added` }
},
}),
},
onFinish: async ({ text }) => {
// Save response to database
},
})
Conditional tools (only provide when context exists):
const result = await streamText({
model: anthropic("claude-sonnet-4-5-20250929"),
messages: modelMessages,
// Only provide tools when we have a valid context
tools: contextId ? {
updateItem: tool({ /* ... */ }),
deleteItem: tool({ /* ... */ }),
} : undefined,
})
Common tool patterns:
// String with validation
inputSchema: z.object({
date: z.string().describe("Date in YYYY-MM-DD format"),
title: z.string().describe("A descriptive title"),
})
// Enum for constrained choices
inputSchema: z.object({
type: z.enum(["option_a", "option_b", "option_c"]).describe("Type of item"),
status: z.enum(["pending", "active", "completed"]).describe("Current status"),
})
// Optional fields
inputSchema: z.object({
required_field: z.string().describe("This is required"),
optional_field: z.string().optional().describe("This is optional"),
optional_number: z.number().optional().describe("Optional number"),
})
// Nested objects (if needed)
inputSchema: z.object({
location: z.object({
latitude: z.number(),
longitude: z.number(),
}).optional().describe("GPS coordinates"),
})
Tool errors and fixes:
| Error | Cause | Fix |
|---|---|---|
tools.0.custom.input_schema.type: Field required |
Using parameters instead of inputSchema |
Change to inputSchema: z.object({...}) |
tools.0.custom.input_schema.type: Field required |
Using raw JSON Schema without type: "object" |
Use Zod with tool() wrapper instead |
| Tool not executing | Missing execute function |
Add execute: async (params) => {...} |
| Type errors in execute | Wrong parameter destructuring | Match parameter names to Zod schema keys |
Complete working example (from trip-planner):
// app/api/chat/route.ts
import { anthropic } from "@ai-sdk/anthropic"
import { streamText, convertToModelMessages, tool } from "ai"
import { z } from "zod"
import { createClient } from "@/lib/supabase/server"
export async function POST(req: Request) {
try {
const { messages, tripId } = await req.json()
const supabase = await createClient()
// Auth check...
const { data: { user } } = await supabase.auth.getUser()
if (!user) return new Response("Unauthorized", { status: 401 })
// Convert messages for streamText
const modelMessages = convertToModelMessages(messages)
const result = await streamText({
model: anthropic("claude-sonnet-4-5-20250929"),
system: "You are a travel planning assistant with tools to update trip data.",
messages: modelMessages,
tools: tripId ? {
updateBudget: tool({
description: "Update the trip budget amount and currency.",
inputSchema: z.object({
amount: z.number().describe("The new budget amount"),
currency: z.string().describe("Currency code (USD, EUR, GBP)"),
}),
execute: async ({ amount, currency }) => {
const { error } = await supabase
.from("trips")
.update({ budget_amount: amount, budget_currency: currency })
.eq("id", tripId)
if (error) {
return { success: false, message: `Failed: ${error.message}` }
}
return { success: true, message: `Budget: ${amount} ${currency}` }
},
}),
updateDates: tool({
description: "Update trip start and end dates.",
inputSchema: z.object({
startDate: z.string().describe("Start date (YYYY-MM-DD)"),
endDate: z.string().describe("End date (YYYY-MM-DD)"),
}),
execute: async ({ startDate, endDate }) => {
const { error } = await supabase
.from("trips")
.update({ start_date: startDate, end_date: endDate })
.eq("id", tripId)
if (error) {
return { success: false, message: `Failed: ${error.message}` }
}
return { success: true, message: `Dates: ${startDate} - ${endDate}` }
},
}),
addLocation: tool({
description: "Add a location to a day's itinerary.",
inputSchema: z.object({
itineraryId: z.string().describe("The itinerary day ID"),
name: z.string().describe("Location name"),
type: z.enum([
"attraction", "restaurant", "hotel", "transport", "activity", "other"
]).describe("Type of location"),
address: z.string().optional().describe("Full address"),
startTime: z.string().optional().describe("Start time (HH:MM)"),
endTime: z.string().optional().describe("End time (HH:MM)"),
estimatedCost: z.number().optional().describe("Estimated cost"),
notes: z.string().optional().describe("Additional notes"),
}),
execute: async ({
itineraryId, name, type, address,
startTime, endTime, estimatedCost, notes
}) => {
const { error } = await supabase.from("locations").insert({
itinerary_id: itineraryId,
name,
type,
address: address || null,
start_time: startTime || null,
end_time: endTime || null,
estimated_cost: estimatedCost || null,
notes: notes || null,
})
if (error) {
return { success: false, message: `Failed: ${error.message}` }
}
return { success: true, message: `"${name}" added` }
},
}),
} : undefined,
onFinish: async ({ text }) => {
if (tripId) {
await supabase.from("chat_messages").insert({
trip_id: tripId,
role: "assistant",
content: text,
})
}
},
})
return result.toUIMessageStreamResponse()
} catch (error) {
console.error("Chat API error:", error)
return new Response("Internal server error", { status: 500 })
}
}
Dynamic Route Parameters¶
Note: Next.js 16+ requires params to be a Promise. See App Conventions for details.
// app/api/items/[id]/route.ts
import { createClient } from '@/lib/supabase/server'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> } // ← Promise in Next.js 16+
) {
const supabase = await createClient()
const { id } = await params // ← Must await
const { data, error } = await supabase
.from('items')
.select()
.eq('id', id)
.single()
if (error) {
return Response.json({ error: error.message }, { status: 404 })
}
return Response.json({ data })
}
Error Handling Best Practices¶
export async function POST(request: NextRequest) {
try {
const supabase = await createClient()
const body = await request.json()
// Validate input
if (!body.name || !body.email) {
return Response.json(
{ error: 'Missing required fields' },
{ status: 400 }
)
}
// Database operation
const { data, error } = await supabase.from('users').insert(body).select()
if (error) {
console.error('Database error:', error)
return Response.json(
{ error: 'Failed to create user' },
{ status: 500 }
)
}
return Response.json({ data }, { status: 201 })
} catch (error) {
console.error('Unexpected error:', error)
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
AI Providers Package (Shared Library)¶
For non-streaming AI completions (summarization, title generation, suggestions), use the shared ai-providers package instead of raw SDK calls.
Package: https://github.com/jakecelentano/ai-providers
Installation:
When to use ai-providers vs AI SDK:
| Use Case | Tool | Why |
|---|---|---|
| Simple completions | ai-providers |
Cleaner abstraction, consistent model config |
| Chat summarization | ai-providers |
Non-streaming, simple prompt/response |
| Title generation | ai-providers |
Quick, one-off generation |
| Streaming chat | AI SDK | Needs streamText, useChat integration |
| Tool-based agents | AI SDK | Needs tool(), stepCountIs() |
Basic usage:
import { AIProviderFactory, MODELS } from "@jakecelentano/ai-providers"
// Create provider from environment (reads ANTHROPIC_API_KEY)
const provider = AIProviderFactory.createFromEnv("anthropic", MODELS.anthropic.primary)
try {
const text = await provider.generateCompletion(
"Summarize this conversation...", // prompt
"You are a summarization assistant." // system prompt
)
return text.trim()
} finally {
await provider.close()
}
Available models:
import { MODELS } from "@jakecelentano/ai-providers"
MODELS.anthropic.primary // claude-sonnet-4-20250514
MODELS.anthropic.fast // claude-3-5-haiku-20241022
MODELS.openrouter.primary // anthropic/claude-sonnet-4-20250514
MODELS.openrouter.fast // anthropic/claude-3-5-haiku-20241022
Hybrid approach (AI SDK + MODELS):
For streaming routes that need tool calling, keep AI SDK but use MODELS for consistent model naming:
import { anthropic } from "@ai-sdk/anthropic"
import { streamText, tool } from "ai"
import { MODELS } from "@jakecelentano/ai-providers"
const result = await streamText({
model: anthropic(MODELS.anthropic.primary), // ← Consistent model config
messages: modelMessages,
tools: { /* ... */ },
})
Environment variables:
Provider fallback chain:
import { AIProviderFactory } from "@jakecelentano/ai-providers"
// Try OpenRouter first, fall back to Anthropic
const provider = await AIProviderFactory.createWithFallback([
{
providerType: 'openrouter',
apiKey: process.env.OPENROUTER_API_KEY!,
model: 'anthropic/claude-sonnet-4-5',
},
{
providerType: 'anthropic',
apiKey: process.env.ANTHROPIC_API_KEY!,
},
], { skipConnectionTest: false })
try {
const text = await provider.generateCompletion(prompt)
} finally {
await provider.close()
}
Version management:
# Update package in consuming app
npm install github:jakecelentano/ai-providers#v1.2.0
# Create new version (in ai-providers repo)
git tag v1.3.0
git push origin master v1.3.0
Current version: v1.2.0
Edge Runtime for Performance¶
// For AI streaming, real-time features, or lightweight operations
export const runtime = 'edge'
export async function GET() {
// Runs on edge, faster cold starts
return Response.json({ message: 'Hello from edge' })
}
When to use Edge: - ✅ AI streaming (Vercel AI SDK) - ✅ Simple data fetching - ✅ Real-time features - ❌ Heavy computation - ❌ Node.js-specific libraries
Tailwind CSS Patterns¶
Dynamic Class Names and Safelisting¶
The Problem: Tailwind CSS scans source files as plain text, looking for complete class names. Dynamic class construction like bg-${color}-500 won't work because Tailwind never sees the complete class names.
Best Practice #1: Use Lookup Tables (Preferred)
Map dynamic values to complete, static class names:
// lib/utils/tag-colors.ts
export const TAG_COLORS: Record<string, string> = {
'Protein': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
'Vegetable': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
'Dairy': 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
};
export function getTagColor(tagName: string): string {
return TAG_COLORS[tagName] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
}
Usage:
This works because all class names exist as complete strings in tag-colors.ts, so Tailwind detects them.
Best Practice #2: @source inline() Safelist (Tailwind v4.1+)
When lookup tables aren't practical (e.g., classes used only in data files or external configs), use the official Tailwind v4 safelist mechanism:
/* app/globals.css */
/* Force Tailwind to generate these classes even if not found in source files */
/* Using @source inline() - official Tailwind v4.1+ safelist mechanism */
/* See: https://tailwindcss.com/docs/detecting-classes-in-source-files */
@source inline("{bg,text}-{pink,rose,indigo,lime,sky,stone,teal,violet}-{100,200,700,800,900}");
Brace Expansion Syntax:
- {bg,text} → generates both bg- and text- prefixes
- {100,200,700,800,900} → generates all listed shade values
- Result: bg-pink-100, text-pink-800, bg-rose-200, etc.
When to Use Each Approach:
| Approach | Use When |
|---|---|
| Lookup tables | Class names are driven by TypeScript code you control |
@source inline() |
Classes come from external data, CMS, or database |
| Both | Complex apps with multiple dynamic class sources |
Common Mistakes:
// ❌ WRONG - Dynamic string interpolation
const colorClass = `bg-${color}-500` // Tailwind won't detect this
// ✅ CORRECT - Complete class name in lookup
const colorClass = COLOR_MAP[color] // Maps to 'bg-blue-500' etc.
Example from RMS (ingredient tag colors):
// lib/utils/ingredient-tag-colors.ts
export const INGREDIENT_TAG_COLORS: Record<string, string> = {
'Protein': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
'Seafood': 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-200',
'Vegetable': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
'Herb': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
// ... more categories
};
/* app/globals.css - safelist for colors not used elsewhere */
@source inline("{bg,text}-{pink,rose,indigo,lime,sky,stone,teal,violet}-{100,200,700,800,900}");
Sources: - Tailwind CSS - Detecting Classes in Source Files - GitHub Discussion - Safelist in v4
See Also¶
- Supabase Integration - Database and auth patterns
- App Conventions - Overall project structure
- Development Environment - Local setup