Building a Custom AI Chatbot with Claude API and Next.js App Router (2026 Guide)

A hands-on guide to building a production-ready AI chatbot using Anthropic's Claude API and Next.js App Router. Covers streaming with the Vercel AI SDK, system prompts, conversation history, tool use, rate limiting, prompt caching, and Vercel deployment.

Building a Custom AI Chatbot with Claude API and Next.js App Router (2026 Guide)

By Elhassane Mehdioui, AI & automation consultant


If you have been following the Claude API Next.js chatbot space, you already know the ecosystem has matured dramatically in 2026. Anthropic's SDK is stable, the App Router is production-grade, and streaming works out of the box. This guide walks you through building a fully-featured, production-ready AI chatbot from scratch, covering every layer from Anthropic SDK setup to cost optimisation with prompt caching and final deployment to Vercel.

Whether you are an automation consultant looking to embed AI into a client's workflow or a developer shipping your first conversational product, this tutorial gives you the complete picture.


Prerequisites

  • Node.js 22 or later
  • A Next.js 15 project (App Router)
  • An Anthropic API key from console.anthropic.com
  • Basic familiarity with TypeScript and React Server Components

1. Anthropic SDK Setup

Start by installing the official SDK. Never use the OpenAI SDK as a shim — the Anthropic SDK is purpose-built and exposes features like adaptive thinking, prompt caching, and tool use that the compatibility layer cannot replicate.

npm install @anthropic-ai/sdk

Create a shared client module that the entire application will import:

// lib/anthropic.ts
import Anthropic from "@anthropic-ai/sdk";

if (!process.env.ANTHROPIC_API_KEY) {
  throw new Error("ANTHROPIC_API_KEY environment variable is required");
}

export const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

Add the key to your .env.local:

ANTHROPIC_API_KEY=sk-ant-api03-...

The SDK resolves credentials from ANTHROPIC_API_KEY automatically, so you only need to pass apiKey explicitly when injecting a runtime value from a secrets manager.


2. Streaming Chat with the App Router and streamText

The biggest UX win in any chatbot is streaming. Instead of waiting for the full response, tokens appear as they are generated. In the Next.js App Router we use a Route Handler that returns a ReadableStream.

The Route Handler

// app/api/chat/route.ts
import { anthropic } from "@/lib/anthropic";
import { NextRequest } from "next/server";
import Anthropic from "@anthropic-ai/sdk";

export const runtime = "edge";

export async function POST(req: NextRequest) {
  const { messages, systemPrompt } = await req.json();

  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      const anthropicStream = anthropic.messages.stream({
        model: "claude-opus-4-8",
        max_tokens: 64000,
        system: systemPrompt ?? "You are a helpful assistant.",
        messages: messages as Anthropic.MessageParam[],
      });

      for await (const event of anthropicStream) {
        if (
          event.type === "content_block_delta" &&
          event.delta.type === "text_delta"
        ) {
          controller.enqueue(encoder.encode(event.delta.text));
        }
      }

      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Transfer-Encoding": "chunked",
    },
  });
}

The React Client Component

// components/ChatWindow.tsx
"use client";

import { useState, useRef } from "react";
import type { MessageParam } from "@anthropic-ai/sdk/resources/messages";

export default function ChatWindow() {
  const [messages, setMessages] = useState<MessageParam[]>([]);
  const [input, setInput] = useState("");
  const [streaming, setStreaming] = useState(false);
  const [currentResponse, setCurrentResponse] = useState("");
  const abortRef = useRef<AbortController | null>(null);

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

    const userMessage: MessageParam = { role: "user", content: input };
    const nextMessages = [...messages, userMessage];

    setMessages(nextMessages);
    setInput("");
    setStreaming(true);
    setCurrentResponse("");

    abortRef.current = new AbortController();

    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ messages: nextMessages }),
      signal: abortRef.current.signal,
    });

    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    let fullText = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      fullText += decoder.decode(value, { stream: true });
      setCurrentResponse(fullText);
    }

    setMessages((prev) => [
      ...prev,
      { role: "assistant", content: fullText },
    ]);
    setCurrentResponse("");
    setStreaming(false);
  }

  return (
    <div className="flex flex-col gap-4 max-w-2xl mx-auto p-4">
      <div className="flex flex-col gap-2 min-h-96 border rounded p-4">
        {messages.map((m, i) => (
          <div key={i} className={m.role === "user" ? "text-right" : "text-left"}>
            <span className="inline-block bg-gray-100 rounded px-3 py-2 text-sm">
              {typeof m.content === "string" ? m.content : ""}
            </span>
          </div>
        ))}
        {streaming && currentResponse && (
          <div className="text-left">
            <span className="inline-block bg-blue-50 rounded px-3 py-2 text-sm">
              {currentResponse}
              <span className="animate-pulse">▍</span>
            </span>
          </div>
        )}
      </div>
      <div className="flex gap-2">
        <input
          className="flex-1 border rounded px-3 py-2 text-sm"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && sendMessage()}
          placeholder="Type a message..."
          disabled={streaming}
        />
        <button
          className="bg-blue-600 text-white rounded px-4 py-2 text-sm disabled:opacity-50"
          onClick={sendMessage}
          disabled={streaming || !input.trim()}
        >
          Send
        </button>
      </div>
    </div>
  );
}

3. System Prompts

A well-crafted system prompt is the single most impactful customisation you can make. It sets the model's persona, constraints, and tone before any user message is processed.

// lib/systemPrompts.ts
export const SUPPORT_BOT_PROMPT = `
You are a concise, professional customer support agent for Acme Corp.

Rules:
- Answer questions about Acme products only. Politely decline off-topic requests.
- If you do not know the answer, say so clearly and offer to escalate.
- Never reveal internal pricing margins or unreleased features.
- Keep responses under 150 words unless the user explicitly asks for detail.
- Always end with a concrete next step or offer of further help.
`.trim();

Pass the system prompt as its own field, not embedded in the messages array. The Claude API treats it as a higher-trust operator channel:

const response = await anthropic.messages.create({
  model: "claude-opus-4-8",
  max_tokens: 4096,
  system: SUPPORT_BOT_PROMPT,
  messages,
});

For multi-tenant applications where the system prompt changes per organisation, use the mid-conversation system message beta to inject updated instructions without invalidating the prompt cache:

// Append operator instruction mid-conversation without rebuilding the prefix
messages.push({
  // @ts-expect-error — role:"system" types pending SDK update
  role: "system",
  content: "User has upgraded to Pro. Unlock advanced analytics questions.",
});

4. Conversation History

The Anthropic API is stateless. Every request must include the full conversation history. Store messages in React state on the client, or in a database (Redis, Postgres, Upstash) for persistence across sessions.

// lib/history.ts
import type { MessageParam } from "@anthropic-ai/sdk/resources/messages";

const MAX_HISTORY_TOKENS_APPROX = 100_000; // rough character budget

/**
 * Trims conversation history when it grows too large.
 * A real implementation should use `client.messages.countTokens` for accuracy.
 */
export function trimHistory(messages: MessageParam[]): MessageParam[] {
  let totalChars = 0;
  const trimmed: MessageParam[] = [];

  for (let i = messages.length - 1; i >= 0; i--) {
    const msg = messages[i];
    const charCount = typeof msg.content === "string"
      ? msg.content.length
      : JSON.stringify(msg.content).length;

    totalChars += charCount;
    if (totalChars > MAX_HISTORY_TOKENS_APPROX) break;
    trimmed.unshift(msg);
  }

  // Always preserve the first user message for context
  if (trimmed[0]?.role !== "user" && messages[0]?.role === "user") {
    trimmed.unshift(messages[0]);
  }

  return trimmed;
}

For very long conversations on Claude Opus 4.8, enable server-side compaction to automatically summarise earlier context when you approach the 1M context window:

const response = await anthropic.beta.messages.create({
  betas: ["compact-2026-01-12"],
  model: "claude-opus-4-8",
  max_tokens: 16000,
  messages,
  context_management: { edits: [{ type: "compact_20260112" }] },
});

// Critical: append full content array, not just the text
messages.push({ role: "assistant", content: response.content });

5. Tool Use

Tool use (function calling) lets Claude interact with your application's data and APIs. The pattern is a loop: Claude calls a tool, you execute it, you return the result, Claude continues.

Here is a complete example with the tool runner beta, which eliminates the manual loop:

// app/api/chat-tools/route.ts
import { anthropic } from "@/lib/anthropic";
import Anthropic from "@anthropic-ai/sdk";
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";

const getOrderStatus = betaZodTool({
  name: "get_order_status",
  description:
    "Fetch the current status of an order. Call this when the user asks about a specific order.",
  inputSchema: z.object({
    order_id: z.string().describe("The order ID, e.g. ORD-12345"),
  }),
  run: async ({ order_id }) => {
    // Replace with your real database call
    const mockStatuses: Record<string, string> = {
      "ORD-12345": "Shipped — estimated delivery 2026-06-18",
      "ORD-99999": "Processing — payment confirmed",
    };
    return mockStatuses[order_id] ?? `Order ${order_id} not found.`;
  },
});

const searchProducts = betaZodTool({
  name: "search_products",
  description: "Search the product catalogue by keyword.",
  inputSchema: z.object({
    query: z.string().describe("Search keywords"),
    max_results: z.number().int().min(1).max(10).default(5),
  }),
  run: async ({ query, max_results }) => {
    // Replace with your real search
    return JSON.stringify([
      { id: "P001", name: `${query} Deluxe`, price: "$49.99" },
      { id: "P002", name: `${query} Pro`, price: "$99.99" },
    ].slice(0, max_results));
  },
});

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

  const finalMessage = await anthropic.beta.messages.toolRunner({
    model: "claude-opus-4-8",
    max_tokens: 16000,
    system: "You are a helpful Acme Corp assistant. Use tools to look up real data.",
    tools: [getOrderStatus, searchProducts],
    messages: messages as Anthropic.MessageParam[],
  });

  return NextResponse.json({ content: finalMessage.content });
}

For streaming tool use with a manual loop, replace toolRunner with client.messages.stream() and stream.finalMessage() — see the streaming section of the SDK docs.


6. Rate Limiting

Without rate limiting, a single user can exhaust your Anthropic token quota. Implement rate limiting at the edge using a sliding window in Redis (Upstash is the easiest option on Vercel):

// lib/rateLimit.ts
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

const WINDOW_SECONDS = 60;
const MAX_REQUESTS_PER_WINDOW = 20;

export async function checkRateLimit(userId: string): Promise<{
  allowed: boolean;
  remaining: number;
  resetAt: number;
}> {
  const key = `rl:chat:${userId}`;
  const now = Math.floor(Date.now() / 1000);
  const windowStart = now - WINDOW_SECONDS;

  // Remove old entries and add current timestamp
  const pipeline = redis.pipeline();
  pipeline.zremrangebyscore(key, 0, windowStart);
  pipeline.zadd(key, { score: now, member: `${now}-${Math.random()}` });
  pipeline.zcard(key);
  pipeline.expire(key, WINDOW_SECONDS);

  const results = await pipeline.exec<[number, number, number, number]>();
  const requestCount = results[2];

  return {
    allowed: requestCount <= MAX_REQUESTS_PER_WINDOW,
    remaining: Math.max(0, MAX_REQUESTS_PER_WINDOW - requestCount),
    resetAt: now + WINDOW_SECONDS,
  };
}

Apply it in the route handler:

// In app/api/chat/route.ts
import { checkRateLimit } from "@/lib/rateLimit";
import { headers } from "next/headers";

export async function POST(req: NextRequest) {
  const hdrs = await headers();
  const userId =
    hdrs.get("x-user-id") ??
    hdrs.get("x-forwarded-for") ??
    "anonymous";

  const { allowed, remaining, resetAt } = await checkRateLimit(userId);

  if (!allowed) {
    return new Response("Rate limit exceeded", {
      status: 429,
      headers: {
        "Retry-After": String(resetAt - Math.floor(Date.now() / 1000)),
        "X-RateLimit-Remaining": "0",
      },
    });
  }

  // ... rest of handler
}

7. Cost Optimisation with Prompt Caching

Prompt caching is the single most powerful cost lever available in the Claude API. When you mark a prefix with cache_control, repeated requests that share that prefix are served from the cache at approximately 10% of the normal input token cost.

The golden rule: any byte change anywhere in the prefix invalidates everything after it. Render order is toolssystemmessages. Keep dynamic content after the last cache breakpoint.

// lib/cachedChat.ts
import { anthropic } from "./anthropic";
import Anthropic from "@anthropic-ai/sdk";

const LARGE_KNOWLEDGE_BASE = `
  [Your product documentation, FAQs, or domain knowledge — 
   thousands of tokens that never change per-user]
`.trim();

export async function chatWithCache(
  messages: Anthropic.MessageParam[],
  userSystemAddition?: string,
) {
  const response = await anthropic.messages.create({
    model: "claude-opus-4-8",
    max_tokens: 4096,
    system: [
      {
        type: "text",
        // This large, stable prefix is cached for 5 minutes.
        // On repeated requests, ~90% of these tokens cost 0.1x.
        text: LARGE_KNOWLEDGE_BASE,
        cache_control: { type: "ephemeral" },
      },
      // Dynamic per-user context goes AFTER the breakpoint
      ...(userSystemAddition
        ? [{ type: "text" as const, text: userSystemAddition }]
        : []),
    ],
    messages,
  });

  // Log cache efficiency
  const { cache_creation_input_tokens, cache_read_input_tokens, input_tokens } =
    response.usage;

  console.log("Cache stats:", {
    written: cache_creation_input_tokens, // paid ~1.25x
    read: cache_read_input_tokens,        // paid ~0.1x
    uncached: input_tokens,               // paid 1x
  });

  return response;
}

For a multi-turn chatbot, also add a cache breakpoint on the last message of the conversation history. As the conversation grows, each subsequent request reuses the entire prior prefix from cache:

// Place cache_control on the last content block of the last turn
const lastMessage = messages[messages.length - 1];
if (lastMessage && typeof lastMessage.content === "string") {
  messages[messages.length - 1] = {
    ...lastMessage,
    content: [
      {
        type: "text",
        text: lastMessage.content,
        cache_control: { type: "ephemeral" },
      },
    ],
  };
}

Common silent cache invalidators to audit:

  • Date.now() or new Date() in the system prompt
  • Non-deterministic JSON.stringify() (use sort_keys equivalent)
  • Changing the tool list between requests
  • UUIDs or request IDs injected into the system prompt

8. Deploying to Vercel

Vercel is the natural deployment target for Next.js. The App Router's edge runtime works seamlessly with streaming responses.

Environment Variables

In your Vercel project settings, add:

ANTHROPIC_API_KEY=sk-ant-api03-...
UPSTASH_REDIS_REST_URL=https://...
UPSTASH_REDIS_REST_TOKEN=...

Never commit these to source control. Vercel injects them at build and runtime.

next.config.ts

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    // Required for edge runtime streaming
    serverActions: { allowedOrigins: ["localhost:3000"] },
  },
};

export default nextConfig;

Vercel Configuration File

// vercel.json
{
  "functions": {
    "app/api/chat/route.ts": {
      "maxDuration": 60
    }
  }
}

The maxDuration of 60 seconds accommodates long-running streamed responses. The free tier allows 10 seconds; you need a Pro plan for longer durations.

Deployment

# Install Vercel CLI
npm install -g vercel

# Deploy to preview
vercel

# Promote to production
vercel --prod

Vercel automatically detects the App Router and sets up the necessary edge function infrastructure. Your streaming route will be served from Vercel's edge network, giving you low latency globally.


9. Putting It All Together: Production Checklist

Before shipping your Claude API Next.js chatbot to production, verify these items:

AreaWhat to check
SecurityANTHROPIC_API_KEY is in environment variables, never in source code
Rate limitingPer-user sliding window in place; 429 responses include Retry-After
StreamingEdge runtime declared; maxDuration set in vercel.json
Cachingcache_control on stable system content; no timestamps in the prefix
HistoryConversation trimmed before hitting context window; compaction enabled for long sessions
Error handlingAnthropic.RateLimitError, Anthropic.APIError caught and surfaced gracefully
Tool safetyTool inputs validated; destructive tools require confirmation
Monitoringcache_read_input_tokens logged; costs tracked per session

10. Advanced Patterns Worth Knowing

Adaptive Thinking for Complex Queries

For queries that require multi-step reasoning, enable adaptive thinking. The model decides when and how much to think — no manual budget required:

const response = await anthropic.messages.create({
  model: "claude-opus-4-8",
  max_tokens: 64000,
  thinking: { type: "adaptive", display: "summarized" },
  output_config: { effort: "high" },
  messages,
});

Pre-warming the Cache

For applications with predictable traffic patterns, pre-warm the cache at startup to eliminate cold-start latency for the first real user:

// Called once at server startup
await anthropic.messages.create({
  model: "claude-opus-4-8",
  max_tokens: 0, // No output — just warms the cache
  system: [
    {
      type: "text",
      text: LARGE_KNOWLEDGE_BASE,
      cache_control: { type: "ephemeral" },
    },
  ],
  messages: [{ role: "user", content: "warmup" }],
});

Counting Tokens Before Sending

Avoid surprise costs on large documents by counting tokens before making the inference call:

const estimate = await anthropic.messages.countTokens({
  model: "claude-opus-4-8",
  messages,
  system: systemPrompt,
});

const estimatedCost = (estimate.input_tokens / 1_000_000) * 5.0; // $5/1M input
console.log(`Estimated request cost: $${estimatedCost.toFixed(4)}`);

Final Thoughts

Building a production-quality AI chatbot on top of the Claude API and Next.js App Router is more approachable than ever in 2026. The key decisions are:

  1. Always stream — users will not wait for multi-second responses
  2. Cache aggressively — prompt caching can cut costs by 80% or more on repeated context
  3. Rate limit at the edge — protect your quota and your users from runaway spending
  4. Keep conversation history explicit — statefulness is your responsibility, not the API's
  5. Use the tool runner — let the SDK handle the agentic loop so your code stays clean

The patterns in this guide scale from a solo side project to a multi-tenant SaaS. Start simple, instrument your costs from day one, and tune as you grow.


Elhassane Mehdioui is an AI & automation consultant helping businesses integrate Claude API workflows into their products. Follow for more tutorials on building with Anthropic's Claude API and Next.js.

Need a React or Next.js interface?

Modern, responsive front-ends built to perform.

I build fast, accessible UIs with React, Next.js, TypeScript and Tailwind CSS.

HassanOSSYS-00
SECURE CHANNEL · ACTIVE
INIT://