Build an AI Portfolio with Claude

Build a portfolio site with a Claude-powered chatbot that answers questions about you.

Introduction

30 Second Summary

Most portfolios are static pages that look the same as everyone else's. Imagine if yours could actually talk to visitors and answer their questions about you.

In this project, you will build a personal portfolio website with a standout feature: an AI chatbot powered by Claude that answers visitor questions about your skills, experience, and projects in real-time.

What You'll Build

A Next.js full-stack portfolio website with an embedded streaming chatbot that calls the Claude API, deployed live on Vercel.

By the end of this project, you'll have:

  • A responsive portfolio landing page displaying your name, bio, and skills.
  • A streaming AI chatbot powered by Claude that answers visitor questions about your experience in real-time.
  • A Next.js API route that handles Claude API calls with streaming responses.
  • A live deployment on Vercel accessible via a public URL, plus a secret mission to add conversation memory so the chatbot remembers context across multiple questions.

Are there any prerequisites?

Basic JavaScript, HTML, and CSS knowledge is all you need. No prior React or Next.js experience required.

Set Up Your Development Environment

Before you can build anything, you need the right tools on your machine. This project uses Node.js to run a Next.js application, and Cursor as your AI-powered code editor.

You also need accounts for GitHub (to store and deploy your code) and Anthropic (to access the Claude API for your chatbot). This step gets everything installed and configured so you can jump straight into building.

In this step, get ready to:
  • Install Node.js and Cursor on your machine.
  • Create your GitHub and Anthropic accounts.
  • Get your Anthropic API key for the Claude chatbot.
Install Node.js

Node.js is the JavaScript runtime that powers your Next.js application. You need version 18 or higher installed.

  • Open your terminal.

How do I open a terminal?

Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar. Type Terminal (macOS) or PowerShell (Windows) and press Enter.

  • Check if Node.js is already installed by running this command:
node --version

I see a version number

Node.js is installed. Make sure the version starts with v18 or higher (for example, v24.15.0). If your version is v18 or above, you're good to go.

Command not found

Node.js isn't installed yet. Let's fix that.

  • Go to nodejs.org/en/download.
  • Download the LTS version for your operating system.
  • Run the installer and accept the default settings at each prompt.
  • Close your terminal and open a new one.
  • Run node --version again to confirm you see a version number starting with v18 or higher.

Still seeing command not found?

  • Make sure you opened a new terminal window after installing. The old terminal doesn't know about the new install.
  • On Windows, try restarting your computer after the installer finishes.
  • help me troubleshoot my Node.js installation
Install Cursor

Cursor is a code editor built on VS Code with AI assistance built in. It helps you write code faster with intelligent suggestions.

macOS

  • Open the downloaded .dmg file.
  • Drag the Cursor icon into your Applications folder.

Windows

  • Run the downloaded .exe installer.
  • Follow the setup prompts and accept the default settings.
  • Verify Cursor is installed by opening it. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.

On first launch, Cursor may ask if you want to import your VS Code settings. Choose whatever you prefer. You can also skip this.

What about the Cursor free tier?

Cursor's free Hobby plan gives you 2,000 code completions per month. That's more than enough for this project. You don't need to pay for anything.

Cursor won't open?

  • On macOS, if you see a security warning, go to System Settings, then Privacy & Security, and click Open Anyway.
  • On Windows, try right-clicking the installer and selecting Run as Administrator.
  • help me troubleshoot my Cursor installation
Create your GitHub and Anthropic accounts

You need two accounts for this project. GitHub stores your code and connects to Vercel for deployment. Anthropic gives you access to the Claude API that powers your chatbot.

If you already have a GitHub account, skip ahead to the Anthropic steps below.

  • Go to github.com and click Sign up.
  • Follow the prompts to create your free account with your email address.
  • Verify your email when GitHub sends you a confirmation.

Now create your Anthropic account and get an API key. This is the key your portfolio chatbot uses to talk to Claude.

  • Go to console.anthropic.com and click Sign up.
  • Create your account using your email address or Google account.
  • Complete the email or phone verification if prompted.
  • Once you're in the console, navigate to the API Keys section in the sidebar.
  • Click Create Key.
  • Give your key a name like portfolio-chatbot and click Create Key.
  • Copy your API key immediately and paste it somewhere safe (like a notes app). The key is only shown once.

Keep your API key private

Your API key is like a password. Never share it publicly, commit it to GitHub, or paste it into a chat. You'll store it securely in an environment file in the next step.

What about the cost?

New Anthropic accounts get roughly $5 in free API credits. This project uses Claude Haiku 4.5, which costs $1 per million input tokens and $5 per million output tokens. Your free credits will last through this project and well beyond it.

Can't find the API Keys page?

  • Make sure you're on console.anthropic.com, not claude.ai. The consumer chat app and the developer console are separate sites.
  • Look for API Keys in the left sidebar. If you don't see a sidebar, try making your browser window wider.
  • help me find my Anthropic API key

Your tools are installed and your accounts are ready. Next up, you'll open Cursor and scaffold your Next.js project from scratch.

Scaffold Your Next.js Project in Cursor

Your development environment is ready with Node.js, Cursor, and your Anthropic API key all set up.

Now you need an actual project to build in. You'll use Next.js, a React framework with built-in routing and API support, to scaffold your portfolio.

In this step, get ready to:
  • Scaffold a new Next.js project with TypeScript and Tailwind CSS.
  • Install the Anthropic SDK for connecting to Claude.
  • Create your environment file and verify the dev server runs.
Create a new Next.js project

What is Next.js?

Next.js is a React framework that gives you server-side rendering, file-based routing, and built-in API routes.

Instead of wiring together a separate frontend and backend, Next.js handles both in one project. You'll use its API routes later to call the Claude API from the server.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • Open Cursor's integrated terminal by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your Next.js project by running this command:
npx create-next-app@latest my-portfolio

What does this command do?

npx runs the create-next-app tool without installing it permanently. The @latest flag grabs the newest version, and my-portfolio is the name of the folder it creates on your Desktop.

This command launches an interactive wizard that asks about your project configuration. Answer each prompt one at a time as it appears.

  • Enter Yes for the TypeScript prompt.
  • Enter Yes for the ESLint prompt.
  • Enter Yes for the Tailwind CSS prompt.
  • Enter No for the src/ directory prompt.
  • Enter Yes for the App Router prompt.
  • Enter Yes for the Turbopack prompt.
  • Enter No for the import alias prompt to keep the default.

After answering all prompts, the tool installs your project dependencies automatically. You should see a success message that says Success! Created my-portfolio.

Seeing a 'command not found' error?

  • Make sure Node.js is installed by running node --version. You need version 18 or higher.
  • If you just installed Node.js, close and reopen Cursor's terminal so it picks up the new installation.
  • Help me troubleshoot this create-next-app error

Now open the new project folder in Cursor so you can see all the files in the sidebar.

  • In Cursor, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the my-portfolio folder, and click Open.

You should see a file tree in the left sidebar with folders like app/, public/, and node_modules/.

Install the Anthropic SDK

Your project needs a way to talk to Claude. Anthropic publishes an official SDK (software development kit) that handles authentication, request formatting, and streaming for you.

  • Open a new terminal in Cursor by pressing Ctrl+` (backtick).

The terminal should open inside the my-portfolio folder automatically because you opened that folder in the previous step.

  • Install the Anthropic SDK by running this command:
npm install @anthropic-ai/sdk@0.90.0

What does this command do?

npm install downloads the @anthropic-ai/sdk package and adds it to your project's dependencies. The @0.90.0 pins the version so everyone following this project uses the exact same SDK.

  • Confirm the install succeeded by checking for an added 1 package message in your terminal output.

Install failing or timing out?

  • Make sure your terminal is inside the my-portfolio folder. Run pwd to check your current location.
  • Check your internet connection. The SDK needs to be downloaded from the npm registry.
  • Help me fix this npm install error
Set up your environment variables and start the dev server

Your Anthropic API key is a secret that should never appear in your code. Instead, you store it in an environment file that Next.js reads automatically at startup.

  • In Cursor's Explorer sidebar (left panel), right-click on an empty area below the file list.
  • Click New File.
  • Type .env.local as the file name and press Enter.
  • Add the following line to your new .env.local file, replacing your-api-key-here with your actual Anthropic API key:
ANTHROPIC_API_KEY=your-api-key-here

What does this file do?

Next.js automatically reads .env.local at startup and makes the values available through process.env. The Anthropic SDK looks for ANTHROPIC_API_KEY by default, so you won't need to pass it manually in your code.

Keep your API key safe

Never share your API key or commit it to Git. The .env.local file is already included in the default .gitignore that create-next-app generated, so it won't be pushed to GitHub.

  • Save .env.local by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Time to make sure everything is wired up correctly by starting the development server.

  • Start the Next.js dev server by running this command in your terminal:
npm run dev
  • You should see output that includes Ready and a local URL like http://localhost:3000.
  • Open your browser and go to http://localhost:3000. You should see the default Next.js welcome page.

Don't see the welcome page?

  • Check your terminal for error messages. A common one is Port 3000 is already in use. If you see this, close any other dev servers running on that port and try again.
  • Make sure you ran npm run dev from inside the my-portfolio folder, not from Desktop.
  • Help me troubleshoot why my Next.js dev server isn't starting

Your project is scaffolded, the Anthropic SDK is installed, and the dev server is running. Next up, you'll replace that default welcome page with your own portfolio layout.

Build Your Portfolio Landing Page

Your Next.js project is scaffolded, the Anthropic SDK is installed, and your dev server is running. Now it is time to build the actual portfolio that visitors will see.

In this step, you will replace the default Next.js landing page with a portfolio layout, set up clean global styles, and create a chat widget component. The chat widget will not connect to Claude yet. That comes in the next step.

In this step, get ready to:
  • Build a portfolio landing page with your name, bio, skills, and projects.
  • Replace the default styles with a clean Tailwind CSS setup.
  • Create a floating chat widget component with a message list, input field, and send button.
Replace the default page with your portfolio layout

The scaffolded Next.js project comes with a default welcome page full of links and logos. You will replace the entire contents of app/page.tsx with your own portfolio layout.

  • In Cursor, open app/page.tsx from the Explorer sidebar on the left.
  • Select all the existing code with Cmd+A (macOS) or Ctrl+A (Windows), then delete it.
  • Replace the file with this portfolio layout:
import ChatWidget from './components/ChatWidget'

export default function Home() {
  return (
    <main className="min-h-screen bg-gray-50">
      <section className="max-w-3xl mx-auto px-6 py-20">
        {/* Hero section with name and bio */}
        <h1 className="text-4xl font-bold text-gray-900 mb-4">
          Hi, I'm Alex Chen
        </h1>
        <p className="text-xl text-gray-600 mb-8">
          Full-stack developer passionate about AI, cloud computing, and building
          products that solve real problems.
        </p>

        {/* Skills displayed as colored badges */}
        <div className="mb-12">
          <h2 className="text-2xl font-semibold text-gray-800 mb-4">Skills</h2>
          <div className="flex flex-wrap gap-2">
            {[
              'TypeScript',
              'React',
              'Next.js',
              'Node.js',
              'Python',
              'AWS',
              'Docker',
              'PostgreSQL',
              'Claude API',
              'Git',
            ].map((skill) => (
              <span
                key={skill}
                className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium"
              >
                {skill}
              </span>
            ))}
          </div>
        </div>

        {/* Project cards */}
        <div className="mb-12">
          <h2 className="text-2xl font-semibold text-gray-800 mb-4">Projects</h2>
          <div className="space-y-4">
            <div className="p-4 bg-white rounded-lg shadow-sm border">
              <h3 className="font-semibold text-gray-900">AI Portfolio Chatbot</h3>
              <p className="text-gray-600 text-sm mt-1">
                This website! Built with Next.js and Claude API to showcase my work
                through an interactive AI assistant.
              </p>
            </div>
            <div className="p-4 bg-white rounded-lg shadow-sm border">
              <h3 className="font-semibold text-gray-900">Cloud Task Manager</h3>
              <p className="text-gray-600 text-sm mt-1">
                A serverless task management app deployed on AWS with DynamoDB and
                Lambda.
              </p>
            </div>
          </div>
        </div>

        {/* Call-to-action banner pointing to the chatbot */}
        <div className="p-6 bg-blue-50 rounded-lg border border-blue-200">
          <p className="text-blue-800 font-medium">
            Want to know more? Click the chat button below to ask my AI assistant
            anything about my experience!
          </p>
        </div>
      </section>

      {/* Floating chat widget */}
      <ChatWidget />
    </main>
  )
}

What does this code do?

  • The hero section at the top displays your name as a large heading and a short bio paragraph.
  • The skills section uses .map() to loop over an array of skill names and render each one as a styled badge. This is JSX in action. You write HTML-like syntax that can embed JavaScript expressions.
  • Two project cards describe your work. You will customize these with your own projects later.
  • The blue call-to-action banner at the bottom invites visitors to try the AI chatbot.
  • The <ChatWidget /> component will render the floating chat button. You have not created this file yet, so expect an error for now.

What are all those className strings?

Those are Tailwind CSS utility classes. Instead of writing CSS in a separate file, Tailwind lets you style elements directly in your markup. For example, text-4xl sets a large font size and mb-4 adds margin below an element.

  • Save app/page.tsx with Cmd+S (macOS) or Ctrl+S (Windows).
  • Check your browser at http://localhost:3000. You will see a build error because the ChatWidget component does not exist yet. This is expected.

Seeing a Module not found error? That's expected

Your browser will show something like Module not found: Can't resolve './components/ChatWidget'. This happens because page.tsx imports a component you have not created yet. You will fix this in a few minutes when you create the ChatWidget.tsx file.

Replace the default styles with Tailwind

The default globals.css file that Next.js generated has a lot of custom CSS you do not need. You will replace it with a minimal setup that loads Tailwind and sets a clean font.

  • Open app/globals.css in the Explorer sidebar.
  • Select all with Cmd+A (macOS) or Ctrl+A (Windows), then replace the entire file with this code:
/* Load Tailwind's base resets, component classes, and utility classes */
@tailwind base;
@tailwind components;
@tailwind utilities;

/* Set a clean system font stack for the entire page */
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

What does this code do?

  • The three @tailwind directives inject Tailwind's reset styles, component styles, and all the utility classes (like text-4xl and bg-blue-600) that your portfolio uses.
  • The body rule sets a system font stack so the text looks clean on every operating system.
  • Save app/globals.css.

Your browser will still show the build error from the missing ChatWidget import. The next substep creates that component and resolves the error.

Create the chat widget component

The chat widget is a floating button in the bottom-right corner that opens a chat panel when clicked. It handles sending messages, reading streaming responses, and displaying them in real time.

This is a React client component. The 'use client' directive at the top tells Next.js that this component runs in the browser, which is needed for interactive features like state and event handlers.

  • Create a new folder called components inside the app folder. Right-click the app folder in the Explorer sidebar and select New Folder.
  • Right-click the new components folder and select New File.
  • Name the file ChatWidget.tsx.

This component has two main parts: the logic that sends messages and reads streaming responses, and the JSX that renders the chat panel UI. You will add them one at a time.

  • In ChatWidget.tsx, paste the component setup and message-sending logic:
'use client'

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

// Define the shape of a chat message
interface Message {
  role: 'user' | 'assistant'
  content: string
}

export default function ChatWidget() {
  // Track whether the chat panel is open or closed
  const [isOpen, setIsOpen] = useState(false)
  // Store all messages in the conversation
  const [messages, setMessages] = useState<Message[]>([])
  // Track the current input field value
  const [input, setInput] = useState('')
  // Prevent sending while waiting for a response
  const [isLoading, setIsLoading] = useState(false)
  // Reference for auto-scrolling to the latest message
  const messagesEndRef = useRef<HTMLDivElement>(null)

  // Auto-scroll to the bottom whenever messages update
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages])

  // Send a message to the API and process the streaming response
  const sendMessage = async () => {
    if (!input.trim() || isLoading) return

    // Add the user's message to the chat
    const userMessage: Message = { role: 'user', content: input.trim() }
    setMessages((prev) => [...prev, userMessage])
    setInput('')
    setIsLoading(true)

    // Add an empty assistant message that fills in as the stream arrives
    const assistantMessage: Message = { role: 'assistant', content: '' }
    setMessages((prev) => [...prev, assistantMessage])

    try {
      // POST the user's message to the API route
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: input.trim() }),
      })

      if (!response.ok) throw new Error('Failed to get response')

      // Set up a reader to process the streaming response
      const reader = response.body?.getReader()
      const decoder = new TextDecoder()

      if (!reader) throw new Error('No response body')

      // Read chunks from the stream until it's done
      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        // Decode each chunk and parse the SSE-formatted data
        const chunk = decoder.decode(value, { stream: true })
        const lines = chunk.split('\n\n')

        for (const line of lines) {
          if (line.startsWith('data: ') && line !== 'data: [DONE]') {
            try {
              const data = JSON.parse(line.slice(6))
              if (data.text) {
                // Append new text to the last assistant message
                setMessages((prev) => {
                  const updated = [...prev]
                  const last = updated[updated.length - 1]
                  if (last.role === 'assistant') {
                    last.content += data.text
                  }
                  return [...updated]
                })
              }
            } catch {
              // Skip unparseable lines
            }
          }
        }
      }
    } catch (error) {
      // Show an error message if the request fails
      setMessages((prev) => {
        const updated = [...prev]
        const last = updated[updated.length - 1]
        if (last.role === 'assistant') {
          last.content = 'Sorry, something went wrong. Please try again.'
        }
        return [...updated]
      })
    } finally {
      setIsLoading(false)
    }
  }

What does this code do?

  • The Message interface defines what a chat message looks like: it has a role (either 'user' or 'assistant') and a content string. This is TypeScript giving you type safety.
  • Five pieces of React state track the widget's behavior: whether the panel is open, the message history, the current input text, whether a request is in progress, and a ref for auto-scrolling.
  • The sendMessage function adds the user's message to state, POSTs it to /api/chat, and reads the streaming response chunk by chunk. Each chunk of text gets appended to an empty assistant message that was added in advance.
  • The catch block handles errors gracefully by showing a friendly message instead of crashing.

You will not see any change yet because the component has no visual output. The next chunk adds the JSX that renders the chat button and panel.

  • Directly below the closing } of the sendMessage function, add the component's return statement with the chat UI:
  return (
    <>
      {/* Floating chat toggle button in the bottom-right corner */}
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="fixed bottom-6 right-6 w-14 h-14 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 flex items-center justify-center text-2xl z-50"
      >
        {isOpen ? '\u2715' : '\uD83D\uDCAC'}
      </button>

      {/* Chat panel that appears when the button is clicked */}
      {isOpen && (
        <div className="fixed bottom-24 right-6 w-96 h-[500px] bg-white rounded-xl shadow-2xl border flex flex-col z-50">
          {/* Header bar */}
          <div className="p-4 border-b bg-blue-600 text-white rounded-t-xl">
            <h3 className="font-semibold">Ask My AI</h3>
            <p className="text-sm text-blue-100">Ask anything about my experience</p>
          </div>

          {/* Scrollable message list */}
          <div className="flex-1 overflow-y-auto p-4 space-y-3">
            {messages.length === 0 && (
              <p className="text-gray-400 text-sm text-center mt-8">
                Ask me anything about Alex's skills, projects, or experience!
              </p>
            )}
            {messages.map((msg, i) => (
              <div
                key={i}
                className={`p-3 rounded-lg text-sm ${
                  msg.role === 'user'
                    ? 'bg-blue-100 text-blue-900 ml-8'
                    : 'bg-gray-100 text-gray-800 mr-8'
                }`}
              >
                {msg.content || (isLoading && i === messages.length - 1 ? '...' : '')}
              </div>
            ))}
            <div ref={messagesEndRef} />
          </div>

          {/* Input field and send button */}
          <div className="p-3 border-t flex gap-2">
            <input
              type="text"
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
              placeholder="Type your question..."
              className="flex-1 px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
              disabled={isLoading}
            />
            <button
              onClick={sendMessage}
              disabled={isLoading || !input.trim()}
              className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
            >
              Send
            </button>
          </div>
        </div>
      )}
    </>
  )
}

What does this JSX render?

  • A round blue button fixed to the bottom-right corner of the screen. The fixed Tailwind class keeps it pinned even when the page scrolls.
  • When clicked, a chat panel appears above the button. It has a blue header, a scrollable message list, and an input area at the bottom.
  • User messages appear on the right with a blue background. Assistant messages appear on the left with a gray background. This makes it easy to follow the conversation.
  • The input field disables while a response is loading, and the send button grays out when there is nothing to send.
  • Save ChatWidget.tsx.
  • Refresh your browser at http://localhost:3000. The build error is gone. You should see your portfolio page with a blue circular chat button in the bottom-right corner.
  • Click the blue chat button. A white chat panel should slide open with the header Ask My AI, an empty message area, and an input field at the bottom.

Don't see the chat button?

  • Make sure you saved both ChatWidget.tsx and page.tsx. Unsaved changes will not appear in the browser.
  • Check that your file is at app/components/ChatWidget.tsx, not at the project root or inside a different folder. The import path in page.tsx must match the file location exactly.
  • If the browser still shows the old error, try stopping the dev server in your terminal with Ctrl+C and restarting it with npm run dev.

Why isn't my ChatWidget component showing up on the page?

Awesome, I've got everything!

Double check you have saved all three files before moving on.

I'd like to double check the full code

Here is the complete app/page.tsx:

import ChatWidget from './components/ChatWidget'

export default function Home() {
  return (
    <main className="min-h-screen bg-gray-50">
      <section className="max-w-3xl mx-auto px-6 py-20">
        <h1 className="text-4xl font-bold text-gray-900 mb-4">
          Hi, I'm Alex Chen
        </h1>
        <p className="text-xl text-gray-600 mb-8">
          Full-stack developer passionate about AI, cloud computing, and building
          products that solve real problems.
        </p>

        <div className="mb-12">
          <h2 className="text-2xl font-semibold text-gray-800 mb-4">Skills</h2>
          <div className="flex flex-wrap gap-2">
            {[
              'TypeScript',
              'React',
              'Next.js',
              'Node.js',
              'Python',
              'AWS',
              'Docker',
              'PostgreSQL',
              'Claude API',
              'Git',
            ].map((skill) => (
              <span
                key={skill}
                className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium"
              >
                {skill}
              </span>
            ))}
          </div>
        </div>

        <div className="mb-12">
          <h2 className="text-2xl font-semibold text-gray-800 mb-4">Projects</h2>
          <div className="space-y-4">
            <div className="p-4 bg-white rounded-lg shadow-sm border">
              <h3 className="font-semibold text-gray-900">AI Portfolio Chatbot</h3>
              <p className="text-gray-600 text-sm mt-1">
                This website! Built with Next.js and Claude API to showcase my work
                through an interactive AI assistant.
              </p>
            </div>
            <div className="p-4 bg-white rounded-lg shadow-sm border">
              <h3 className="font-semibold text-gray-900">Cloud Task Manager</h3>
              <p className="text-gray-600 text-sm mt-1">
                A serverless task management app deployed on AWS with DynamoDB and
                Lambda.
              </p>
            </div>
          </div>
        </div>

        <div className="p-6 bg-blue-50 rounded-lg border border-blue-200">
          <p className="text-blue-800 font-medium">
            💬 Want to know more? Click the chat button below to ask my AI assistant
            anything about my experience!
          </p>
        </div>
      </section>

      <ChatWidget />
    </main>
  )
}

Here is the complete app/globals.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

Here is the complete app/components/ChatWidget.tsx:

'use client'

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

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

export default function ChatWidget() {
  const [isOpen, setIsOpen] = useState(false)
  const [messages, setMessages] = useState<Message[]>([])
  const [input, setInput] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const messagesEndRef = useRef<HTMLDivElement>(null)

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

  const sendMessage = async () => {
    if (!input.trim() || isLoading) return

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

    const assistantMessage: Message = { role: 'assistant', content: '' }
    setMessages((prev) => [...prev, assistantMessage])

    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: input.trim() }),
      })

      if (!response.ok) throw new Error('Failed to get response')

      const reader = response.body?.getReader()
      const decoder = new TextDecoder()

      if (!reader) throw new Error('No response body')

      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        const chunk = decoder.decode(value, { stream: true })
        const lines = chunk.split('\n\n')

        for (const line of lines) {
          if (line.startsWith('data: ') && line !== 'data: [DONE]') {
            try {
              const data = JSON.parse(line.slice(6))
              if (data.text) {
                setMessages((prev) => {
                  const updated = [...prev]
                  const last = updated[updated.length - 1]
                  if (last.role === 'assistant') {
                    last.content += data.text
                  }
                  return [...updated]
                })
              }
            } catch {
              // Skip unparseable lines
            }
          }
        }
      }
    } catch (error) {
      setMessages((prev) => {
        const updated = [...prev]
        const last = updated[updated.length - 1]
        if (last.role === 'assistant') {
          last.content = 'Sorry, something went wrong. Please try again.'
        }
        return [...updated]
      })
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <>
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="fixed bottom-6 right-6 w-14 h-14 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 flex items-center justify-center text-2xl z-50"
      >
        {isOpen ? '\u2715' : '\uD83D\uDCAC'}
      </button>

      {isOpen && (
        <div className="fixed bottom-24 right-6 w-96 h-[500px] bg-white rounded-xl shadow-2xl border flex flex-col z-50">
          <div className="p-4 border-b bg-blue-600 text-white rounded-t-xl">
            <h3 className="font-semibold">Ask My AI</h3>
            <p className="text-sm text-blue-100">Ask anything about my experience</p>
          </div>

          <div className="flex-1 overflow-y-auto p-4 space-y-3">
            {messages.length === 0 && (
              <p className="text-gray-400 text-sm text-center mt-8">
                Ask me anything about Alex's skills, projects, or experience!
              </p>
            )}
            {messages.map((msg, i) => (
              <div
                key={i}
                className={`p-3 rounded-lg text-sm ${
                  msg.role === 'user'
                    ? 'bg-blue-100 text-blue-900 ml-8'
                    : 'bg-gray-100 text-gray-800 mr-8'
                }`}
              >
                {msg.content || (isLoading && i === messages.length - 1 ? '...' : '')}
              </div>
            ))}
            <div ref={messagesEndRef} />
          </div>

          <div className="p-3 border-t flex gap-2">
            <input
              type="text"
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
              placeholder="Type your question..."
              className="flex-1 px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
              disabled={isLoading}
            />
            <button
              onClick={sendMessage}
              disabled={isLoading || !input.trim()}
              className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
            >
              Send
            </button>
          </div>
        </div>
      )}
    </>
  )
}

Can I send a message yet?

You can type a message and click Send, but you will get an error message because the API route at /api/chat does not exist yet. You will build that in the next step.

Your portfolio looks great and the chat widget is ready to go. In the next step, you will build the API route that connects the chat widget to Claude so it can actually answer questions about you.

Create the Claude API Route with Streaming

Your portfolio page looks great, but right now the chat button has no brain behind it. In this step, you'll build the server-side route that receives a visitor's question and sends it to Claude for an answer.

The key technique here is streaming. Instead of waiting for Claude to finish its entire response before showing anything, you'll forward each word as it arrives. This gives the chatbot that satisfying "typing" effect users expect from modern AI apps.

In this step, get ready to:
  • Create a Next.js API route that handles chat requests from the frontend.
  • Write a system prompt that teaches Claude about your resume and skills.
  • Build a streaming response pipeline that forwards Claude's words to the browser in real-time.
Create the API route file with a POST handler

In Next.js App Router, any file named route.ts inside the app/api/ directory becomes a server-side API endpoint. The folder path determines the URL, so app/api/chat/route.ts creates an endpoint at /api/chat.

  • In the Explorer sidebar in Cursor, right-click the app folder inside your my-portfolio project.
  • Select New Folder and name it api.
  • Right-click the new api folder, select New Folder, and name it chat.
  • Right-click the chat folder, select New File, and name it route.ts.
  • Copy the following code into route.ts to set up the Anthropic client and a POST handler that validates incoming messages:
import Anthropic from '@anthropic-ai/sdk'

// Create the Anthropic client using your API key from .env.local
const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
})

// Handle POST requests from the chat frontend
export async function POST(request: Request) {
  const { message } = await request.json()

  // Validate that the request includes a message string
  if (!message || typeof message !== 'string') {
    return new Response(JSON.stringify({ error: 'Message is required' }), {
      status: 400,
      headers: { 'Content-Type': 'application/json' },
    })
  }
}

What does this code do?

  • import Anthropic from '@anthropic-ai/sdk' loads the SDK you installed in Step 2.
  • new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }) creates a client that reads your API key from the .env.local file you created earlier.
  • The POST function runs every time the chat frontend sends a question. It extracts the message and rejects empty or invalid requests with a 400 error.
  • Save route.ts.

You won't see a visible change yet. The next two chunks build on this handler to make it actually respond.

Seeing a red underline on the import?

If Cursor shows an error under '@anthropic-ai/sdk', make sure you ran npm install @anthropic-ai/sdk@0.90.0 in Step 2. You can confirm the package exists by checking for a @anthropic-ai folder inside node_modules.

help me fix an import error for the Anthropic SDK in my Next.js project

Add your resume data as a system prompt

A system prompt is a set of instructions that shapes how Claude responds. It runs before every conversation, giving Claude context it can reference. For this chatbot, the system prompt contains your resume data so Claude can answer questions about your skills and experience.

  • In route.ts, find this line:
// Handle POST requests from the chat frontend
export async function POST(request: Request) {
  • Add the following SYSTEM_PROMPT constant directly above that line:
const SYSTEM_PROMPT = `You are an AI assistant on Alex Chen's portfolio website. Answer questions about Alex based on the following information:

Name: Alex Chen
Role: Full-stack Developer
Experience: 3 years of professional software development
Skills: TypeScript, React, Next.js, Node.js, Python, AWS, Docker, PostgreSQL, Git
Education: B.S. in Computer Science

Projects:
- AI Portfolio Chatbot: Built with Next.js and Claude API. Demonstrates AI integration skills.
- Cloud Task Manager: Serverless app on AWS using DynamoDB and Lambda. Shows cloud architecture skills.

Interests: AI/ML, cloud computing, open source, building developer tools

Keep responses concise (2-3 sentences max). Be friendly and professional. If asked something not covered above, politely say you only have information about Alex's professional background.`

What does this code do?

The SYSTEM_PROMPT string is a template of your professional profile. Claude reads it before answering every question. The last two lines are behavioral guardrails. They keep answers short and prevent Claude from making up information that isn't in your resume.

Pro tip: make it yours

Replace the sample data (name, skills, projects) with your own real information. This is your portfolio. The more accurate the prompt, the more impressive the chatbot feels to visitors.

  • Save route.ts.

You won't see a change yet. The POST handler still doesn't return anything. The next chunk wires up the streaming response.

What is prompt engineering?

Prompt engineering is the practice of crafting instructions that guide an AI model's behavior. Your system prompt is a form of prompt engineering. By specifying the response length, tone, and boundaries, you control the quality of your chatbot without changing any code.

Build the streaming response

This is the core of the chatbot. You'll create a ReadableStream that calls Claude, listens for text chunks as they arrive, and forwards each one to the browser using the Server-Sent Events format.

  • In route.ts, find the closing brace of the if (!message ...) validation block.
  • Add the following code below it, still inside the POST function:
  // Create a readable stream that forwards Claude's response in real-time
  const stream = new ReadableStream({
    async start(controller) {
      try {
        // Start a streaming conversation with Claude
        const anthropicStream = client.messages.stream({
          model: 'claude-haiku-4-5-20241022',
          max_tokens: 300,
          system: SYSTEM_PROMPT,
          messages: [{ role: 'user', content: message }],
        })

        // Listen for each text chunk and forward it as an SSE event
        for await (const event of anthropicStream) {
          if (
            event.type === 'content_block_delta' &&
            event.delta.type === 'text_delta'
          ) {
            const data = `data: ${JSON.stringify({ text: event.delta.text })}\n\n`
            controller.enqueue(new TextEncoder().encode(data))
          }
        }

        // Signal that the stream is complete
        controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
        controller.close()
      } catch (error) {
        // Send an error message if something goes wrong
        const errData = `data: ${JSON.stringify({ error: 'Stream error occurred' })}\n\n`
        controller.enqueue(new TextEncoder().encode(errData))
        controller.close()
      }
    },
  })

  // Return the stream with SSE headers so the browser reads it as a live feed
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  })

What does this code do?

  • client.messages.stream() sends the user's question to Claude and asks for a streaming response. The model parameter picks Claude Haiku 4.5, which is fast and cheap. max_tokens: 300 caps the response length.
  • The for await loop processes events as they arrive. Each content_block_delta event contains a few words of Claude's response.
  • Each chunk is formatted as data: {"text": "..."}\n\n. This is the Server-Sent Events format that browsers know how to read as a live feed.
  • The three SSE headers (text/event-stream, no-cache, keep-alive) tell the browser to treat this response as a continuous stream instead of a single download.

Why streaming instead of waiting for the full response?

Without streaming, the user would stare at a blank screen for several seconds while Claude generates the complete answer. Streaming starts showing text within 1-2 seconds. This feels much more responsive and natural, like watching someone type.

  • Save route.ts.
  • Check your terminal where npm run dev is running. You should see no errors. If you see a compilation error, double check the code below.

Seeing a compilation error in the terminal?

  • Make sure the streaming code block is inside the POST function, after the validation if block. The function should have one return for the 400 error and one return for the stream response.
  • Check for mismatched curly braces. The ReadableStream has nested braces for the start callback, the try/catch, and the for await loop. Use the double-check tab below to compare your full file.
  • help me debug a compilation error in my Next.js API route for Claude streaming

Awesome, I've got everything!

Double check you've saved route.ts and your terminal shows no errors.

I'd like to double check the full code

Here is the complete app/api/chat/route.ts file. Make sure yours matches exactly.

import Anthropic from '@anthropic-ai/sdk'

const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
})

const SYSTEM_PROMPT = `You are an AI assistant on Alex Chen's portfolio website. Answer questions about Alex based on the following information:

Name: Alex Chen
Role: Full-stack Developer
Experience: 3 years of professional software development
Skills: TypeScript, React, Next.js, Node.js, Python, AWS, Docker, PostgreSQL, Git
Education: B.S. in Computer Science

Projects:
- AI Portfolio Chatbot: Built with Next.js and Claude API. Demonstrates AI integration skills.
- Cloud Task Manager: Serverless app on AWS using DynamoDB and Lambda. Shows cloud architecture skills.

Interests: AI/ML, cloud computing, open source, building developer tools

Keep responses concise (2-3 sentences max). Be friendly and professional. If asked something not covered above, politely say you only have information about Alex's professional background.`

export async function POST(request: Request) {
  const { message } = await request.json()

  if (!message || typeof message !== 'string') {
    return new Response(JSON.stringify({ error: 'Message is required' }), {
      status: 400,
      headers: { 'Content-Type': 'application/json' },
    })
  }

  const stream = new ReadableStream({
    async start(controller) {
      try {
        const anthropicStream = client.messages.stream({
          model: 'claude-haiku-4-5-20241022',
          max_tokens: 300,
          system: SYSTEM_PROMPT,
          messages: [{ role: 'user', content: message }],
        })

        for await (const event of anthropicStream) {
          if (
            event.type === 'content_block_delta' &&
            event.delta.type === 'text_delta'
          ) {
            const data = `data: ${JSON.stringify({ text: event.delta.text })}\n\n`
            controller.enqueue(new TextEncoder().encode(data))
          }
        }

        controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
        controller.close()
      } catch (error) {
        const errData = `data: ${JSON.stringify({ error: 'Stream error occurred' })}\n\n`
        controller.enqueue(new TextEncoder().encode(errData))
        controller.close()
      }
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  })
}

Why Haiku instead of a more powerful model?

Claude Haiku 4.5 costs $1 input / $5 output per million tokens. For a chatbot that gives 2-3 sentence answers about resume data, Haiku is more than capable. A more powerful model like Sonnet would produce similar quality answers for this simple task but cost more and respond slower.

Your API route is complete. Claude is ready to answer questions about your portfolio. Next up, you'll connect the chat frontend to this streaming endpoint so users can actually see Claude's responses appear in real-time.

Wire Up the Chat Frontend to the Streaming API

Your API route is built and ready to stream Claude responses. Now you need to connect your chat component to it so users actually see AI-generated text appear in real time.

In this step, you'll add the logic that sends messages to your API, reads the streaming response chunk by chunk, and updates the UI as text arrives. By the end, your chatbot will feel alive.

In this step, get ready to:
  • Add fetch logic that POSTs user messages to your streaming API route.
  • Parse Server-Sent Events in real time and update the chat UI as text streams in.
  • Add auto-scroll, loading states, and error handling for a polished experience.
Add the sendMessage function with fetch and streaming reader

Your chat component needs a function that takes what the user typed, sends it to your API, and reads back the streaming response piece by piece. This is where the Server-Sent Events pattern comes together on the client side.

  • In app/components/ChatWidget.tsx, find the sendMessage function. Replace its contents with the following full implementation:
const sendMessage = async () => {
  if (!input.trim() || isLoading) return

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

  // Add an empty assistant message that we'll fill as chunks arrive
  const assistantMessage: Message = { role: 'assistant', content: '' }
  setMessages((prev) => [...prev, assistantMessage])

  try {
    // POST the user's message to our streaming API route
    const response = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: input.trim() }),
    })

    if (!response.ok) throw new Error('Failed to get response')

    // Get a reader to consume the stream chunk by chunk
    const reader = response.body?.getReader()
    const decoder = new TextDecoder()

    if (!reader) throw new Error('No response body')

    // Read chunks until the stream is done
    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      // Decode binary chunk into text
      const chunk = decoder.decode(value, { stream: true })
      // Split by double newline (SSE event separator)
      const lines = chunk.split('\n\n')

      for (const line of lines) {
        if (line.startsWith('data: ') && line !== 'data: [DONE]') {
          try {
            const data = JSON.parse(line.slice(6))
            if (data.text) {
              // Append each text chunk to the assistant's message
              setMessages((prev) => {
                const updated = [...prev]
                const last = updated[updated.length - 1]
                if (last.role === 'assistant') {
                  last.content += data.text
                }
                return [...updated]
              })
            }
          } catch {
            // Skip unparseable lines
          }
        }
      }
    }
  } catch (error) {
    // If anything fails, show a friendly error in the chat
    setMessages((prev) => {
      const updated = [...prev]
      const last = updated[updated.length - 1]
      if (last.role === 'assistant') {
        last.content = 'Sorry, something went wrong. Please try again.'
      }
      return [...updated]
    })
  } finally {
    setIsLoading(false)
  }
}

What does this code do?

  • The function first adds the user's message to state and creates an empty assistant message as a placeholder.
  • It POSTs to /api/chat (the route you built in Step 4) and gets a ReadableStream back.
  • The getReader() method gives you a reader that pulls data from the stream one chunk at a time.
  • Each chunk is decoded and split by \n\n (the SSE separator). Lines starting with data: contain JSON with the text fragment from Claude.
  • As each text fragment arrives, it's appended to the assistant message in state. React re-renders, and the user sees text appear word by word.
  • Save ChatWidget.tsx.

Seeing a TypeScript error on the catch block?

If your editor underlines the empty catch {} with a warning, make sure you're using catch { (no variable) instead of catch (e) {. The empty catch syntax requires TypeScript 4.0+ which Next.js includes by default.

help me fix TypeScript errors in my sendMessage function

Understand how the streaming parser works

The parsing loop is the core of the real-time experience. Here's what happens for each chunk that arrives from your API:

How does SSE parsing work in the browser?

Your API sends text in SSE format. Each event looks like data: {"text": "Hello"} followed by two newlines.

  • reader.read() pulls the next available bytes from the network. A single read might contain one event, part of an event, or multiple events.
  • TextDecoder converts raw bytes into a string. The { stream: true } option handles multi-byte characters that might be split across chunks.
  • Splitting by \n\n separates individual SSE events. Each line is then checked for the data: prefix before parsing the JSON payload.
  • When data: [DONE] arrives, the stream is complete. The while loop exits on the next reader.read() when done is true.

Why not just await the full response?

Without streaming, the user would stare at a blank chat bubble for 2-3 seconds while Claude generates the entire response. Streaming shows text appearing immediately, word by word. It makes the chatbot feel responsive and alive.

Add auto-scroll and verify the loading states

As messages stream in, the chat window needs to scroll down automatically so the user always sees the latest text. You also need the input field and send button to disable while a response is loading.

  • In ChatWidget.tsx, find your component's state declarations (the useState lines near the top). Add this useEffect hook right after the messagesEndRef line:
useEffect(() => {
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])

What does this code do?

This useEffect runs every time the messages array changes. Since messages update on every streaming chunk, this keeps the scroll position pinned to the bottom. The messagesEndRef is an invisible div at the end of the message list that acts as the scroll target.

  • Verify that your input field has disabled={isLoading} and your send button has disabled={isLoading || !input.trim()}. These prevent the user from sending multiple messages while Claude is still responding.
  • Check that your message rendering includes the loading indicator: {msg.content || (isLoading && i === messages.length - 1 ? '...' : '')}. This shows three dots in the empty assistant bubble before the first chunk arrives.
  • Save ChatWidget.tsx.
  • Open your browser at http://localhost:3000 and click the chat button in the bottom-right corner.
  • Type a question like What are Alex's skills? and press Enter.
  • You should see three dots appear briefly, then text streaming in word by word. The chat automatically scrolls as the response grows.

Not seeing any response from the chatbot?

  • Open your browser's DevTools (right-click, then Inspect, then the Console tab). Look for errors. A 401 error means your ANTHROPIC_API_KEY is missing or invalid in .env.local.
  • Make sure your dev server is still running (npm run dev in the terminal). If you edited .env.local, restart the server for environment variable changes to take effect.
  • If you see Failed to get response in the chat, check the terminal running your dev server for error details. A common cause is a typo in the API route file path (it must be app/api/chat/route.ts exactly).

help me debug why my chat API isn't responding

Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

I'd like to double check the full code

Here's the complete ChatWidget.tsx file. Make sure yours matches:

'use client'

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

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

export default function ChatWidget() {
  const [isOpen, setIsOpen] = useState(false)
  const [messages, setMessages] = useState<Message[]>([])
  const [input, setInput] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const messagesEndRef = useRef<HTMLDivElement>(null)

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

  const sendMessage = async () => {
    if (!input.trim() || isLoading) return

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

    const assistantMessage: Message = { role: 'assistant', content: '' }
    setMessages((prev) => [...prev, assistantMessage])

    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: input.trim() }),
      })

      if (!response.ok) throw new Error('Failed to get response')

      const reader = response.body?.getReader()
      const decoder = new TextDecoder()

      if (!reader) throw new Error('No response body')

      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        const chunk = decoder.decode(value, { stream: true })
        const lines = chunk.split('\n\n')

        for (const line of lines) {
          if (line.startsWith('data: ') && line !== 'data: [DONE]') {
            try {
              const data = JSON.parse(line.slice(6))
              if (data.text) {
                setMessages((prev) => {
                  const updated = [...prev]
                  const last = updated[updated.length - 1]
                  if (last.role === 'assistant') {
                    last.content += data.text
                  }
                  return [...updated]
                })
              }
            } catch {
              // Skip unparseable lines
            }
          }
        }
      }
    } catch (error) {
      setMessages((prev) => {
        const updated = [...prev]
        const last = updated[updated.length - 1]
        if (last.role === 'assistant') {
          last.content = 'Sorry, something went wrong. Please try again.'
        }
        return [...updated]
      })
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <>
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="fixed bottom-6 right-6 w-14 h-14 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 flex items-center justify-center text-2xl z-50"
      >
        {isOpen ? '✕' : '💬'}
      </button>

      {isOpen && (
        <div className="fixed bottom-24 right-6 w-96 h-[500px] bg-white rounded-xl shadow-2xl border flex flex-col z-50">
          <div className="p-4 border-b bg-blue-600 text-white rounded-t-xl">
            <h3 className="font-semibold">Ask My AI</h3>
            <p className="text-sm text-blue-100">Ask anything about my experience</p>
          </div>

          <div className="flex-1 overflow-y-auto p-4 space-y-3">
            {messages.length === 0 && (
              <p className="text-gray-400 text-sm text-center mt-8">
                Ask me anything about Alex's skills, projects, or experience!
              </p>
            )}
            {messages.map((msg, i) => (
              <div
                key={i}
                className={`p-3 rounded-lg text-sm ${
                  msg.role === 'user'
                    ? 'bg-blue-100 text-blue-900 ml-8'
                    : 'bg-gray-100 text-gray-800 mr-8'
                }`}
              >
                {msg.content || (isLoading && i === messages.length - 1 ? '...' : '')}
              </div>
            ))}
            <div ref={messagesEndRef} />
          </div>

          <div className="p-3 border-t flex gap-2">
            <input
              type="text"
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
              placeholder="Type your question..."
              className="flex-1 px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
              disabled={isLoading}
            />
            <button
              onClick={sendMessage}
              disabled={isLoading || !input.trim()}
              className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
            >
              Send
            </button>
          </div>
        </div>
      )}
    </>
  )
}

Your AI-powered chatbot is working locally. Next up, you'll deploy the whole thing to Vercel so the world can see it.

Deploy to Vercel

Your chatbot streams real-time AI responses and your portfolio looks polished on your local machine.

In this step, you will push your code to GitHub and deploy your portfolio to Vercel. Vercel is a hosting platform built for Next.js applications. By the end, anyone on the internet can visit your AI-powered portfolio.

In this step, get ready to:
  • Push your code to GitHub.
  • Deploy your portfolio to Vercel.
  • Test the live AI chatbot at your public URL.
Push your code to GitHub

Vercel deploys code directly from a Git repository. You need to commit your project and push it to GitHub so Vercel can access it.

What about your API key?

Your .env.local file is already listed in .gitignore by default. Git will skip it entirely, so your Anthropic API key never leaves your machine.

  • In Cursor's terminal, make sure you are inside your my-portfolio folder.
  • Initialize a Git repository and commit all your files by running these commands:
git init
git add .
git commit -m "Add portfolio with AI chatbot"

What do these commands do?

  • git init creates a new Git repository in your project folder. If one already exists from create-next-app, this safely reinitializes it.
  • git add . stages every changed file for the next commit.
  • git commit saves a snapshot of your project with a descriptive message.

You should see output listing the files that were committed.

Seeing "command not found: git"?

  • On macOS, running any git command for the first time triggers an install prompt for Xcode Command Line Tools. Click Install and wait for it to finish, then re-run the commands above.
  • On Windows, download Git from git-scm.com, install it with the default settings, restart Cursor, and try again.
  • Help me install Git on my computer.

Now create a repository on GitHub to store your code remotely.

  • Open your browser and go to github.com.
  • Click the + icon in the top-right corner of the page.
  • Click New repository.
  • Enter my-portfolio in the Repository name field.
  • Leave it set to Public.
  • Do NOT check any of the initialization boxes (README, .gitignore, license). You already have these files locally.
  • Click Create repository.

GitHub shows a quick setup page with a URL for your new repository. You need this URL to connect your local project.

  • On the quick setup page, make sure HTTPS is selected (not SSH).
  • Copy the repository URL. It looks like https://github.com/your-username/my-portfolio.git.
  • Back in Cursor's terminal, connect your local project to GitHub and push your code by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/my-portfolio.git"]]
git push -u origin main

What do these commands do?

  • git remote add origin tells Git where to push your code (your GitHub repo).
  • git push -u origin main uploads your committed code to the main branch on GitHub.
  • Refresh your GitHub repository page in the browser. You should see all your project files listed, including app/, package.json, and next.config.

Push failed or asking for authentication?

  • If Git asks for a username and password, GitHub no longer accepts passwords for HTTPS pushes. You need a Personal Access Token. Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic), generate a token with repo scope, and paste it as your password.
  • If you see error: src refspec main does not match any, make sure you ran git add . and git commit before pushing.
  • Help me push my code to GitHub.
Connect your GitHub repo to Vercel

Vercel pulls your code directly from GitHub and handles the build and hosting for you. It auto-detects Next.js projects, so there is zero configuration needed.

  • Open your browser and go to vercel.com.
  • Click Sign Up (or Log In if you already have an account).
  • Select Continue with GitHub to connect your GitHub account.
  • Authorize Vercel when GitHub asks for permission.

Once you are logged in, you can import your repository.

  • Click Add New... in the top-right area of the dashboard.
  • Click Project.
  • Find my-portfolio in your repository list and click Import.

Don't see your repository in the list?

  • Click Adjust GitHub App Permissions at the bottom of the list. This opens GitHub's settings where you can grant Vercel access to your my-portfolio repository.
  • Help me connect my GitHub repo to Vercel.

Before clicking Deploy, you need to add your API key as an environment variable. Your .env.local file was not pushed to GitHub, so Vercel does not have your API key yet.

  • On the import page, scroll down to the Environment Variables section.
  • In the Key field (sometimes labeled Name), enter ANTHROPIC_API_KEY.
  • In the Value field, paste the same Anthropic API key you used in your .env.local file.
  • Click Add to save the environment variable.

Why add the API key here?

Environment variables on Vercel work like your local .env.local file. They are stored securely on Vercel's servers and injected into your application at runtime. Without this, your API route cannot authenticate with the Anthropic API, and the chatbot returns errors.

  • Click Deploy.

Vercel automatically detects your Next.js project and starts building. This takes about 1-2 minutes.

Test your live portfolio chatbot

Once the build finishes, Vercel shows a congratulations page with your live URL. This URL is your deployed portfolio that anyone can visit.

  • Click the preview image or the URL on the Vercel congratulations page to open your deployed site. The URL looks something like my-portfolio-xxxxx.vercel.app.
  • Verify that your portfolio page loads with your name, bio, and skills section.
  • Click the chat button in the bottom-right corner of the page.
  • Type What are Alex's skills? and press Enter.

You should see the AI response stream in word-by-word, just like it did on your local machine. Your portfolio is now live on the internet.

Chatbot returning an error or blank response?

  • Double check your environment variable on Vercel. Go to your project dashboard, click Settings, then Environment Variables. Make sure the key is exactly ANTHROPIC_API_KEY with no extra spaces.
  • If you updated an environment variable after deploying, you need to redeploy for changes to take effect. Go to the Deployments tab, click the three-dot menu on the latest deployment, and select Redeploy.
  • Check the Logs tab in your Vercel project dashboard for specific error messages from your API route.
  • Help me debug my Vercel deployment.

Pro tip: share your URL

Copy your Vercel URL and send it to a friend or paste it into your LinkedIn profile. Every future push to your main branch on GitHub triggers an automatic redeployment on Vercel.

Secret mission

Add Conversation Memory

Right now, your chatbot treats every message as a brand new conversation. It has no idea what you asked 10 seconds ago. In this secret mission, you'll add multi-turn conversation memory so the chatbot can handle follow-up questions like a real assistant.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Your Vercel deployment is free on the Hobby tier, but your Anthropic API key will consume credits each time someone uses the chatbot.

Cost warning

Your Anthropic account started with approximately $5 in free credits. Each chatbot conversation uses a small amount of credits (Haiku 4.5 costs $1 input / $5 output per million tokens). If your deployed portfolio gets traffic, those credits will slowly drain. Once they run out, the chatbot will stop working unless you add a payment method. If you want to avoid unexpected charges, consider pausing or deleting your deployment.

Resources you used:

  • Anthropic API account and API key (console.anthropic.com).
  • Vercel project and deployment (vercel.com dashboard).
  • GitHub repository.
  • Local Next.js project folder (my-portfolio).
  • Environment file (.env.local) containing your API key.

Keep everything running

No action needed. Choose this if you are still actively building or want to share your live portfolio with others.

  • Your Vercel deployment stays live at your project URL.
  • Your Anthropic API key remains active. Keep an eye on your credit balance at console.anthropic.com.
  • Your GitHub repository stays as-is.

Pause - I'll come back to this later

Take your deployment offline to stop API credit usage while keeping all your code intact.

  • Go to your Vercel dashboard, select your project, then open Settings.
  • Scroll down to Delete Project or disable the deployment. This stops all traffic and API credit consumption.
  • Your GitHub repo and local files remain untouched. You can redeploy anytime by reconnecting the repo to Vercel.
  • Optionally, revoke your API key at console.anthropic.com and generate a new one when you return.

Delete - I don't want to use this again

Remove all project resources completely.

  • Go to your Vercel dashboard, select your project, open Settings, scroll to the bottom, and click Delete Project.
  • Go to your GitHub repository, open Settings, scroll to the bottom, and click Delete this repository.
  • Go to console.anthropic.com and revoke your API key under API Keys.
  • Delete your local my-portfolio folder from your computer.

Mission accomplished

Nice Work!

You just built and deployed an AI-powered portfolio website with a live chatbot powered by Claude. Visitors can now ask your portfolio questions about your skills, projects, and experience, and get real-time streaming responses.

Here's what you accomplished:

  • Built a full-stack Next.js App Router application with a streaming AI chatbot powered by the Claude API.
  • Implemented real-time streaming responses using ReadableStream and Server-Sent Events to give the chatbot a live typing feel.
  • Deployed to Vercel with secure environment variable management, giving you a public URL to share with employers.
  • Added conversation memory so the chatbot remembers context across multiple questions in a session.

Ready to quiz yourself?