← Back to Blog
AI Development9 min read

Building AI-Powered Web Apps with Next.js and the Claude API in 2026

A practical guide to integrating Anthropic's Claude API into a Next.js 14 app — from streaming responses to building a real AI feature your users will love.

By Asif Hossain·
Building AI-Powered Web Apps with Next.js and the Claude API in 2026

AI is no longer a nice-to-have feature — it's becoming the baseline expectation for modern web applications. If you're a Next.js developer in 2026, knowing how to wire up a language model API is as essential as knowing how to fetch from a REST endpoint.

In this post I'll show you how to integrate Anthropic's Claude API into a Next.js 14 (App Router) project, using the official @anthropic-ai/sdk package — including streaming responses, proper error handling, and a clean UI pattern you can drop into a real product.

Why Claude?

Before we get into the code, a quick word on why I personally reach for Claude first.

Claude produces long, coherent, accurate responses. It's particularly strong for tasks like summarisation, code generation, document Q&A, and writing — which maps to most of the AI features I'm asked to build for clients. The API is clean, the pricing is competitive, and Anthropic has a strong track record on safety and reliability.

That said, the pattern below works almost identically with OpenAI's API — the concepts transfer.

Project Setup

Start with a fresh Next.js 14 app (or add this to an existing one):

npx create-next-app@latest my-ai-app --typescript --tailwind --app
cd my-ai-app
npm install @anthropic-ai/sdk

Add your API key to .env.local:

ANTHROPIC_API_KEY=sk-ant-...

Never expose this key to the browser. We'll call the API from a Route Handler (server-side) only.

Creating the API Route

Create app/api/chat/route.ts:

import Anthropic from "@anthropic-ai/sdk"
import { NextRequest } from "next/server"

const client = new Anthropic()

export async function POST(req: NextRequest) {
  const { message } = await req.json()

  if (!message || typeof message !== "string") {
    return new Response("Missing message", { status: 400 })
  }

  // Stream the response back to the browser
  const stream = await client.messages.stream({
    model: "claude-opus-4-6",
    max_tokens: 1024,
    messages: [{ role: "user", content: message }],
  })

  // Return a ReadableStream so the UI can show text as it arrives
  const readableStream = 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(readableStream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  })
}

This route streams tokens back to the browser as Claude generates them — exactly like you see in Claude.ai itself.

Building the Chat UI

Create app/chat/page.tsx:

"use client"

import { useState, useRef, useEffect } from "react"

interface Message {
  role: "user" | "assistant"
  content: string
}

export default function ChatPage() {
  const [messages, setMessages] = useState<Message[]>([])
  const [input, setInput] = useState("")
  const [loading, setLoading] = useState(false)
  const bottomRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" })
  }, [messages])

  async function sendMessage() {
    if (!input.trim() || loading) return

    const userMessage = input.trim()
    setInput("")
    setMessages((prev) => [...prev, { role: "user", content: userMessage }])
    setLoading(true)

    // Add empty assistant message to stream into
    setMessages((prev) => [...prev, { role: "assistant", content: "" }])

    try {
      const res = await fetch("/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: userMessage }),
      })

      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)

        // Append chunk to the last message
        setMessages((prev) => {
          const updated = [...prev]
          updated[updated.length - 1] = {
            ...updated[updated.length - 1],
            content: updated[updated.length - 1].content + chunk,
          }
          return updated
        })
      }
    } catch (err) {
      console.error(err)
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
      <div className="flex-1 overflow-y-auto space-y-4 py-4">
        {messages.map((msg, i) => (
          <div
            key={i}
            className={`p-4 rounded-xl text-sm whitespace-pre-wrap ${
              msg.role === "user"
                ? "bg-blue-500/10 text-blue-100 ml-8"
                : "bg-card text-text-primary mr-8"
            }`}
          >
            {msg.content}
          </div>
        ))}
        <div ref={bottomRef} />
      </div>

      <form
        onSubmit={(e) => { e.preventDefault(); sendMessage() }}
        className="flex gap-2 border-t border-border pt-4"
      >
        <input
          className="flex-1 bg-surface border border-border rounded-xl px-4 py-2 text-sm text-text-primary focus:outline-none focus:border-accent-yellow"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Ask Claude anything..."
          disabled={loading}
        />
        <button
          type="submit"
          disabled={loading || !input.trim()}
          className="px-4 py-2 bg-accent-yellow text-background font-semibold rounded-xl text-sm disabled:opacity-50"
        >
          {loading ? "..." : "Send"}
        </button>
      </form>
    </div>
  )
}

Giving Claude a System Prompt

For real products you almost always want a system prompt — it defines Claude's persona and scope. Update the route:

const stream = await client.messages.stream({
  model: "claude-opus-4-6",
  max_tokens: 1024,
  system: `You are a helpful assistant for Acme Corp. 
Answer questions about our products only. 
Be concise, friendly, and professional.`,
  messages: [{ role: "user", content: message }],
})

This is the key difference between a demo and a real product. A well-crafted system prompt dramatically improves output quality.

Maintaining Conversation History

The example above sends only the latest message. For real chat you need to send the full history:

// In your API route, accept a messages array instead
const { messages } = await req.json()

const stream = await client.messages.stream({
  model: "claude-opus-4-6",
  max_tokens: 1024,
  system: "You are a helpful assistant.",
  messages, // Full conversation history
})

On the client, pass messages from state (excluding any empty streaming messages) with each request.

Production Checklist

Before going live:

  • Rate limiting — add a simple IP-based rate limiter (e.g. upstash/ratelimit) on the route
  • Input sanitisation — cap message length server-side to prevent abuse
  • Error boundaries — wrap the chat component so a network error doesn't crash the whole page
  • Model selectionclaude-haiku-4-5-20251001 is significantly cheaper for high-volume consumer apps; use claude-opus-4-6 for quality-critical tasks
  • Cost monitoring — set a usage alert in the Anthropic console on day one

What to Build Next

Once you have a basic integration working, here are the features I add to most client projects:

  1. Document Q&A — accept file uploads, extract text, pass it as context in the system prompt
  2. Structured output — ask Claude to return JSON and parse it for form-filling, categorisation, or data extraction features
  3. Function calling — use tools to let Claude trigger actions in your app (search a database, send an email, etc.)
  4. Semantic caching — cache similar questions to cut API costs by up to 80%

Final Thoughts

The barrier to shipping AI features in 2026 is lower than ever. With Next.js App Router and the Anthropic SDK, you can go from zero to a streaming AI feature in under an hour. The hard part is the product thinking — deciding what to build, where it genuinely adds value, and how to make it feel natural.

If you're looking to add AI features to your web product or want a developer who can handle the full stack — front-end, API integration, and deployment — get in touch.

Asif Hossain is a full-stack developer based in Wollongong, NSW, with hands-on experience building AI-powered web applications using the Claude API, OpenAI, and the Vercel AI SDK.

Need a Full-Stack Developer?

Based in Wollongong, NSW. Available for projects across Australia and globally.