| name | ai-integration |
| description | AI/LLM integration patterns - Claude API, fal.ai, streaming, tool use |
| triggers | ["ai integration","claude api","anthropic","fal.ai","llm","streaming ai","tool use","yapay zeka","AI entegrasyon"] |
AI Integration Skill
Patterns for integrating AI services into Next.js applications: Anthropic Claude API, fal.ai, streaming, tool use, and cost optimization.
1. Anthropic Claude API - Messages
File: src/lib/ai/anthropic.ts
import Anthropic from '@anthropic-ai/sdk'
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
export async function generateCompletion({
systemPrompt,
userMessage,
maxTokens = 1024,
model = 'claude-sonnet-4-20250514',
}: {
systemPrompt: string
userMessage: string
maxTokens?: number
model?: string
}) {
const response = await anthropic.messages.create({
model,
max_tokens: maxTokens,
system: systemPrompt,
messages: [
{ role: 'user', content: userMessage },
],
})
const textBlock = response.content.find((block) => block.type === 'text')
if (!textBlock || textBlock.type !== 'text') {
throw new Error('No text response from Claude')
}
return {
text: textBlock.text,
usage: {
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
},
stopReason: response.stop_reason,
}
}
2. Streaming Responses (Next.js API Route)
File: src/app/api/ai/chat/route.ts
import Anthropic from '@anthropic-ai/sdk'
import { NextRequest } from 'next/server'
import { auth } from '@/lib/auth'
import { z } from 'zod'
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
const ChatSchema = z.object({
messages: z.array(z.object({
role: z.enum(['user', 'assistant']),
content: z.string().min(1).max(100000),
})),
systemPrompt: z.string().max(10000).optional(),
})
export async function POST(request: NextRequest) {
const { userId } = await auth()
if (!userId) {
return (, { : })
}
body = request.()
result = .(body)
(!result.) {
.(
{ : , : result..() },
{ : }
)
}
{ messages, systemPrompt } = result.
stream = anthropic..({
: ,
: ,
: systemPrompt ?? ,
messages,
})
encoder = ()
readable = ({
() {
{
( event stream) {
(
event. === &&
event.. ===
) {
chunk =
controller.(encoder.(chunk))
}
}
finalMessage = stream.()
done =
controller.(encoder.(done))
controller.()
} (error) {
.(, error)
errorChunk =
controller.(encoder.(errorChunk))
controller.()
}
},
})
(readable, {
: {
: ,
: ,
: ,
},
})
}
Client-Side Stream Consumer
'use client'
export function useAIStream() {
const [isStreaming, setIsStreaming] = useState(false)
const [streamedText, setStreamedText] = useState('')
const abortRef = useRef<AbortController | null>(null)
const startStream = useCallback(async (
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
systemPrompt?: string
) => {
setIsStreaming(true)
setStreamedText('')
abortRef.current = new AbortController()
try {
const response = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, systemPrompt }),
signal: abortRef.current.signal,
})
(!response.) ()
(!response.) ()
reader = response..()
decoder = ()
buffer =
() {
{ done, value } = reader.()
(done)
buffer += decoder.(value, { : })
lines = buffer.()
buffer = lines.() ??
( line lines) {
(!line.())
data = .(line.())
(data.) {
( prev + data.)
}
(data.) {
()
}
(data.) {
(data.)
}
}
}
} (error) {
((error ). !== ) {
.(, error)
}
} {
()
}
}, [])
stopStream = ( {
abortRef.?.()
()
}, [])
{ streamedText, isStreaming, startStream, stopStream }
}
3. Tool Use (Structured Output with Zod)
File: src/lib/ai/structured.ts
import Anthropic from '@anthropic-ai/sdk'
import { z } from 'zod'
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
const ExtractedProductSchema = z.object({
name: z.string(),
price: z.number(),
currency: z.string(),
features: z.array(z.string()),
category: z.enum(['electronics', 'clothing', 'food', 'other']),
})
type ExtractedProduct = z.infer<typeof ExtractedProductSchema>
export async function extractProductInfo(
rawText: string
): Promise<ExtractedProduct> {
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: ,
: [
{
: ,
: ,
: {
: ,
: {
: { : , : },
: { : , : },
: { : , : },
: {
: ,
: { : },
: ,
},
: {
: ,
: [, , , ],
: ,
},
},
: [, , , , ],
},
},
],
: { : , : },
: [
{
: ,
: ,
},
],
})
toolUseBlock = response..(
block. ===
)
(!toolUseBlock || toolUseBlock. !== ) {
()
}
.(toolUseBlock.)
}
Multi-Turn Tool Use (Agentic)
export async function runAgentLoop({
systemPrompt,
userMessage,
tools,
toolHandlers,
maxIterations = 10,
}: {
systemPrompt: string
userMessage: string
tools: Anthropic.Tool[]
toolHandlers: Record<string, (input: unknown) => Promise<string>>
maxIterations?: number
}) {
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: userMessage },
]
for (let i = 0; i < maxIterations; i++) {
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
system: systemPrompt,
tools,
messages,
})
if (response.stop_reason === 'end_turn') {
const textBlock = response.content.find((b) => b.type === 'text')
return textBlock?.type === 'text' ? textBlock.text : ''
}
messages.({ : , : response. })
: .[] = []
( block response.) {
(block. !== )
handler = toolHandlers[block.]
(!handler) {
toolResults.({
: ,
: block.,
: ,
: ,
})
}
{
result = (block.)
toolResults.({
: ,
: block.,
: result,
})
} (error) {
toolResults.({
: ,
: block.,
: ,
: ,
})
}
}
messages.({ : , : toolResults })
}
()
}
4. Prompt Caching
export async function cachedCompletion({
systemPrompt,
userMessage,
}: {
systemPrompt: string
userMessage: string
}) {
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
system: [
{
type: 'text',
text: systemPrompt,
cache_control: { type: 'ephemeral' },
},
],
messages: [{ role: 'user', content: userMessage }],
})
return {
text: response.content.find((b) => b.type === 'text')?.type === 'text'
? (response.content.find((b) => b.type === 'text') as Anthropic.TextBlock).text
: '',
usage: {
inputTokens: response.usage.input_tokens,
outputTokens: response..,
: response.. ?? ,
: response.. ?? ,
},
}
}
Caching Large Context (Documents, Knowledge Base)
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
system: [
{
type: 'text',
text: 'You are an expert analyst.',
},
],
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: largeDocumentText,
cache_control: { type: 'ephemeral' },
},
{
type: 'text',
text: 'Summarize the key findings from this document.',
},
],
},
],
})
5. fal.ai Integration
File: src/lib/ai/fal.ts
import { fal } from '@fal-ai/client'
fal.config({
credentials: process.env.FAL_KEY,
})
export async function generateImage({
prompt,
negativePrompt,
width = 1024,
height = 1024,
model = 'fal-ai/flux/dev',
}: {
prompt: string
negativePrompt?: string
width?: number
height?: number
model?: string
}) {
const result = await fal.subscribe(model, {
input: {
prompt,
negative_prompt: negativePrompt,
image_size: { width, height },
num_images: 1,
enable_safety_checker: true,
},
logs: true,
onQueueUpdate: (update) => {
if (update.status === 'IN_PROGRESS' && update.logs) {
for (const log of update.logs) {
console.log(`[fal] ${log.message}`)
}
}
},
})
return {
: result..[].,
: result..,
: result.,
}
}
() {
result = fal.(model, {
: {
: imageUrl,
prompt,
: | ,
},
: ,
: {
(update. === && update.) {
( log update.) {
.()
}
}
},
})
{
: result...,
: result.,
}
}
fal.ai Webhook Pattern (Async Processing)
export async function submitImageGeneration({
prompt,
callbackUrl,
metadata,
}: {
prompt: string
callbackUrl: string
metadata: Record<string, string>
}) {
const { request_id } = await fal.queue.submit('fal-ai/flux/dev', {
input: {
prompt,
image_size: { width: 1024, height: 1024 },
num_images: 1,
},
webhookUrl: callbackUrl,
})
return { requestId: request_id }
}
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { generations } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
export async function POST(request: NextRequest) {
const body = await request.()
{ request_id, status, payload } = body
(status === ) {
db
.(generations)
.({
: ,
: payload.[].,
: (),
})
.((generations., request_id))
} {
db
.(generations)
.({
: ,
: payload?. ?? ,
})
.((generations., request_id))
}
.({ : })
}
6. Rate Limiting and Retry
File: src/lib/ai/retry.ts
interface RetryOptions {
maxRetries: number
baseDelay: number
maxDelay: number
}
const DEFAULT_RETRY: RetryOptions = {
maxRetries: 3,
baseDelay: 1000,
maxDelay: 30000,
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: Partial<RetryOptions> = {}
): Promise<T> {
const { maxRetries, baseDelay, maxDelay } = { ...DEFAULT_RETRY, ...options }
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn()
} catch (error) {
const isRetryable =
error instanceof Error &&
('status' in error
? [429, 500, 502, 503, 529].includes(
(error as Error & { status: }).
)
: error..() ||
error..())
(!isRetryable || attempt === maxRetries) {
error
}
retryAfter =
error
? (
(error & { : <, > }).?.[
]
) *
:
delay = retryAfter || .(baseDelay * ** attempt, maxDelay)
jitter = delay * ( + .() * )
.(
)
( (resolve, jitter))
}
}
()
}
result = (
({
: ,
: ,
})
)
Per-User Rate Limiting
import { Redis } from '@upstash/redis'
const redis = Redis.fromEnv()
export async function checkAIRateLimit(
userId: string,
{
maxRequests = 50,
windowSeconds = 3600,
}: { maxRequests?: number; windowSeconds?: number } = {}
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
const key = `ai:rate:${userId}`
const now = Math.floor(Date.now() / 1000)
const windowStart = now - windowSeconds
await redis.zremrangebyscore(key, 0, windowStart)
const count = await redis.zcard(key)
if (count >= maxRequests) {
const oldest = await redis.zrange(key, 0, 0, { withScores: true })
resetAt = (
((oldest[]?. ?? now) + windowSeconds) *
)
{ : , : , resetAt }
}
redis.(key, { : now, : })
redis.(key, windowSeconds)
{
: ,
: maxRequests - count - ,
: ((now + windowSeconds) * ),
}
}
7. Token Counting and Context Management
import Anthropic from '@anthropic-ai/sdk'
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
export async function countTokens(
messages: Anthropic.MessageParam[],
systemPrompt?: string
): Promise<number> {
const result = await anthropic.messages.countTokens({
model: 'claude-sonnet-4-20250514',
system: systemPrompt,
messages,
})
return result.input_tokens
}
const MODEL_LIMITS: Record<string, number> = {
'claude-sonnet-4-20250514': 200000,
'claude-opus-4-20250514': 200000,
'claude-haiku-3-20250307': 200000,
}
export async function trimConversation({
messages,
systemPrompt,
model = 'claude-sonnet-4-20250514',
maxOutputTokens = ,
reserveRatio = ,
}: {
messages: Anthropic.MessageParam[]
systemPrompt?:
model?:
maxOutputTokens?:
reserveRatio?:
}): <.[]> {
limit = [model] ??
maxInput = .(limit * reserveRatio) - maxOutputTokens
tokenCount = (messages, systemPrompt)
trimmed = [...messages]
(tokenCount > maxInput && trimmed. > ) {
trimmed.(, )
tokenCount = (trimmed, systemPrompt)
}
trimmed
}
8. OpenAI SDK Pattern (Reference)
import OpenAI from 'openai'
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
export async function generateEmbedding(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
})
return response.data[0].embedding
}
export async function generateEmbeddings(
texts: string[]
): Promise<number[][]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: texts,
})
return response.data.map((d) => d.embedding)
}
9. Error Handling
import Anthropic from '@anthropic-ai/sdk'
export function handleAIError(error: unknown): {
message: string
retryable: boolean
statusCode: number
} {
if (error instanceof Anthropic.APIError) {
switch (error.status) {
case 400:
return {
message: 'Invalid request to AI service',
retryable: false,
statusCode: 400,
}
case 401:
return {
message: 'AI service authentication failed',
retryable: false,
statusCode: 500,
}
case 429:
return {
message: 'AI service rate limited. Please try again shortly.',
retryable: true,
statusCode: 429,
}
case :
{
: ,
: ,
: ,
}
:
{
: ,
: error. >= ,
: ,
}
}
}
{
: ,
: ,
: ,
}
}
() {
{
result = ({ ... })
.(result)
} (error) {
{ message, statusCode } = (error)
.(, error)
.({ : message }, { : statusCode })
}
}
Cost Optimization
| Strategy | Savings | When to Use |
|---|
| Prompt caching | 90% on cached tokens | Repeated system prompts, large documents |
| Haiku for simple tasks | 80-95% vs Opus | Classification, extraction, simple Q&A |
| Sonnet for most tasks | 50-80% vs Opus | Code generation, analysis, tool use |
| Batch API | 50% | Non-real-time processing, bulk operations |
| Shorter prompts | Linear | Always optimize prompt length |
| Token counting | Prevents waste | Before sending large contexts |
Model Selection Helper
type TaskComplexity = 'simple' | 'moderate' | 'complex'
function selectModel(complexity: TaskComplexity): string {
switch (complexity) {
case 'simple':
return 'claude-haiku-3-20250307'
case 'moderate':
return 'claude-sonnet-4-20250514'
case 'complex':
return 'claude-opus-4-20250514'
}
}
Checklist
[ ] API key stored in env var (never NEXT_PUBLIC_)
[ ] Auth check before every AI endpoint
[ ] Input validation with Zod
[ ] Rate limiting per user
[ ] Retry with exponential backoff for 429/5xx
[ ] Streaming for long responses
[ ] Token counting before large context sends
[ ] Proper error handling with handleAIError
[ ] Cost tracking via usage response fields
[ ] Prompt caching for repeated system prompts
[ ] Generic errors to client, detailed to server logs
[ ] AbortController support for client-side streaming
[ ] tsc --noEmit = 0 errors
Dependencies
npm install @anthropic-ai/sdk @fal-ai/client
npm install openai @upstash/redis
Red Flags (STOP)
| If You See | Fix |
|---|
NEXT_PUBLIC_ANTHROPIC_API_KEY | Move to server-only env var |
| No auth check on AI endpoint | Add auth() check |
| No rate limiting | Add per-user rate limits |
| Catching errors silently | Use handleAIError, log to server |
| Hardcoded model strings everywhere | Use selectModel() or constants |
| No streaming for chat UI | Use ReadableStream + SSE |
| Sending full conversation without trim | Use trimConversation() |
| fal.ai polling in a loop | Use fal.subscribe or webhooks |
10. OpenAI GPT-4o Integration
import OpenAI from 'openai'
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
export async function chatCompletion({
systemPrompt,
userMessage,
model = 'gpt-4o',
maxTokens = 4096,
}: {
systemPrompt: string
userMessage: string
model?: string
maxTokens?: number
}) {
const response = await openai.chat.completions.create({
model,
max_tokens: maxTokens,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
})
return {
text: response.choices[0].message.content ?? '',
usage: {
promptTokens: response.usage?.prompt_tokens ?? 0,
completionTokens: response.usage?.completion_tokens ?? 0,
},
: response.[].,
}
}
Function Calling
export async function functionCall<T>({
systemPrompt,
userMessage,
functionName,
functionDescription,
parameters,
}: {
systemPrompt: string
userMessage: string
functionName: string
functionDescription: string
parameters: Record<string, unknown>
}) {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
tools: [
{
type: 'function',
function: {
name: functionName,
description: functionDescription,
parameters,
},
},
],
tool_choice: { type: 'function', function: { name: functionName } },
})
const toolCall = response.choices[0].message.tool_calls?.[0]
if (!toolCall) throw new Error()
.(toolCall..) T
}
11. Embeddings + pgvector RAG Pipeline
Drizzle Schema with pgvector
import { pgTable, text, vector, bigint, timestamp } from 'drizzle-orm/pg-core'
export const documents = pgTable('documents', {
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
content: text('content').notNull(),
embedding: vector('embedding', { dimensions: 1536 }),
metadata: text('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
})
Enable pgvector Extension
CREATE EXTENSION IF NOT EXISTS vector;
Generate Embeddings
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
export async function generateEmbedding(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
})
return response.data[0].embedding
}
export async function generateEmbeddings(texts: string[]): Promise<number[][]> {
const results: number[][] = []
for (let i = 0; i < texts.length; i += 100) {
const batch = texts.slice(i, i + 100)
const response = openai..({
: ,
: batch,
})
results.(...response..( d.))
}
results
}
Similarity Search
import { db } from '@/lib/db'
import { documents } from '@/lib/db/schema'
import { cosineDistance, desc, gt, sql } from 'drizzle-orm'
import { generateEmbedding } from './embeddings'
export async function similaritySearch({
query,
limit = 5,
minSimilarity = 0.7,
}: {
query: string
limit?: number
minSimilarity?: number
}) {
const queryEmbedding = await generateEmbedding(query)
const similarity = sql<number>`1 - (${cosineDistance(documents.embedding, queryEmbedding)})`
const results = await db
.select({
id: documents.id,
content: documents.content,
metadata: documents.metadata,
similarity,
})
.from(documents)
.where(gt(similarity, minSimilarity))
.orderBy(desc(similarity))
.limit(limit)
return results
}
Full RAG Pipeline
import { similaritySearch } from './search'
import { chatCompletion } from './openai'
export async function ragQuery({
question,
systemPrompt = 'You are a helpful assistant. Answer based on the provided context.',
maxContextDocs = 5,
}: {
question: string
systemPrompt?: string
maxContextDocs?: number
}) {
const relevantDocs = await similaritySearch({
query: question,
limit: maxContextDocs,
})
const context = relevantDocs
.map((doc, i) => `[${i + 1}] ${doc.content}`)
.join('\n\n')
const augmentedMessage = `Context:\n${context}\n\nQuestion: ${question}`
const response = await chatCompletion({
systemPrompt,
userMessage: augmentedMessage,
})
return {
answer: response.text,
sources: relevantDocs,
usage: response.,
}
}
Ingest Documents
import { db } from '@/lib/db'
import { documents } from '@/lib/db/schema'
import { generateEmbeddings } from './embeddings'
export async function ingestDocuments(
docs: Array<{ content: string; metadata?: string }>
) {
const contents = docs.map((d) => d.content)
const embeddings = await generateEmbeddings(contents)
const rows = docs.map((doc, i) => ({
content: doc.content,
embedding: embeddings[i],
metadata: doc.metadata ?? null,
}))
await db.insert(documents).values(rows)
return { ingested: rows.length }
}
Dependencies
npm install openai