Building an AI application is easy. Building one that handles streaming responses, manages costs, scales cleanly, and doesn't leak API keys takes more thought. This guide covers the architecture decisions and implementation patterns I have learned from building TokenPulse and several other AI-powered tools with Next.js.
The stack
Next.js 15 (App Router)
TypeScript
Tailwind CSS
Vercel (hosting + edge functions)
Resend (transactional email)
This stack handles most AI application requirements without introducing unnecessary complexity. Next.js App Router gives you server components, route handlers, and streaming out of the box.
Project structure
app/
layout.tsx — root layout, metadata, providers
page.tsx — landing page
api/
chat/route.ts — AI streaming endpoint
usage/route.ts — usage tracking
dashboard/
page.tsx — server component
client.tsx — client component with state
lib/
ai.ts — AI client configuration
auth.ts — session handling
db.ts — database client
components/
chat/
ChatWindow.tsx — client component
Message.tsx — pure component
StreamingText.tsx — streaming display
The key architectural decision: keep AI calls in server-side route handlers, never in client components. API keys stay server-side. The client receives streamed responses but never touches credentials.
Setting up the AI client
// lib/ai.ts
import Anthropic from '@anthropic-ai/sdk'
// Single instance — reused across requests
export const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
// Type-safe model constants
export const MODELS = {
fast: 'claude-haiku-4-5',
default: 'claude-sonnet-4-5',
smart: 'claude-opus-4-5',
} as const
export type ModelKey = keyof typeof MODELS
Never instantiate the client per-request. The SDK handles connection pooling internally and a single instance is more efficient.
Streaming responses with App Router
Streaming is the single most important UX improvement for AI applications. Without streaming, users stare at a blank screen for 3-10 seconds. With streaming, they see content appear token by token.
// app/api/chat/route.ts
import { anthropic, MODELS } from '@/lib/ai'
export const runtime = 'nodejs' // required for streaming
export async function POST(req: Request) {
const { messages, model = 'default' } = await req.json()
// Validate input
if (!messages?.length) {
return Response.json({ error: 'Messages required' }, { status: 400 })
}
// Create streaming response
const stream = anthropic.messages.stream({
model: MODELS[model as keyof typeof MODELS] ?? MODELS.default,
max_tokens: 4096,
messages,
})
// Convert to ReadableStream for the Response
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
if (
chunk.type === 'content_block_delta' &&
chunk.delta.type === 'text_delta'
) {
controller.enqueue(
new TextEncoder().encode(chunk.delta.text)
)
}
}
controller.close()
},
})
return new Response(readable, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
'X-Content-Type-Options': 'nosniff',
},
})
}
Client-side streaming consumption
// components/chat/ChatWindow.tsx
'use client'
import { useState, useRef } from 'react'
export default function ChatWindow() {
const [messages, setMessages] = useState<Message[]>([])
const [streaming, setStreaming] = useState(false)
const abortRef = useRef<AbortController | null>(null)
async function sendMessage(content: string) {
const userMessage = { role: 'user' as const, content }
const newMessages = [...messages, userMessage]
setMessages(newMessages)
setStreaming(true)
// Add empty assistant message to fill in
setMessages(prev => [...prev, { 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: newMessages }),
signal: abortRef.current.signal,
})
if (!res.ok) throw new Error('Request failed')
const reader = res.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
// Append to last message
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = {
role: 'assistant',
content: updated[updated.length - 1].content + text,
}
return updated
})
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('Stream error:', err)
}
} finally {
setStreaming(false)
abortRef.current = null
}
}
function stopStreaming() {
abortRef.current?.abort()
}
return (
<div>
{messages.map((msg, i) => (
<div key={i} className={msg.role === 'user' ? 'user' : 'assistant'}>
{msg.content}
</div>
))}
{/* Input and controls */}
</div>
)
}
Rate limiting API routes
Without rate limiting, a single user can exhaust your AI credits in minutes. Implement token bucket rate limiting at the route level:
// lib/ratelimit.ts
const requests = new Map<string, { count: number; reset: number }>()
export function rateLimit(
identifier: string,
limit = 20,
windowMs = 60_000
): { success: boolean; remaining: number } {
const now = Date.now()
const record = requests.get(identifier)
if (!record || now > record.reset) {
requests.set(identifier, { count: 1, reset: now + windowMs })
return { success: true, remaining: limit - 1 }
}
if (record.count >= limit) {
return { success: false, remaining: 0 }
}
record.count++
return { success: true, remaining: limit - record.count }
}
// In your route handler
import { rateLimit } from '@/lib/ratelimit'
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? 'unknown'
const { success, remaining } = rateLimit(ip)
if (!success) {
return Response.json(
{ error: 'Rate limit exceeded' },
{ status: 429, headers: { 'Retry-After': '60' } }
)
}
// ...
}
For production, use Upstash Redis with their @upstash/ratelimit package — the in-memory approach above does not work across multiple serverless function instances.
Cost tracking per request
Track costs at the API layer so you have data on actual spend:
// After streaming completes, get usage data
const finalMessage = await stream.finalMessage()
const usage = finalMessage.usage
const cost = calculateCost(
usage.input_tokens,
usage.output_tokens,
model
)
// Log to your database or analytics
await logUsage({
userId: session?.userId,
model,
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cost,
timestamp: new Date(),
})
function calculateCost(
inputTokens: number,
outputTokens: number,
model: string
): number {
const pricing: Record<string, { input: number; output: number }> = {
'claude-haiku-4-5': { input: 0.80, output: 4.00 },
'claude-sonnet-4-5': { input: 3.00, output: 15.00 },
'claude-opus-4-5': { input: 15.00, output: 75.00 },
}
const p = pricing[model]
if (!p) return 0
return (inputTokens / 1_000_000) * p.input +
(outputTokens / 1_000_000) * p.output
}
Environment variables — never expose them
Three rules:
1. Never use NEXT_PUBLIC_ prefix for AI API keys. NEXT_PUBLIC_ variables are bundled into the client JavaScript and visible to anyone who opens DevTools.
2. Always validate on startup. Add a check in your root layout or a startup file:
// lib/env.ts
const required = [
'ANTHROPIC_API_KEY',
'DATABASE_URL',
]
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`)
}
}
3. Use .env.example committed to git, .env.local never committed. Your .gitignore should always include .env.local.
Deployment on Vercel
One vercel.json setting that matters for streaming:
{
"functions": {
"app/api/chat/route.ts": {
"maxDuration": 60
}
}
}
Default Vercel function timeout is 10 seconds on hobby plans — not enough for long AI responses. Set maxDuration to 60 on pro plans, or implement response chunking to stay within the limit.
Also add export const runtime = 'nodejs' to any route that uses streaming. The Edge runtime does not support all Node.js APIs that the AI SDKs depend on.
The architecture decision that matters most
Keep AI logic in server-side route handlers. The temptation is to call AI APIs directly from client components using the AI SDK's client-side helpers. Resist it.
Server-side AI calls give you:
- API key security
- Cost logging at the server level
- Rate limiting before the request reaches the AI provider
- Error handling in one place The extra round-trip through your API layer is worth every one of these.
The full source code for TokenPulse's website is available at github.com/anu-ship-it/TokenPulse. If you are building AI tools and want to track token usage in real time, TokenPulse is free and works with no API key.
Top comments (0)