Skip to content

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:

{
  "@ai-sdk/anthropic": "^2.0.0",
  "@ai-sdk/react": "^2.0.0",
  "ai": "^5.0.0"
}

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:

npm install github:jakecelentano/ai-providers#v1.1.0

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:

ANTHROPIC_API_KEY=sk-ant-...
OPENROUTER_API_KEY=sk-or-...
DEFAULT_AI_PROVIDER=anthropic  # Optional

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:

<Badge className={getTagColor(tag.name)}>{tag.name}</Badge>

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