Modern JavaScript & TypeScript
for AI Applications
This is not a language tutorial. This is an engineering guide for developers building AI-powered applications — from real-time chat interfaces to backend orchestration layers, from structured prompt pipelines to production-grade TypeScript systems.
Why JS/TS is the dominant language for AI apps
?.optional or ??nullish and wonder what they do, MDN is one tab away.
JavaScript's Role in the AI Stack
JavaScript operates at every layer of a modern AI application. Understanding which layer handles what work is the foundation of good AI app architecture.
The AI Application Stack
Frontend: The User's AI Interface
The frontend orchestrates user interaction, streams responses, manages conversation state, and renders AI output in real time. It must handle partial JSON, markdown, code blocks, and tool call results gracefully.
// AIStreamChat.tsx — Production streaming chat component
import { useState, useRef, useCallback } from 'react';
interface Message {
role: 'user' | 'assistant';
content: string;
id: string;
}
export function AIStreamChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [streaming, setStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async () => {
if (!input.trim() || streaming) return;
const userMsg: Message = {
id: crypto.randomUUID(),
role: 'user',
content: input.trim(),
};
setMessages(prev => [...prev, userMsg]);
setInput('');
setStreaming(true);
// Placeholder for streaming assistant message
const assistantId = crypto.randomUUID();
setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '' }]);
abortRef.current = new AbortController();
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [...messages, userMsg] }),
signal: abortRef.current.signal,
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
if (!res.body) throw new Error('No response body');
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// Parse SSE data chunks
const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
for (const line of lines) {
const text = line.slice(6); // strip "data: "
if (text === '[DONE]') break;
try {
const parsed = JSON.parse(text);
const delta = parsed.choices?.[0]?.delta?.content ?? '';
setMessages(prev =>
prev.map(m =>
m.id === assistantId
? { ...m, content: m.content + delta }
: m
)
);
} catch {
// skip malformed chunks
}
}
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
setMessages(prev =>
prev.map(m =>
m.id === assistantId
? { ...m, content: '⚠️ Stream error. Please retry.' }
: m
)
);
}
} finally {
setStreaming(false);
}
}, [input, messages, streaming]);
return (
<div className="chat-container">
{messages.map(msg => (
<div key={msg.id} className={`message ${msg.role}`}>
{msg.content || (streaming ? '▋' : '')}
</div>
))}
<div className="input-row">
<input value={input} onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendMessage()}
placeholder="Send a message..." />
<button onClick={streaming ? () => abortRef.current?.abort() : sendMessage}>
{streaming ? 'Stop' : 'Send'}
</button>
</div>
</div>
);
}
Backend: The AI Orchestration Server
Node.js handles prompt construction, model routing, rate limiting, context management, streaming relay, and caching. It's the control plane for your AI operations.
// app/api/chat/route.ts — Next.js App Router streaming endpoint
import Anthropic from '@anthropic-ai/sdk';
import { z } from 'zod';
import { NextRequest } from 'next/server';
const client = new Anthropic(); // uses ANTHROPIC_API_KEY env var
const RequestSchema = z.object({
messages: z.array(z.object({
role: z.enum(['user', 'assistant']),
content: z.string().min(1).max(32_000),
})).min(1).max(100),
model: z.string().optional().default('claude-sonnet-4-20250514'),
});
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
const parsed = RequestSchema.safeParse(body);
if (!parsed.success) {
return new Response(JSON.stringify({ error: parsed.error.flatten() }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const { messages, model } = parsed.data;
const stream = client.messages.stream({
model,
max_tokens: 4096,
system: 'You are a precise, expert AI assistant.',
messages,
});
// Relay as SSE
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
try {
for await (const event of stream) {
if (event.type === 'content_block_delta' &&
event.delta.type === 'text_delta') {
const sseChunk = `data: ${JSON.stringify({
choices: [{ delta: { content: event.delta.text } }]
})}\n\n`;
controller.enqueue(encoder.encode(sseChunk));
}
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
} catch (err) {
controller.error(err);
} finally {
controller.close();
}
},
});
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
},
});
}
TypeScript in AI-Powered Systems
TypeScript isn't optional in production AI apps. It's the mechanism that enforces contracts between your prompt definitions, API responses, and downstream consumers.
Why TypeScript Matters More with AI
// You have no idea what comes back
const res = await callAI(prompt);
const name = res.user.profile.name;
// 💥 TypeError at runtime
// Contract-first design
const res: AIUserResponse = await callAI(prompt);
const name = res.user?.profile?.name ?? 'Unknown';
// ✅ Caught at compile time
Core TypeScript Patterns for AI Apps
// types/ai.ts — Central type definitions for your AI layer
// ─── Message types ───────────────────────────────────────────────
export type MessageRole = 'user' | 'assistant' | 'system';
export interface ChatMessage {
role: MessageRole;
content: string;
timestamp: number;
id: string;
metadata?: Record<string, unknown>;
}
// ─── Model configuration ─────────────────────────────────────────
export interface ModelConfig {
model: 'claude-sonnet-4-20250514' | 'claude-opus-4-20250514' | string;
maxTokens: number;
temperature?: number; // 0–1 for most models
topP?: number;
system?: string;
}
// ─── Prompt templates with type safety ───────────────────────────
export interface PromptTemplate<TInput extends Record<string, string>> {
name: string;
template: string;
variables: (keyof TInput)[];
render: (input: TInput) => string;
}
// Generic factory for typed prompt templates
export function createPrompt<T extends Record<string, string>>(
name: string,
template: string,
variables: (keyof T)[]
): PromptTemplate<T> {
return {
name,
template,
variables,
render: (input: T) =>
variables.reduce(
(acc, key) => acc.replace(`{{${String(key)}}}`, input[key]),
template
),
};
}
// Usage:
const summarizePrompt = createPrompt<{ document: string; maxWords: string }>(
'summarize',
'Summarize the following in {{maxWords}} words or fewer:\n\n{{document}}',
['document', 'maxWords']
);
const rendered = summarizePrompt.render({
document: '...',
maxWords: '100'
}); // ← TypeScript validates both keys exist
// ─── AI tool definitions (function calling) ──────────────────────
export interface AITool<TInput, TOutput> {
name: string;
description: string;
inputSchema: z.ZodType<TInput>;
execute: (input: TInput) => Promise<TOutput>;
}
// ─── Structured output via Zod ────────────────────────────────────
import { z } from 'zod';
export const SentimentSchema = z.object({
sentiment: z.enum(['positive', 'negative', 'neutral', 'mixed']),
score: z.number().min(-1).max(1),
reasoning: z.string(),
entities: z.array(z.string()).optional(),
});
export type SentimentAnalysis = z.infer<typeof SentimentSchema>;
// Force structured JSON output from AI
export async function analyzeSentiment(
client: Anthropic,
text: string
): Promise<SentimentAnalysis> {
const response = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 512,
system: 'You respond ONLY with valid JSON matching the requested schema.',
messages: [{
role: 'user',
content: `Analyze sentiment of: "${text}"\n\nRespond with JSON: { sentiment, score, reasoning, entities? }`
}]
});
const raw = response.content[0].type === 'text'
? response.content[0].text
: '';
return SentimentSchema.parse(JSON.parse(raw));
}
Generics for Reusable AI Utilities
// A typed Result monad for AI operations — no thrown exceptions
type AIResult<T> =
| { success: true; data: T; usage: TokenUsage }
| { success: false; error: string; code: AIErrorCode };
type TokenUsage = { input: number; output: number; total: number };
type AIErrorCode = 'rate_limit' | 'context_overflow' | 'invalid_response' | 'timeout' | 'unknown';
// Wrapper that never throws
async function safeAICall<T>(
fn: () => Promise<T>,
usage?: TokenUsage
): Promise<AIResult<T>> {
try {
const data = await fn();
return { success: true, data, usage: usage ?? { input: 0, output: 0, total: 0 } };
} catch (err: unknown) {
if (err instanceof Anthropic.RateLimitError) {
return { success: false, error: 'Rate limit exceeded', code: 'rate_limit' };
}
if (err instanceof Anthropic.BadRequestError) {
return { success: false, error: 'Context too large', code: 'context_overflow' };
}
const message = err instanceof Error ? err.message : 'Unknown error';
return { success: false, error: message, code: 'unknown' };
}
}
// Caller uses discriminated union — no runtime surprises
const result = await safeAICall(() => runInference(prompt));
if (!result.success) {
if (result.code === 'rate_limit') await delay(1000);
logError(result.error);
return;
}
// ✅ TypeScript knows result.data is defined here
process(result.data);
Modern JS Patterns You'll Actually Use
Skip the beginner stuff. These are the ES2020+ patterns that appear repeatedly in AI application codebases.
Destructuring & Optional Chaining in API Responses
// Deep destructuring of AI API responses
const {
id,
model,
usage: { input_tokens, output_tokens },
content: [{ type, text } = {}],
stop_reason = 'unknown',
} = response;
// Optional chaining — safe navigation through uncertain AI output
const firstCodeBlock = response
?.content
?.find(block => block.type === 'code')
?.code
?? ''; // ← Nullish coalescing: fallback if null/undefined (not 0 or '')
// Logical assignment operators (ES2021)
config.systemPrompt ??= 'You are a helpful assistant.'; // assign if nullish
config.temperature ||= 0.7; // assign if falsy
config.maxRetries &&= Math.min(config.maxRetries, 5); // assign if truthy
// Object.fromEntries for transforming AI tool results
const toolResultsMap = Object.fromEntries(
toolResults.map(({ name, output }) => [name, output])
);
Array Methods for Data Pipelines
// Build a conversation history from raw events
const conversationHistory = rawEvents
.filter(e => e.type === 'message' && e.content.trim())
.map(({ role, content, timestamp }) => ({ role, content, timestamp }))
.slice(-20); // keep last 20 turns (context window management)
// Chunk documents for embedding / RAG pipelines
function chunkText(text, chunkSize = 512, overlap = 64) {
const words = text.split(/\s+/);
const chunks = [];
let i = 0;
while (i < words.length) {
chunks.push(words.slice(i, i + chunkSize).join(' '));
i += chunkSize - overlap;
}
return chunks;
}
// Group messages by session with reduce
const sessions = messages.reduce((acc, msg) => {
(acc[msg.sessionId] ??= []).push(msg);
return acc;
}, {});
// flatMap — flatten tool call results from parallel executions
const allResults = toolCallBatches
.flatMap(batch => batch.results)
.filter(r => r.status === 'success');
Module Patterns for AI Services
// lib/ai/client.ts — Singleton client with lazy initialization
import Anthropic from '@anthropic-ai/sdk';
let _client: Anthropic | null = null;
export function getAIClient(): Anthropic {
if (!_client) {
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error('ANTHROPIC_API_KEY is required');
}
_client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
maxRetries: 3,
timeout: 60_000,
});
}
return _client;
}
// Named exports for tree-shaking
export { createPrompt, type PromptTemplate } from './prompts';
export { withRetry, withCache } from './middleware';
export { RateLimiter } from './rate-limiter';
// Re-export types only (zero runtime cost)
export type { ChatMessage, ModelConfig, AIResult } from '../types/ai';
Async Mastery for AI Applications
AI APIs are fundamentally asynchronous — inference takes time, streams arrive in chunks, and parallel tool calls need coordination. Mastering async is non-negotiable.
await inside a loop when calls are independent. Use Promise.all() for parallelism, Promise.allSettled() when partial failure is acceptable.
Parallel vs Sequential AI Calls
// ❌ Sequential — takes N * latency
async function analyzeDocsBad(docs: string[]) {
const results = [];
for (const doc of docs) {
results.push(await analyzeDoc(doc)); // waits each time
}
return results;
}
// ✅ Parallel — takes ~1 * latency
async function analyzeDocsGood(docs: string[]) {
return Promise.all(docs.map(doc => analyzeDoc(doc)));
}
// ✅ Parallel with partial failure tolerance
async function analyzeDocsSafe(docs: string[]) {
const settled = await Promise.allSettled(docs.map(analyzeDoc));
return settled.map((result, i) => ({
doc: docs[i],
status: result.status,
data: result.status === 'fulfilled' ? result.value : null,
error: result.status === 'rejected' ? result.reason.message : null,
}));
}
// ✅ Controlled concurrency — avoid rate limit errors
async function analyzeWithConcurrency(
docs: string[],
concurrency = 5
) {
const results: Awaited<ReturnType<typeof analyzeDoc>>[] = [];
for (let i = 0; i < docs.length; i += concurrency) {
const batch = docs.slice(i, i + concurrency);
const batchResults = await Promise.all(batch.map(analyzeDoc));
results.push(...batchResults);
// Rate limit: pause between batches
if (i + concurrency < docs.length) await delay(200);
}
return results;
}
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
Retry Logic with Exponential Backoff
interface RetryOptions {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
retryable?: (error: unknown) => boolean;
onRetry?: (attempt: number, error: unknown) => void;
}
async function withRetry<T>(
fn: () => Promise<T>,
opts: RetryOptions
): Promise<T> {
const {
maxAttempts, baseDelayMs, maxDelayMs,
retryable = isRetryable,
onRetry,
} = opts;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxAttempts || !retryable(error)) throw error;
// Exponential backoff with jitter
const expDelay = baseDelayMs * Math.pow(2, attempt - 1);
const jitter = Math.random() * baseDelayMs;
const waitMs = Math.min(expDelay + jitter, maxDelayMs);
onRetry?.(attempt, error);
await delay(waitMs);
}
}
throw lastError;
}
function isRetryable(error: unknown): boolean {
if (error instanceof Anthropic.RateLimitError) return true;
if (error instanceof Anthropic.InternalServerError) return true;
if (error instanceof Anthropic.APIConnectionError) return true;
return false;
}
// Usage
const result = await withRetry(
() => client.messages.create({ model, messages, max_tokens: 1024 }),
{ maxAttempts: 4, baseDelayMs: 500, maxDelayMs: 15_000,
onRetry: (n, e) => console.warn(`Retry ${n}:`, e) }
);
Server-Sent Events (SSE) Parsing
// Parse SSE streams from any AI provider (agnostic)
async function* parseSSEStream(
response: Response
): AsyncGenerator<string> {
if (!response.body) throw new Error('Empty response body');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // keep incomplete line
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') return;
if (data) yield data;
}
}
} finally {
reader.releaseLock();
}
}
// Consumer
for await (const rawChunk of parseSSEStream(response)) {
const event = JSON.parse(rawChunk);
process.stdout.write(event?.choices?.[0]?.delta?.content ?? '');
}
AI API Integration Patterns
Direct API integration gives you full control. These patterns cover Anthropic's API in depth, with patterns that transfer to any major provider.
| Provider | SDK Package | Streaming | Tool Use | Vision |
|---|---|---|---|---|
| Anthropic | @anthropic-ai/sdk | ✅ SSE | ✅ Native | ✅ |
| OpenAI | openai | ✅ SSE | ✅ Native | ✅ |
| Google Gemini | @google/generative-ai | ✅ | ✅ | ✅ |
| Mistral | @mistralai/mistraljs | ✅ | ✅ | ✅ |
| Vercel AI SDK | ai | ✅ Unified | ✅ Unified | ✅ |
Prompt Engineering in Code
// Structured prompt builder — maintainable, testable
class PromptBuilder {
private systemParts: string[] = [];
private userParts: string[] = [];
role(instruction: string) {
this.systemParts.push(`## Role\n${instruction}`);
return this;
}
context(ctx: string) {
this.systemParts.push(`## Context\n${ctx}`);
return this;
}
constraints(rules: string[]) {
const list = rules.map((r, i) => `${i + 1}. ${r}`).join('\n');
this.systemParts.push(`## Constraints\n${list}`);
return this;
}
outputFormat(format: string) {
this.systemParts.push(`## Output Format\n${format}`);
return this;
}
userMessage(content: string) {
this.userParts.push(content);
return this;
}
build(): { system: string; userContent: string } {
return {
system: this.systemParts.join('\n\n'),
userContent: this.userParts.join('\n\n'),
};
}
}
// Usage
const { system, userContent } = new PromptBuilder()
.role('You are an expert code reviewer specializing in TypeScript and Node.js.')
.constraints([
'Focus only on bugs, security issues, and performance problems.',
'Ignore formatting and stylistic preferences.',
'Rank issues by severity: critical, high, medium, low.',
'Respond in JSON only.',
])
.outputFormat('{ issues: [{ severity, line, description, fix }] }')
.userMessage(`Review this code:\n\`\`\`typescript\n${codeToReview}\n\`\`\``)
.build();
const response = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 2048,
system,
messages: [{ role: 'user', content: userContent }],
});
Tool Use (Function Calling)
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
// Define tools with strict JSON Schema
const tools: Anthropic.Tool[] = [
{
name: 'search_database',
description: 'Query the product database for items matching criteria.',
input_schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
category: { type: 'string', enum: ['electronics', 'books', 'clothing'] },
maxPrice: { type: 'number', minimum: 0 },
limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
},
required: ['query'],
},
},
{
name: 'calculate_shipping',
description: 'Estimate shipping cost for a product.',
input_schema: {
type: 'object',
properties: {
productId: { type: 'string' },
zipCode: { type: 'string', pattern: '^[0-9]{5}$' },
expedited: { type: 'boolean', default: false },
},
required: ['productId', 'zipCode'],
},
},
];
// Agentic loop — model calls tools until it reaches final answer
async function runAgent(userQuery: string): Promise<string> {
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: userQuery }
];
while (true) {
const response = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
tools,
messages,
});
// Add assistant's response to history
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason === 'end_turn') {
// Extract final text response
const textBlock = response.content.find(b => b.type === 'text');
return textBlock?.text ?? '';
}
if (response.stop_reason === 'tool_use') {
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== 'tool_use') continue;
const result = await executeToolCall(block.name, block.input);
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify(result),
});
}
messages.push({ role: 'user', content: toolResults });
}
}
}
// Tool executor — routes to real implementations
async function executeToolCall(
name: string,
input: Record<string, unknown>
): Promise<unknown> {
switch (name) {
case 'search_database': return await db.search(input as SearchParams);
case 'calculate_shipping': return await shipping.estimate(input as ShippingParams);
default: throw new Error(`Unknown tool: ${name}`);
}
}
Building AI-Powered UI Experiences
AI-integrated frontends demand new interaction patterns — streaming text rendering, optimistic UI, loading skeletons, abort control, and graceful degradation under latency.
Custom Hook: useAIStream
// hooks/useAIStream.ts — Reusable streaming hook
import { useState, useRef, useCallback } from 'react';
interface UseAIStreamOptions {
endpoint: string;
onChunk?: (chunk: string) => void;
onComplete?: (fullText: string) => void;
onError?: (error: Error) => void;
}
interface UseAIStreamReturn {
text: string;
streaming: boolean;
error: string | null;
stream: (payload: unknown) => Promise<void>;
abort: () => void;
reset: () => void;
}
export function useAIStream({
endpoint,
onChunk,
onComplete,
onError,
}: UseAIStreamOptions): UseAIStreamReturn {
const [text, setText] = useState('');
const [streaming, setStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fullTextRef = useRef('');
const stream = useCallback(async (payload: unknown) => {
setText('');
setError(null);
setStreaming(true);
fullTextRef.current = '';
abortRef.current = new AbortController();
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: abortRef.current.signal,
});
if (!res.ok) {
const errBody = await res.json().catch(() => ({}));
throw new Error(errBody.message ?? `HTTP ${res.status}`);
}
for await (const chunk of parseSSEStream(res)) {
try {
const data = JSON.parse(chunk);
const delta = data?.choices?.[0]?.delta?.content
?? data?.delta?.text
?? '';
if (delta) {
fullTextRef.current += delta;
setText(prev => prev + delta);
onChunk?.(delta);
}
} catch { /* skip malformed */ }
}
onComplete?.(fullTextRef.current);
} catch (err: unknown) {
if ((err as Error).name === 'AbortError') return;
const msg = err instanceof Error ? err.message : 'Stream failed';
setError(msg);
onError?.(err instanceof Error ? err : new Error(msg));
} finally {
setStreaming(false);
}
}, [endpoint, onChunk, onComplete, onError]);
const abort = useCallback(() => {
abortRef.current?.abort();
setStreaming(false);
}, []);
const reset = useCallback(() => {
setText(''); setError(null);
}, []);
return { text, streaming, error, stream, abort, reset };
}
AI Response Rendering Patterns
// components/StreamingMarkdown.tsx
import { useMemo } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
interface Props {
content: string;
streaming: boolean;
}
// Configure marked for safe rendering
marked.setOptions({
gfm: true,
breaks: true,
});
export function StreamingMarkdown({ content, streaming }: Props) {
// Only re-parse when content changes (memoized)
const html = useMemo(() => {
const raw = marked.parse(content) as string;
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS: ['p', 'h1','h2','h3','strong','em','code',
'pre','ul','ol','li','blockquote','a'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
}, [content]);
return (
<div className="prose prose-invert max-w-none">
<div dangerouslySetInnerHTML={{ __html: html }} />
{streaming && (
<span
className="inline-block w-[2px] h-[1em] bg-current ml-0.5 animate-pulse"
aria-hidden="true"
/>
)}
</div>
);
}
Building AI-Backed API Services
Your Node.js backend is the trust boundary for your AI application — it holds the API keys, enforces rate limits, validates inputs, and routes between model providers.
Hono.js — Lightweight API Server for AI
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { rateLimiter } from 'hono-rate-limiter';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import Anthropic from '@anthropic-ai/sdk';
const app = new Hono();
const client = new Anthropic();
// ─── Global middleware ───────────────────────────────────────────
app.use('*', logger());
app.use('*', cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') ?? '*' }));
app.use('/api/*', rateLimiter({
windowMs: 60_000, // 1 minute
limit: 20, // 20 requests per user per minute
keyGenerator: (c) => c.req.header('x-user-id') ?? c.req.raw.headers.get('cf-connecting-ip') ?? 'unknown',
}));
// ─── Auth middleware ─────────────────────────────────────────────
app.use('/api/*', async (c, next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '');
if (!token || !isValidToken(token)) {
return c.json({ error: 'Unauthorized' }, 401);
}
c.set('userId', decodeToken(token).userId);
await next();
});
// ─── Chat completion route ───────────────────────────────────────
const ChatSchema = z.object({
messages: z.array(z.object({
role: z.enum(['user', 'assistant']),
content: z.string().min(1).max(16_000),
})).min(1).max(50),
stream: z.boolean().optional().default(true),
model: z.string().optional(),
});
app.post(
'/api/chat',
zValidator('json', ChatSchema),
async (c) => {
const { messages, stream, model } = c.req.valid('json');
const userId = c.get('userId');
if (!stream) {
// Non-streaming response
const response = await client.messages.create({
model: model ?? 'claude-sonnet-4-20250514',
max_tokens: 4096,
messages,
});
return c.json({ content: response.content, usage: response.usage });
}
// Streaming response
return c.stream(async (stream) => {
const aiStream = client.messages.stream({
model: model ?? 'claude-sonnet-4-20250514',
max_tokens: 4096,
messages,
});
for await (const event of aiStream) {
if (event.type === 'content_block_delta' &&
event.delta.type === 'text_delta') {
await stream.write(
`data: ${JSON.stringify({ delta: event.delta.text })}\n\n`
);
}
}
await stream.write('data: [DONE]\n\n');
}, 200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
});
}
);
// ─── Health + metrics ────────────────────────────────────────────
app.get('/health', (c) => c.json({ status: 'ok', ts: Date.now() }));
export default app;
Context & Session Management
// lib/context-manager.ts
// Context window management: keep cost under control without losing coherence
const TOKEN_BUDGET = 180_000; // Leave headroom in 200k window
const SUMMARY_TRIGGER = 80_000; // Summarize when context exceeds this
class ContextManager {
private sessions = new Map<string, ConversationSession>();
async getContext(sessionId: string): Promise<ChatMessage[]> {
const session = this.sessions.get(sessionId);
if (!session) return [];
return session.messages;
}
async addMessage(
sessionId: string,
message: ChatMessage
): Promise<void> {
const session = this.sessions.get(sessionId) ?? { messages: [] };
session.messages.push(message);
const estimatedTokens = estimateTokens(session.messages);
if (estimatedTokens > SUMMARY_TRIGGER) {
await this.summarizeOldMessages(session);
}
this.sessions.set(sessionId, session);
}
private async summarizeOldMessages(
session: ConversationSession
): Promise<void> {
// Keep the last 10 messages verbatim, summarize the rest
const toSummarize = session.messages.slice(0, -10);
const recent = session.messages.slice(-10);
const summary = await summarizeConversation(toSummarize);
session.messages = [
{
role: 'user',
content: `[Previous conversation summary: ${summary}]`,
id: crypto.randomUUID(),
timestamp: Date.now(),
},
...recent,
];
}
}
function estimateTokens(messages: ChatMessage[]): number {
// Rule of thumb: ~1 token per 4 characters
return messages.reduce((sum, m) => sum + Math.ceil(m.content.length / 4), 0);
}
Working with Data in AI Pipelines
AI applications are data transformation machines — ingesting documents, parsing structured responses, normalizing schemas, and preparing data for embedding or generation.
Structured JSON Extraction from AI
import { z } from 'zod';
// Define expected schema
const InvoiceSchema = z.object({
invoiceNumber: z.string(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
vendor: z.object({
name: z.string(),
email: z.string().email().optional(),
address: z.string().optional(),
}),
lineItems: z.array(z.object({
description: z.string(),
quantity: z.number().positive(),
unitPrice: z.number().nonnegative(),
total: z.number().nonnegative(),
})),
subtotal: z.number(),
tax: z.number().optional(),
total: z.number(),
currency: z.string().length(3).default('USD'),
});
type Invoice = z.infer<typeof InvoiceSchema>;
async function extractInvoice(
client: Anthropic,
documentText: string
): Promise<Invoice> {
const response = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 1024,
system: 'Extract invoice data. Respond with ONLY valid JSON, no markdown.',
messages: [{
role: 'user',
content: `Extract invoice data from this document:\n\n${documentText}\n\nJSON schema: ${JSON.stringify(InvoiceSchema._def, null, 2)}`,
}],
});
const raw = response.content[0].type === 'text'
? response.content[0].text.trim()
: '';
// Strip accidental markdown fences
const cleaned = raw.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
// Parse and validate
const parsed = JSON.parse(cleaned);
return InvoiceSchema.parse(parsed); // throws ZodError if invalid
}
// Batch processing with error collection
async function batchExtractInvoices(
client: Anthropic,
documents: string[]
): Promise<{ success: Invoice[]; failed: { index: number; error: string }[] }> {
const results = await Promise.allSettled(
documents.map(doc => extractInvoice(client, doc))
);
return results.reduce(
(acc, result, i) => {
if (result.status === 'fulfilled') {
acc.success.push(result.value);
} else {
acc.failed.push({ index: i, error: result.reason.message });
}
return acc;
},
{ success: [] as Invoice[], failed: [] as { index: number; error: string }[] }
);
}
Document Preprocessing for RAG
interface DocumentChunk {
id: string;
content: string;
metadata: ChunkMetadata;
embedding?: number[];
}
interface ChunkMetadata {
sourceId: string;
chunkIndex: number;
totalChunks: number;
charStart: number;
charEnd: number;
}
// Semantic chunking — split on section boundaries
function semanticChunk(
text: string,
sourceId: string,
options: { maxChars: number; overlapChars: number } = {
maxChars: 1500,
overlapChars: 200,
}
): DocumentChunk[] {
const { maxChars, overlapChars } = options;
// First try to split on headings (##, ###)
let segments = text.split(/(?=\n#{1,3}\s)/);
// Fall back to paragraph splits
if (segments.every(s => s.length > maxChars)) {
segments = text.split(/\n\n+/);
}
const chunks: DocumentChunk[] = [];
let buffer = '';
let charOffset = 0;
for (const segment of segments) {
if (buffer.length + segment.length > maxChars && buffer.length > 0) {
const charStart = charOffset - buffer.length;
chunks.push({
id: `${sourceId}-${chunks.length}`,
content: buffer.trim(),
metadata: {
sourceId, chunkIndex: chunks.length,
totalChunks: 0, // filled after
charStart, charEnd: charOffset,
},
});
// Overlap: keep last N chars for context continuity
buffer = buffer.slice(-overlapChars);
}
buffer += segment;
charOffset += segment.length;
}
if (buffer.trim()) {
chunks.push({
id: `${sourceId}-${chunks.length}`,
content: buffer.trim(),
metadata: {
sourceId, chunkIndex: chunks.length,
totalChunks: 0,
charStart: charOffset - buffer.length,
charEnd: charOffset,
},
});
}
// Patch totalChunks
return chunks.map(c => ({
...c,
metadata: { ...c.metadata, totalChunks: chunks.length },
}));
}
Scalable AI Application Architecture
Structure your project so that prompt definitions, model configuration, API integration, and UI components each have a clear home — and no layer bleeds into another.
Next.js Full-Stack AI App Structure
my-ai-app/
├── app/ # Next.js App Router
│ ├── (auth)/
│ │ ├── login/page.tsx
│ │ └── layout.tsx
│ ├── chat/
│ │ ├── [sessionId]/page.tsx
│ │ └── layout.tsx
│ ├── api/
│ │ ├── chat/route.ts # Streaming chat endpoint
│ │ ├── tools/route.ts # Tool execution endpoint
│ │ └── embed/route.ts # Embedding endpoint
│ └── layout.tsx
│
├── lib/ # Core application logic
│ ├── ai/
│ │ ├── client.ts # Anthropic client singleton
│ │ ├── prompts/
│ │ │ ├── system.ts # System prompt definitions
│ │ │ ├── tasks.ts # Task-specific prompt templates
│ │ │ └── index.ts
│ │ ├── tools/
│ │ │ ├── definitions.ts # Tool JSON schemas
│ │ │ ├── executor.ts # Tool routing + execution
│ │ │ └── implementations/ # Actual tool functions
│ │ ├── context-manager.ts # Context window management
│ │ └── streaming.ts # SSE stream utilities
│ │
│ ├── db/
│ │ ├── client.ts # Supabase / DB client
│ │ ├── queries/ # Typed query functions
│ │ └── schema.ts # Zod schema ↔ DB types
│ │
│ ├── cache/
│ │ ├── redis.ts # Redis client (Upstash)
│ │ └── strategies.ts # Cache invalidation patterns
│ │
│ └── middleware/
│ ├── auth.ts # JWT verification
│ ├── rate-limit.ts # Per-user rate limiting
│ └── validate.ts # Request validation
│
├── components/
│ ├── chat/
│ │ ├── ChatInterface.tsx # Main chat component
│ │ ├── MessageBubble.tsx
│ │ ├── StreamingMarkdown.tsx
│ │ └── ToolCallDisplay.tsx
│ └── ui/ # Design system components
│
├── hooks/
│ ├── useAIStream.ts
│ ├── useConversation.ts
│ └── useToolResults.ts
│
├── types/
│ ├── ai.ts # Core AI types
│ ├── api.ts # API request/response types
│ └── db.ts # Database entity types
│
├── config/
│ ├── models.ts # Model configurations
│ └── features.ts # Feature flags
│
└── tests/
├── unit/ # Jest unit tests
├── integration/ # API integration tests
└── e2e/ # Playwright E2E tests
Environment Configuration
// config/env.ts — fail fast on missing env vars
import { z } from 'zod';
const EnvSchema = z.object({
// AI
ANTHROPIC_API_KEY: z.string().min(1),
OPENAI_API_KEY: z.string().optional(),
// Database
DATABASE_URL: z.string().url(),
SUPABASE_ANON_KEY: z.string().min(1),
// Cache
REDIS_URL: z.string().url().optional(),
// Auth
JWT_SECRET: z.string().min(32),
NEXTAUTH_SECRET: z.string().min(32),
NEXTAUTH_URL: z.string().url(),
// App
NODE_ENV: z.enum(['development', 'production', 'test']),
ALLOWED_ORIGINS: z.string().optional(),
MAX_REQUESTS_PER_MINUTE: z.coerce.number().default(20),
});
export const env = (() => {
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment variables:');
console.error(result.error.flatten().fieldErrors);
process.exit(1); // Hard fail on startup
}
return result.data;
})();
// Types flow everywhere from this single source
export type Env = z.infer<typeof EnvSchema>;
Production-Grade AI Engineering
These practices separate hobby projects from production systems. Every AI application handling real users needs these foundations.
Security: Never Expose AI Keys to the Frontend
NEXT_PUBLIC_, or client-side bundles. Always proxy through your backend.
// lib/security/sanitize.ts
// Patterns that indicate prompt injection attempts
const INJECTION_PATTERNS = [
/ignore\s+(all\s+)?previous\s+instructions/i,
/you\s+are\s+now\s+(a|an)\s+/i,
/system\s+prompt\s*:/i,
/\[INST\]/,
/<<SYS>>/,
/\[\/INST\]/,
];
export function sanitizeUserInput(input: string): {
safe: string;
flagged: boolean;
reason?: string;
} {
// Length check
if (input.length > 16_000) {
return { safe: input.slice(0, 16_000), flagged: true, reason: 'Truncated: exceeded max length' };
}
// Injection detection
for (const pattern of INJECTION_PATTERNS) {
if (pattern.test(input)) {
return { safe: '', flagged: true, reason: `Potential prompt injection: ${pattern}` };
}
}
// Strip null bytes and control characters (keep \n, \t)
const clean = input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
return { safe: clean, flagged: false };
}
// Wrap user content to prevent bleeding into system context
export function wrapUserContent(content: string): string {
return `<user_message>\n${content}\n</user_message>`;
}
// Rate limit by user + route
export const rateLimits = {
chat: { requests: 30, windowMs: 60_000 }, // 30/min
embed: { requests: 100, windowMs: 60_000 }, // 100/min
analyze: { requests: 10, windowMs: 60_000 }, // 10/min (expensive)
} as const;
Observability: Logging AI Interactions
// lib/telemetry/ai-logger.ts
interface AICallLog {
traceId: string;
userId: string;
model: string;
inputTokens: number;
outputTokens: number;
latencyMs: number;
status: 'success' | 'error' | 'aborted';
error?: string;
cost?: number; // USD
cached: boolean;
}
const TOKEN_COSTS: Record<string, { input: number; output: number }> = {
'claude-sonnet-4-20250514': { input: 0.000003, output: 0.000015 }, // per token
'claude-opus-4-20250514': { input: 0.000015, output: 0.000075 },
};
export async function logAICall(log: AICallLog): Promise<void> {
const cost = TOKEN_COSTS[log.model];
const totalCost = cost
? (log.inputTokens * cost.input + log.outputTokens * cost.output)
: undefined;
// Structured log for log aggregation (Datadog, Grafana, etc.)
console.log(JSON.stringify({
event: 'ai_call',
level: log.status === 'error' ? 'error' : 'info',
...log,
cost: totalCost?.toFixed(6),
timestamp: new Date().toISOString(),
}));
// Async: persist to DB for cost tracking (fire and forget)
persistToMetricsDB(log).catch(console.error);
}
Error Handling Hierarchy
Retry-After header.Production AI Application Patterns
Three complete application patterns you can adapt immediately. Each represents a class of AI product with distinct technical requirements.
Pattern 1 — AI Document Analyzer
// api/analyze-document/route.ts
const DocumentAnalysisSchema = z.object({
summary: z.string(),
keyPhrases: z.array(z.string()),
risks: z.array(z.object({
severity: z.enum(['high', 'medium', 'low']),
clause: z.string(),
explanation: z.string(),
})),
obligations: z.array(z.object({
party: z.string(),
action: z.string(),
deadline: z.string().optional(),
})),
recommendation: z.enum(['sign', 'review', 'reject']),
});
export async function analyzeDocument(
client: Anthropic,
pdfBase64: string
): Promise<z.infer<typeof DocumentAnalysisSchema>> {
const response = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
system: `You are a contract analysis AI.
Analyze the provided document and respond with ONLY valid JSON.
Focus on: risks, obligations, and whether to sign.`,
messages: [{
role: 'user',
content: [
{
type: 'document',
source: { type: 'base64', media_type: 'application/pdf', data: pdfBase64 },
},
{
type: 'text',
text: 'Analyze this contract. Return JSON with: summary, keyPhrases, risks[], obligations[], recommendation.',
},
],
}],
});
const raw = (response.content[0] as { text: string }).text;
const cleaned = raw.replace(/^```json?\n?/, '').replace(/\n?```$/, '');
return DocumentAnalysisSchema.parse(JSON.parse(cleaned));
}
Pattern 2 — Multi-Turn AI Chat with Memory
// A stateful agent that remembers user preferences across sessions
class PersonalizedAgent {
private client: Anthropic;
private db: Database;
private memory: ContextManager;
constructor(client: Anthropic, db: Database) {
this.client = client;
this.db = db;
this.memory = new ContextManager();
}
async chat(
userId: string,
sessionId: string,
userInput: string
): Promise<AsyncIterable<string>> {
// 1. Load user profile and preferences
const profile = await this.db.users.get(userId);
// 2. Load conversation history
const history = await this.memory.getContext(sessionId);
// 3. Build personalized system prompt
const system = `You are a helpful assistant.
User profile: ${JSON.stringify(profile.preferences)}
Communication style: ${profile.tone ?? 'professional'}
Language: ${profile.preferredLanguage ?? 'English'}
Always address them as ${profile.displayName}.`;
// 4. Persist user message
await this.memory.addMessage(sessionId, {
id: crypto.randomUUID(),
role: 'user',
content: userInput,
timestamp: Date.now(),
});
// 5. Stream response
const stream = this.client.messages.stream({
model: 'claude-sonnet-4-20250514',
max_tokens: 2048,
system,
messages: [...history, { role: 'user', content: userInput }],
});
// 6. Collect and persist assistant reply
const generator = async function* (this: PersonalizedAgent) {
let fullReply = '';
for await (const event of stream) {
if (event.type === 'content_block_delta' &&
event.delta.type === 'text_delta') {
fullReply += event.delta.text;
yield event.delta.text;
}
}
await this.memory.addMessage(sessionId, {
id: crypto.randomUUID(),
role: 'assistant',
content: fullReply,
timestamp: Date.now(),
});
}.bind(this);
return generator();
}
}
Pattern 3 — AI Content Pipeline (Batch)
// Use Anthropic's Batch API for high-volume, non-real-time tasks
import Anthropic from '@anthropic-ai/sdk';
async function batchGenerateDescriptions(
client: Anthropic,
products: Array<{ id: string; name: string; specs: string }>
): Promise<Map<string, string>> {
// Create batch requests
const requests: Anthropic.MessageCreateParamsNonStreaming[] = products.map(p => ({
custom_id: p.id,
params: {
model: 'claude-haiku-4-5-20251001', // fast + cheap for bulk
max_tokens: 300,
messages: [{
role: 'user',
content: `Write a compelling 2-sentence product description for:\nName: ${p.name}\nSpecs: ${p.specs}`,
}],
},
}));
// Submit batch (async, no streaming needed)
const batch = await client.batches.create({ requests });
console.log(`Batch ${batch.id} submitted: ${batch.request_counts.processing} requests`);
// Poll until complete
let status = batch;
while (status.processing_status !== 'ended') {
await delay(5_000);
status = await client.batches.retrieve(batch.id);
console.log(`Status: ${status.processing_status} | Done: ${status.request_counts.succeeded}`);
}
// Collect results
const results = new Map<string, string>();
for await (const result of await client.batches.results(batch.id)) {
if (result.result.type === 'succeeded') {
const text = result.result.message.content[0];
if (text.type === 'text') {
results.set(result.custom_id, text.text);
}
}
}
console.log(`✅ Batch complete: ${results.size}/${products.length} succeeded`);
return results;
}
Scaling AI Applications in Production
AI API calls are expensive — in both latency and cost. These patterns help you serve more users with lower spend and better response times.
Response Caching Strategy
import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();
// Deterministic cache key: model + hash of inputs
function cacheKey(
model: string,
system: string,
messages: ChatMessage[]
): string {
const payload = JSON.stringify({ model, system, messages });
// Simple hash (use crypto.subtle for production)
const hash = [...payload].reduce((acc, c) => (acc * 31 + c.charCodeAt(0)) | 0, 0);
return `ai:cache:${Math.abs(hash).toString(36)}`;
}
async function cachedCompletion(
client: Anthropic,
model: string,
system: string,
messages: ChatMessage[],
ttlSecs = 3600 // 1 hour default
): Promise<string> {
// Only cache deterministic (temperature=0) requests
const key = cacheKey(model, system, messages);
const cached = await redis.get<string>(key);
if (cached) {
console.log(`Cache HIT: ${key}`);
return cached;
}
const response = await client.messages.create({
model, max_tokens: 2048, system, messages,
temperature: 0, // deterministic for cacheability
});
const text = response.content[0].type === 'text'
? response.content[0].text
: '';
await redis.setex(key, ttlSecs, text);
return text;
}
// Prompt caching — reduce input token costs 90% on repeated system prompts
const clientWithPromptCaching = new Anthropic();
async function cachedSystemPrompt(
userMessage: string,
largeSystemPrompt: string // documents, instructions, etc.
): Promise<string> {
const response = await clientWithPromptCaching.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 1024,
system: [{
type: 'text',
text: largeSystemPrompt,
cache_control: { type: 'ephemeral' }, // ← Cache this prefix
}],
messages: [{ role: 'user', content: userMessage }],
});
return (response.content[0] as { text: string }).text;
}
Performance Summary
| Technique | Latency Impact | Cost Impact | Complexity |
|---|---|---|---|
| Response caching | ↓ 99% (cache hit) | ↓ 99% | Low |
| Prompt caching | ↓ 85% (input) | ↓ 90% on input | Low |
| Parallel tool calls | ↓ N× | — | Medium |
| Batch API | +hours (async) | ↓ 50% | Medium |
| Model routing | ↓ 60% (haiku) | ↓ 90% | Medium |
| Edge deployment | ↓ 30–50ms | — | High |
| Streaming | ↓ TTFB 80% | — | Low |
Model Routing by Complexity
type ModelTier = 'fast' | 'balanced' | 'powerful';
interface RoutingCriteria {
estimatedComplexity: 'simple' | 'medium' | 'complex';
requiresReasoning: boolean;
contextLength: number;
latencySensitive: boolean;
}
const MODEL_MAP: Record<ModelTier, string> = {
fast: 'claude-haiku-4-5-20251001', // Simple tasks, high volume
balanced: 'claude-sonnet-4-20250514', // Most production use cases
powerful: 'claude-opus-4-20250514', // Complex reasoning, rare
};
function routeModel(criteria: RoutingCriteria): string {
const { estimatedComplexity, requiresReasoning, contextLength, latencySensitive } = criteria;
// Long context + deep reasoning → powerful
if (contextLength > 100_000 || requiresReasoning && estimatedComplexity === 'complex') {
return MODEL_MAP.powerful;
}
// Real-time simple tasks → fast
if (latencySensitive && estimatedComplexity === 'simple') {
return MODEL_MAP.fast;
}
// Default to balanced
return MODEL_MAP.balanced;
}
// Auto-estimate complexity from input
function estimateComplexity(messages: ChatMessage[]): RoutingCriteria {
const lastMessage = messages.at(-1)?.content ?? '';
const totalChars = messages.reduce((s, m) => s + m.content.length, 0);
const COMPLEX_SIGNALS = [
'analyze', 'reason', 'compare', 'evaluate', 'strategy',
'architecture', 'design', 'review', 'audit',
];
const isComplex = COMPLEX_SIGNALS.some(s =>
lastMessage.toLowerCase().includes(s)
);
return {
estimatedComplexity: isComplex ? 'complex' : totalChars > 2000 ? 'medium' : 'simple',
requiresReasoning: isComplex,
contextLength: totalChars,
latencySensitive: messages.length < 3, // first few messages need to be fast
};
}
