Build an AI Chat App with Next.js

Build a streaming AI chat app with Next.js App Router and Google Gemini.

Introduction

30 Second Summary

When you type a message in ChatGPT or Google Gemini, the response appears word-by-word instead of all at once. That streaming effect is not magic. It is a specific engineering pattern that every AI chat app uses under the hood.

In this project, you will build a real-time AI chat application from scratch using Next.js and Google Gemini. You will send messages, watch the AI respond character-by-character, and have full conversations where the AI remembers everything you said.

What You'll Build

You type a message, hit send, and watch the AI's response stream in word-by-word in real time. Then you ask a follow-up question and the AI remembers exactly what you were talking about.

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

  • A streaming AI chat app where you type a question and watch the response appear character-by-character in real time, exactly like ChatGPT.
  • Full multi-turn conversations where the AI remembers your entire chat history and can refer back to anything you said earlier.
  • A hands-on understanding of LLM API mechanics: what tokens are, why streaming matters, and how conversation context works at the protocol level.
  • Secret Mission: Add switchable AI personas with system prompts that transform the AI into a coding tutor, creative writer, or translator.

Are there any prerequisites?

You should be comfortable with JavaScript fundamentals and React basics (components, state, hooks). You also need Node.js installed on your machine.

No TypeScript experience is required. You will pick it up naturally as you build.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Taking a moment to articulate your goal up front turns a follow-along tutorial into a project you own.

Set Up Your Next.js Project and Gemini API Key

Every AI chat app starts the same way: a frontend for the user to type in, a backend to talk to the AI, and a secure API key to authenticate your requests.

In this step, you will scaffold a full-stack Next.js project, grab a free Gemini API key from Google AI Studio, and install the SDK that connects your code to the model.

In this step, get ready to:
  • Create a Next.js project with TypeScript and App Router.
  • Get a free Gemini API key and store it in your project.
  • Install the Google GenAI SDK.
Create a new Next.js project

Next.js is a full-stack React framework. It gives you both a frontend (React pages) and a backend (API routes) in a single project.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, type Terminal, and press Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Next.js project by running this command:
npx create-next-app@latest ai-chat

What will this command ask me?

The CLI will show two prompts. Here is exactly what to do:

  • First it asks What is your project named? Since you already passed ai-chat in the command, it pre-fills this. Press Enter to confirm.
  • Then it asks Would you like to use the recommended Next.js defaults? Select Yes, use recommended defaults and press Enter. This gives you TypeScript, ESLint, Tailwind CSS, and App Router.
  • Once the install finishes, move into your new project folder by running:
cd ai-chat
  • Start your development server by running:
npm run dev
  • Open your browser and go to http://localhost:3000.

You should see the Next.js starter page with the Next.js logo and some default text.

Don't see the starter page?

  • Make sure your terminal is still running the npm run dev process. If you closed it, run npm run dev again from inside the ai-chat folder.
  • Check that the URL is exactly http://localhost:3000 with no trailing path.
  • If port 3000 is already in use, the terminal output shows which port the server started on instead.

help me troubleshoot my Next.js dev server not loading at localhost:3000

Get your Gemini API key

To call the Gemini AI model, your app needs an API key that proves who you are. Google AI Studio gives you one for free with no credit card required.

  • Open aistudio.google.com in your browser.
  • Sign in with your Google account.
  • Click Get API Key in the left navigation panel.
  • Click Create API key. Google AI Studio automatically creates a project for new users and generates a key.
  • Copy the API key that appears. You will only see it once at creation time, so copy it now.

What is an API key?

An API key is like a password that identifies your app to Google's servers. It tells Google who is making the request and tracks your usage against the free tier limit (1,500 requests per day).

You should never share your API key publicly or commit it to version control. The .env.local file you create next keeps it out of your codebase.

  • Switch back to your terminal. Open a new terminal tab or window so the dev server keeps running.
  • Create the .env.local file in the root of your project by running:
echo 'GEMINI_API_KEY=your-api-key-here' > .env.local
  • Open .env.local in your code editor and replace your-api-key-here with the actual API key you copied from Google AI Studio.
  • Save the file.

Why .env.local?

Next.js automatically reads environment variables from .env.local and makes them available in your server-side code via process.env. This file is already listed in the default .gitignore, so your key stays off GitHub.

  • Confirm the file exists by running:
ls -a | grep .env

You should see .env.local in the output.

Not seeing .env.local?

  • Make sure you ran the echo command from inside the ai-chat folder. Run pwd to check your current directory.
  • On Windows, use dir instead of ls -a to see hidden files.

help me find or create my .env.local file in my Next.js project

Install the Google GenAI SDK

The SDK is the library that lets your TypeScript code talk to Gemini's API without manually writing HTTP requests.

  • Install the official Google GenAI package by running:
npm install @google/genai
  • Confirm the package was added by running:
cat package.json | grep genai

You should see a line containing @google/genai with a version number in the output.

Install command failing?

  • Make sure you are inside the ai-chat directory. Running npm install outside a project with a package.json will fail.
  • Check your internet connection. npm needs to download the package from the registry.

help me fix npm install errors in my ai-chat project

✔️ Awesome, I've got everything!

Great. Double check that .env.local is saved with your real API key inside.

ⓧ I'd like to double check the full code

Your .env.local file should look like this (with your actual key in place of the placeholder):

GEMINI_API_KEY=your-api-key-here

Your project is scaffolded, your API key is stored, and your SDK is installed. Next up, you will write your first API route and make Gemini answer a question.

Get Your First AI Response

Your Next.js project is running and your API key is stored. Now it is time to prove the connection works.

Before building an elaborate chat interface, you need to verify that your app can talk to Gemini and get a response back. This step produces the simplest possible version of your end product: type a question, get an AI answer.

In this step, get ready to:
  • Create an API Route Handler that calls the Gemini model.
  • Build a Client Component with a form that sends messages to your API.
  • See your first AI-generated response appear on the page.
Create the API Route Handler

In Next.js, a file named route.ts inside the app/api/ folder becomes an API endpoint automatically. This is the equivalent of an Express route, but without any manual server setup.

  • In the file explorer, create a new folder path app/api/chat/ inside your ai-chat project.
  • Create a new file called route.ts inside app/api/chat/ and paste the following code:
import { GoogleGenAI } from "@google/genai";
import { NextRequest } from "next/server";

// Initialize the Gemini client with your API key
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });

export async function POST(request: NextRequest) {
  // Extract the message from the request body
  const { message } = await request.json();

  // Call Gemini to generate a response
  const response = await ai.models.generateContent({
    model: "gemini-2.5-flash",
    contents: [{ role: "user", parts: [{ text: message }] }],
  });

  // Return the AI's response as JSON
  return Response.json({ text: response.text });
}

What does this code do?

This file exports a POST function, which tells Next.js to handle POST requests at /api/chat. The function extracts the user's message, sends it to the Gemini gemini-2.5-flash model, and returns the AI's text as JSON.

The ! after process.env.GEMINI_API_KEY is TypeScript's non-null assertion. It tells TypeScript you are certain this environment variable exists (because you set it in .env.local).

  • Save app/api/chat/route.ts.
  • Test that your Route Handler works by running this command in a new terminal:
curl -X POST http://localhost:3000/api/chat -H "Content-Type: application/json" -d '{"message": "What is JavaScript?"}'

You should see a JSON response containing AI-generated text about JavaScript. It may take a few seconds.

Seeing an error from curl?

  • Make sure your dev server is still running in the other terminal. If it stopped, run npm run dev again.
  • Check that .env.local has your actual API key replacing your-api-key-here.
  • If you see a 404, confirm the file is at app/api/chat/route.ts (not app/api/route.ts).

Why is my Route Handler returning an error?

✔️ Awesome, I've got everything!

Great. Double check you have saved app/api/chat/route.ts and move on to the next substep.

ⓧ I'd like to double check the full code

import { GoogleGenAI } from "@google/genai";
import { NextRequest } from "next/server";

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });

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

  const response = await ai.models.generateContent({
    model: "gemini-2.5-flash",
    contents: [{ role: "user", parts: [{ text: message }] }],
  });

  return Response.json({ text: response.text });
}
Build the chat page

Your API works. Now you need a frontend for it. In Next.js, components that use React state or event handlers need a "use client" directive at the top. These are called Client Components.

  • Open app/page.tsx in your editor. This file already has the default Next.js starter content.
  • Delete everything in the file and replace it with the following code that sets up the component logic:
"use client";

import { useState, FormEvent } from "react";

export default function Home() {
  // State for the user's input text
  const [input, setInput] = useState("");
  // State for the AI's response
  const [response, setResponse] = useState("");

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!input.trim()) return;

    // Send the message to our Route Handler
    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: input }),
    });

    // Parse the JSON and update state
    const data = await res.json();
    setResponse(data.text);
  }

What does this code do?

  • "use client" tells Next.js this component runs in the browser (so useState and event handlers work).
  • input holds what the user is typing. response holds what the AI sends back.
  • handleSubmit sends a POST request to /api/chat with the user's message and stores the AI's reply in state.
  • Directly below the code you just pasted (still inside the same file), add the JSX that renders the UI:
  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
      <h1 className="text-2xl font-bold text-center mb-4">AI Chat</h1>

      {/* Display the AI's response */}
      <div className="flex-1 mb-4">
        {response && (
          <div className="bg-gray-200 text-gray-900 rounded-lg px-4 py-2">
            {response}
          </div>
        )}
      </div>

      {/* Input form */}
      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Type a message..."
          className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <button
          type="submit"
          className="bg-blue-500 text-white px-6 py-2 rounded-lg"
        >
          Send
        </button>
      </form>
    </div>
  );
}

What does this JSX do?

  • The outer div centers the chat area and fills the screen height.
  • The response variable renders inside a gray bubble only when the AI has replied (the && check).
  • The form at the bottom has a text input bound to input state and a blue Send button that triggers handleSubmit.
  • Save app/page.tsx.
  • Switch to your browser and refresh localhost:3000. You should see the "AI Chat" heading, an empty area in the middle, and a text input with a Send button at the bottom.

Page not updating?

  • Make sure you deleted all the original content from app/page.tsx before pasting the new code.
  • Check for a red error overlay in the browser. A missing closing brace or bracket is the most common cause.
  • Confirm both code chunks are in the same file, with the return ( directly below the closing } of handleSubmit.

Why is my page showing an error after I saved?

✔️ Awesome, I've got everything!

Great. Double check you have saved app/page.tsx and move on to testing.

ⓧ I'd like to double check the full code

Your app/page.tsx should contain both chunks in order. Scroll up and confirm:

  • Chunk 1 starts with "use client"; and ends with the closing } of handleSubmit.
  • Chunk 2 starts with return ( and ends with the final } that closes the Home function.
  • There are no leftover lines from the original starter page.
Test your first AI response

Everything is wired up. Time to see it work end-to-end in the browser.

  • Before you test, predict: when you type a message and click Send, how long do you think it will take for the response to appear? Do you think it will show up all at once or word-by-word?
  • In the input field, type What is JavaScript? and click Send.

After 2-5 seconds of silence, the entire response appears all at once in a gray bubble. That delay with no feedback is the exact problem you will fix in the next step.

Why does it take so long?

The model generates the entire answer before the Route Handler sends anything back. Your browser waits with no indication that work is happening. Users have no idea whether the app is broken or just thinking.

In the next step, you will switch to streaming so words appear as the model generates them.

You have a working AI chat. Type a question, get a real answer from Gemini. Next up, you will make the response stream in word-by-word so it feels like a real conversation instead of a frozen page.

Stream the Response in Real Time

Right now, your chat app has a frustrating problem. You click send, stare at a blank screen for 3-5 seconds with zero feedback, then the entire response dumps onto the page all at once.

Real chat apps like ChatGPT show words appearing as the AI generates them. In this step, you will fix that dead silence by streaming the AI's response token-by-token directly to the browser.

In this step, get ready to:
  • Update your Route Handler to stream chunks instead of returning one JSON blob.
  • Update the frontend to read the stream and display text as it arrives.
  • Add a loading indicator and prevent double-sends during streaming.
Update the Route Handler to stream responses

The Gemini SDK has a method called generateContentStream() that returns an async iterable of text chunks instead of one complete response. Your Route Handler will pipe each chunk through a ReadableStream to the browser as it arrives.

  • Replace the entire contents of app/api/chat/route.ts with the following code:
import { GoogleGenAI } from "@google/genai";
import { NextRequest } from "next/server";

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });

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

  // Use generateContentStream instead of generateContent
  const responseStream = await ai.models.generateContentStream({
    model: "gemini-2.5-flash",
    contents: [{ role: "user", parts: [{ text: message }] }],
  });

  // Create a ReadableStream that sends each chunk to the browser
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      for await (const chunk of responseStream) {
        const text = chunk.text;
        if (text) {
          controller.enqueue(encoder.encode(text));
        }
      }
      controller.close();
    },
  });

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

What does this code do?

Instead of waiting for the full AI response and sending it as JSON, this version streams the response piece by piece.

  • generateContentStream() returns an async iterable. Each iteration yields a chunk of text as the model generates it.
  • The ReadableStream wraps those chunks into a format the browser can consume incrementally. TextEncoder converts each string chunk into bytes for the stream.
  • The response header text/plain; charset=utf-8 tells the browser this is a plain text stream, not JSON.
  • Save app/api/chat/route.ts.

✔️ Awesome, I've got everything!

Great. Your backend is now streaming. Next you will update the frontend to read that stream.

ⓧ I'd like to double check the full code

import { GoogleGenAI } from "@google/genai";
import { NextRequest } from "next/server";

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });

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

  const responseStream = await ai.models.generateContentStream({
    model: "gemini-2.5-flash",
    contents: [{ role: "user", parts: [{ text: message }] }],
  });

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      for await (const chunk of responseStream) {
        const text = chunk.text;
        if (text) {
          controller.enqueue(encoder.encode(text));
        }
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}
Update the frontend to consume the stream

Your backend now sends a stream of bytes instead of a JSON object. The frontend needs to read those bytes chunk-by-chunk using the browser's built-in streaming APIs.

  • In app/page.tsx, find these two state declarations at the top of the Home function:
const [input, setInput] = useState("");
const [response, setResponse] = useState("");
  • Add a new isStreaming state variable directly below them, so the three lines look like this:
const [input, setInput] = useState("");
const [response, setResponse] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
  • Now replace the entire handleSubmit function with this new version that reads the stream:
  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!input.trim() || isStreaming) return;

    setResponse("");
    setIsStreaming(true);

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

    if (!res.body) return;

    // Read the stream chunk by chunk
    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let aiResponse = "";

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

    setIsStreaming(false);
  }

What does this code do?

  • res.body.getReader() gives you a ReadableStreamDefaultReader that lets you pull bytes from the response as they arrive over the network.
  • TextDecoder converts those raw bytes back into text strings you can display.
  • The while (true) loop keeps reading until done is true (meaning the stream has ended). Each iteration appends the new chunk to the running response and calls setResponse(), which triggers a React re-render showing the latest text.
  • Save app/page.tsx.
  • Return to your browser at localhost:3000 and type a question like "Explain how the internet works" then click Send.
  • You should see the response appearing word-by-word instead of all at once.

Response not streaming?

  • Check that you saved both files (route.ts and page.tsx). The dev server picks up changes on save.
  • If you see a blank response, confirm the Route Handler returns new Response(stream, ...) and not Response.json().
  • If you get a network error in the console, make sure your dev server is still running in the terminal.

Why isn't my streaming response showing up in the browser?

Add loading feedback and prevent double-sends

Streaming works, but there is still a brief moment between clicking Send and the first chunk arriving where the user sees nothing. You also have no guard against clicking Send multiple times during a response.

  • In app/page.tsx, replace the entire return (...) block with this updated JSX:
  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
      <h1 className="text-2xl font-bold text-center mb-4">AI Chat</h1>
      <div className="flex-1 mb-4">
        {(response || isStreaming) && (
          <div className="bg-gray-200 text-gray-900 rounded-lg px-4 py-2">
            {response || "Thinking..."}
          </div>
        )}
      </div>
      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Type a message..."
          className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <button
          type="submit"
          disabled={isStreaming || !input.trim()}
          className="bg-blue-500 text-white px-6 py-2 rounded-lg disabled:opacity-50"
        >
          Send
        </button>
      </form>
    </div>
  );

What changed in the JSX?

  • The condition {(response || isStreaming) && (...)} shows the response bubble as soon as the user clicks Send, even before any text arrives.
  • {response || "Thinking..."} displays "Thinking..." while the response string is still empty, then replaces it with the streaming text as soon as the first chunk arrives.
  • The disabled prop on the button prevents the user from sending multiple messages at once. The disabled:opacity-50 Tailwind class visually grays out the button when disabled.
  • Save app/page.tsx.

Before you test, think about what you expect to see the moment you click Send. What should appear instantly, before any AI text arrives?

  • Return to your browser and type "Tell me a short story about a robot" then click Send.

You should see "Thinking..." appear immediately in a gray bubble. Within a second, the text starts flowing in word-by-word, replacing the placeholder. The Send button is grayed out and unclickable until the response finishes.

Not seeing "Thinking..." or the button stays enabled?

  • Make sure you added the isStreaming state variable near the top of your component. Without it, the JSX conditions will throw an error.
  • Check that the response display condition is (response || isStreaming) with parentheses around the OR expression. Without the parentheses, the logic short-circuits incorrectly.
  • Confirm the button has disabled={isStreaming || !input.trim()} as an attribute. The curly braces are required around the expression.

My loading state and disabled button are not working correctly in my streaming chat app.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Scroll up through this step and confirm you have all three sections in place, in order:

  • The full app/api/chat/route.ts file using generateContentStream and returning a ReadableStream.
  • The handleSubmit function in app/page.tsx with the getReader() and while (true) loop.
  • The updated JSX return block with (response || isStreaming), the "Thinking..." fallback, and the disabled prop on the button.

Your chat app now feels alive. Words flow onto the screen in real time, exactly like ChatGPT. Next up, you will build a proper multi-message conversation interface so the user can see their full chat history.

Build the Multi-Message Chat Interface

Right now your app only shows one question and one answer. Every time you send a new message, the previous response vanishes. A real chat app displays the full conversation as a scrolling thread.

In this step, you will store every message in an array and render them as a scrollable list. You will also discover a fundamental limitation of how LLMs work.

In this step, get ready to:
  • Replace the single response state with a typed messages array.
  • Render all messages as a scrollable, styled chat history.
  • Discover the AI's memory limitation firsthand.
Define the Message type and update your state

Instead of storing a single string response, you need an array that holds every message in the conversation. Each message has a role (who said it) and content (what they said).

  • In app/page.tsx, replace everything from the top of the file down to (but not including) the handleSubmit function with this code:
"use client";

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

// Define the shape of a single chat message
interface Message {
  role: "user" | "model";
  content: string;
}

export default function Home() {
  // Store the full conversation as an array of messages
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [isStreaming, setIsStreaming] = useState(false);
  // Ref to auto-scroll to the latest message
  const messagesEndRef = useRef<HTMLDivElement>(null);

  // Scroll to the bottom whenever messages change
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

What changed here?

  • The Message interface is a TypeScript type definition. It tells the compiler that every message object must have a role and a content field.
  • The old response string is gone. In its place, messages holds an array of every message in the conversation.
  • The useRef and useEffect work together to auto-scroll the chat to the newest message whenever the array updates.
Update handleSubmit to build the conversation

The submit handler now needs to add the user's message to the array, then append the AI's response chunk-by-chunk as a new entry.

  • Replace the existing handleSubmit function with this code:
  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!input.trim() || isStreaming) return;

    // Create a message object for what the user just typed
    const userMessage: Message = { role: "user", content: input };
    const updatedMessages = [...messages, userMessage];
    setMessages(updatedMessages);
    setInput("");
    setIsStreaming(true);

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

    if (!response.body) return;

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let aiResponse = "";

    // Add an empty model message that we fill chunk-by-chunk
    setMessages([...updatedMessages, { role: "model", content: "" }]);

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      aiResponse += decoder.decode(value, { stream: true });
      setMessages([...updatedMessages, { role: "model", content: aiResponse }]);
    }

    setIsStreaming(false);
  }

What does this code do?

  • When the user submits, it creates a userMessage object and appends it to the messages array immediately. The input clears right away so the user sees their message appear in the chat.
  • Before reading chunks, it pushes an empty model message with content: "". This renders as "Thinking..." in the UI while the first chunk arrives.
  • Each time a chunk arrives, it rebuilds the messages array with the AI response so far. React re-renders, and the learner sees text appear character-by-character.
Render messages as a scrollable chat history

Now you need the JSX to loop through the messages array and display each one with distinct styling for user vs AI messages.

  • In the return statement, find the existing <div className="flex-1 mb-4"> block (the one that conditionally renders the response).
  • Replace that entire <div> and everything inside it (down to its closing </div>) with this code:
      <div className="flex-1 overflow-y-auto space-y-4 mb-4">
        {/* Loop through every message and render it */}
        {messages.map((msg, i) => (
          <div
            key={i}
            className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
          >
            <div
              className={`max-w-[80%] rounded-lg px-4 py-2 ${
                msg.role === "user"
                  ? "bg-blue-500 text-white"
                  : "bg-gray-200 text-gray-900"
              }`}
            >
              {msg.content || "Thinking..."}
            </div>
          </div>
        ))}
        {/* Invisible element that useEffect scrolls to */}
        <div ref={messagesEndRef} />
      </div>

What does this code do?

  • The messages.map() loops through every message in the array and renders a bubble for each one.
  • User messages get justify-end (right-aligned, blue). AI messages get justify-start (left-aligned, gray).
  • The invisible <div ref={messagesEndRef} /> sits at the bottom. The useEffect scrolls to it every time a new message appears.
  • Save app/page.tsx.
  • Refresh your browser at localhost:3000. You should see an empty chat area with the input at the bottom.
  • Type "Hello" and click Send. Your message should appear as a blue bubble on the right, and the AI's response streams in as a gray bubble on the left.
  • Send a second message. Both your messages and both AI responses should remain visible in the scrolling chat history.

Seeing a blank page or error?

  • Check your terminal for TypeScript errors. The most common issue is a leftover reference to the old response state variable. Make sure you removed the old setResponse lines and the old conditional render block.
  • If messages appear but do not scroll, confirm you added overflow-y-auto to the messages container div.
  • If only the user message appears with no AI response, check that you have the setMessages([...updatedMessages, { role: "model", content: "" }]) line before the while loop.

Help me debug my multi-message chat interface.

Test the AI's memory

Your chat UI now looks like a real messaging app. But there is a fundamental problem hiding under the surface. Each time you send a message, the Route Handler only receives that single message. The AI never sees what came before.

  • In the chat, type "My name is Alex" (or any name) and send it. Wait for the AI to respond.
  • Now type "What is my name?" and send it.

Before you read the response, what do you think the AI will say? Will it remember your name?

The AI does NOT know your name. It will say something like "I don't have access to your name" or ask you to tell it. Even though both messages are visible in your chat UI, the API only received the second message by itself.

Why does the AI have no memory?

Every call to the Gemini API is completely stateless. The model has no idea that a previous request existed. Your Route Handler currently sends only the latest message in the request body.

This is how every LLM API works. To give the AI "memory," you must send the entire conversation history with every single request. The next step fixes this.

✔️ Awesome, I've got everything!

Great. Double check you have saved app/page.tsx and that your chat shows multiple messages with the correct styling.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these sections is present in your app/page.tsx, in order:

  • The "use client" directive, imports (useState, useRef, useEffect, FormEvent), and the Message interface.
  • State declarations: messages, input, isStreaming, and messagesEndRef.
  • The useEffect that auto-scrolls on [messages] changes.
  • The handleSubmit function that pushes user and model messages to the array.
  • The JSX with messages.map() rendering styled bubbles, plus the messagesEndRef div and the form.

The app/api/chat/route.ts file is unchanged from the previous step.

Your chat now looks and feels like a real messaging app. The one thing missing is memory. In the next step, you will fix this by sending the full conversation history with every API call.

Add Conversation Memory

In the last step, you discovered something fundamental about how LLM APIs work. You asked the AI what you just said, and it had no idea.

Each API call to Gemini is completely stateless. The model only sees what you send in that single request. To give it memory, you must send the entire conversation history with every request. This is how every chat application works under the hood.

In this step, get ready to:
  • Update the Route Handler to accept the full message history.
  • Update the frontend to send all messages with each request.
  • Test that the AI correctly remembers earlier messages.
Update the Route Handler to accept full conversation history

Right now, your Route Handler receives a single message string and wraps it in a one-item array. You need it to accept the entire messages array so the model can see everything that was said before.

  • In app/api/chat/route.ts, find the opening of the POST function. Locate this line:
  const { message } = await request.json();
  • Replace everything from that line down through the generateContentStream call (but keep the ReadableStream section and the return statement unchanged) with this code:
  const { messages, systemInstruction } = await request.json();

  const contents = messages.map((msg: { role: string; content: string }) => ({
    role: msg.role,
    parts: [{ text: msg.content }],
  }));

  const responseStream = await ai.models.generateContentStream({
    model: "gemini-2.5-flash",
    contents,
    config: systemInstruction ? { systemInstruction } : undefined,
  });

What does this code do?

  • const { messages, systemInstruction } destructures the full conversation array (and an optional system instruction for later use) from the request body.
  • The messages.map() call converts your frontend's {role, content} format into the Gemini API's expected {role, parts: [{text}]} format.
  • The contents variable now holds the entire conversation history, so the model sees every previous message when generating its response.
  • The config parameter passes a system instruction only when one is provided. You will use this in the Secret Mission.
  • Save app/api/chat/route.ts.

✔️ Awesome, I've got everything!

Great. Make sure you saved the file before moving on.

ⓧ I'd like to double check the full code

Your app/api/chat/route.ts should now have these sections in order:

  • Two import lines at the top (GoogleGenAI and NextRequest).
  • The ai constant initialized with your API key.
  • The POST function destructuring messages and systemInstruction from the request body.
  • The contents mapping that converts messages to {role, parts} format.
  • The generateContentStream call with contents and optional config.
  • The unchanged ReadableStream and return new Response() from the previous step.
Update the frontend to send all messages

Your Route Handler now expects a messages array. The frontend still sends a single message string. Time to connect them.

  • In app/page.tsx, scroll to the handleSubmit function and find this line inside the fetch call:
      body: JSON.stringify({ message: input }),
  • Replace { message: input } with { messages: updatedMessages }, so the line now looks like this:
      body: JSON.stringify({ messages: updatedMessages }),

Why updatedMessages and not messages?

The updatedMessages variable is the array you created a few lines earlier. It already includes the user's new message appended to the full history. Sending this gives the model everything it needs to understand the conversation so far.

  • Save app/page.tsx.
Test conversation memory

Both files are updated. The frontend now sends the full conversation history, and the Route Handler passes it all to Gemini. Time to prove it works.

  • Switch to your browser at localhost:3000 and refresh the page to start a fresh conversation.
  • Type I have a dog named Rex. and click Send. Wait for the AI response.

Now, before you send your next message, predict: if you ask "What is my dog's name?" will the AI remember it this time?

  • Type What is my dog's name? and click Send.

The AI correctly answers "Rex." It remembers because you are now sending the full conversation history with every request.

  • Try a longer chain to push it further. Tell the AI three facts (one message at a time): your favourite colour, a city you want to visit, and a food you dislike.
  • Then ask: List all three facts I told you. The AI should recall all three.

AI still not remembering?

  • Make sure your page.tsx sends messages: updatedMessages (plural) not message: input (singular).
  • Make sure your Route Handler destructures messages (not message) from the request body.
  • Refresh the page to start a clean conversation. Old messages from before the code change may have been sent in the wrong format.

Why can't my AI remember previous messages?

Your chat app now has real conversation memory. The AI can follow multi-turn discussions, refer back to earlier points, and build on what you said. Next up, you will take this further in a Secret Mission by giving the AI completely different personalities using system prompts.

Secret mission

Add Switchable AI Personas

Your chat app talks to one generic AI. But what if you could switch between a coding tutor, a creative writer, and a Spanish translator with a single click? System prompts let you shape the AI's entire personality. Build a persona selector that transforms how your AI responds.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The ai-chat project folder on your Desktop.
  • Your Gemini API key stored in .env.local and in Google AI Studio.
  • The Next.js dev server running at localhost:3000.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with personas and prompts.

  • Your dev server stays running at localhost:3000. Stop it anytime with Ctrl+C in the terminal.
  • Your API key remains active in Google AI Studio on the free tier with no charges.
  • Your project files stay in the ai-chat folder on your Desktop.

Pause - I'll come back to this later

Shut down the running process to free up your terminal. Your files and API key stay intact for when you return.

  • In the terminal where your dev server is running, press Ctrl+C to stop the Next.js process.
  • Your project files and .env.local remain on disk. When you are ready to continue, run npm run dev from the ai-chat folder to start back up.

Delete - I don't want to use this again

Remove all project resources and revoke your API key so nothing lingers on your machine or Google account.

  • Stop the dev server by pressing Ctrl+C in the terminal.
  • Delete the project folder on macOS/Linux by running:
rm -rf ~/Desktop/ai-chat
  • On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\ai-chat
  • Delete your Gemini API key by going to aistudio.google.com, selecting Get API key in the left sidebar, and deleting the key you created for this project.

Nice Work!

Nice Work!

Nice work! You just built a fully functional streaming AI chat application from scratch using Next.js App Router and Google Gemini.

You've learned how to:

  • Built a real-time streaming AI chat app where responses appear token-by-token using ReadableStream and async iterators.
  • Understood how LLM APIs work under the hood: stateless calls, conversation context management, and why you must send full chat history with every request.
  • Mastered Next.js App Router fundamentals: file-based routing, Route Handlers, Client Components, and environment variables.
  • Secret Mission: Added switchable AI personas with system prompts to shape the model's behavior for prompt engineering fundamentals.

Ready to quiz yourself?