DEV Community

Cover image for Building a Full-Stack AI Application with Next.js — Architecture and Implementation
Anoop Kumar
Anoop Kumar

Posted on

Building a Full-Stack AI Application with Next.js — Architecture and Implementation

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode
// 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' } }
    )
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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(),
})
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode

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}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)