Series · LangGraph from Scratch · Part 4 of 8

· 23 min read

LangGraph from Scratch, Part 4: The Next.js Frontend

Build a real chat window in React, wire it to your backend with one fetch call, and watch the answer land in a browser. The first part where you have a product.

langgraph · nextjs · react · tutorial

You've been talking to your backend through curl for three parts now. It works, but curl is a screwdriver, not a chatbot. Nobody you know is going to open a terminal to say hello to your bot.

By the end of this page that changes completely. You'll have a real chat window in your browser: type a message, hit Send, watch the answer slide in as a bubble. The Part 3 backend doesn't change by a single line. Today is about building it a face.

A browser at localhost:3000 showing a finished chat app titled Chatbot. An assistant bubble greets the user, the user asked to explain recursion in one sentence, and the assistant replied with a one-sentence definition. An input box at the bottom reads 'Ask me anything...' with a Send button.
Today's destination. The same backend from Part 3, now answering you in a real chat UI instead of a terminal. By the end of this page, you built this.

Everything new today lives mostly in one file, frontend/app/page.tsx, about seventy lines of TypeScript by the time you're done. Three tools do the heavy lifting, all of them already in your frontend/ folder since Part 1's create-next-app:

ToolVersion used here
Next.js16.2.9
React19.2.0
Tailwind CSS4.1.13

One more thing joins them today, shadcn/ui, and it has no version number on purpose. More on that when we install it.

Two servers, still strangers

Cast your mind back to the last picture in Part 1: two boxes on one laptop, a frontend on :3000, a backend on :8000, and a dotted line between them labeled "they don't talk to each other yet. That's Part 4."

This is Part 4. Today you draw that line. The backend already knows how to answer (Part 3) and it already welcomes the frontend's origin (Part 2's CORS middleware, set up for exactly this moment). The only missing piece is a browser that knows how to ask. That's the whole job.

You'll want both servers running for the second half of this part. Start the frontend now, in its own terminal, from the frontend/ folder:

BASH
cd frontend
npm run dev

That serves your app at http://localhost:3000. Leave it running; like the backend's --reload, the Next.js dev server rebuilds every time you save. The backend can stay asleep for a few more sections; we'll wake it when there's something to call.

A tour of the room you're about to redecorate

Open the frontend/ folder in your editor. There's a lot of generated scaffolding in there, but for this whole series you only ever touch three files, all inside app/:

TEXT
frontend/
└── app/
├── layout.tsx the shell wrapped around every page
├── page.tsx the page at "/" ← you live here today
└── globals.css global styles and the Tailwind import

layout.tsx is the outer shell: it renders the <html> and <body> tags once and wraps every page inside them. You'll leave it alone. globals.css holds your global styles and the one line that pulls in Tailwind; shadcn will add a block of color variables here in a minute, and then you'll leave it alone too. page.tsx is the page served at /, the welcome screen you saw in Part 1, and it's the one file you're about to gut.

Open app/page.tsx, delete everything in it, and save. The browser tab goes blank. That's correct; an empty file is a clean canvas. Now let's fill it.

Borrow a professional wardrobe

You could hand-build a text input and a button from raw <div> tags and Tailwind classes, and spend an hour getting the focus rings and padding to feel right. Or you can borrow components that a designer already sweated over. That's shadcn/ui: a collection of accessible React components you copy straight into your project and own outright. No version number because it isn't a dependency you install; it's code that lands in your folder and becomes yours to edit.

Stop the dev server for a moment (CTRL+C in its terminal), and set shadcn up:

BASH
npx shadcn@latest init

It asks a couple of questions; when it asks for a base color, pick Neutral, and take the defaults for the rest. This writes a components.json, adds a small lib/utils.ts, and drops a set of color variables into your globals.css. Now pull in the three pieces today's UI needs:

BASH
npx shadcn@latest add button input card

Start the dev server again (npm run dev) and let's write some React.

Teach the page what a message is

A chat is a list of messages, and each message has two facts: who said it, and what they said. Before you can store a conversation, you have to describe its shape, the same instinct as Part 2's Pydantic models, just in TypeScript this time. Type this into your empty app/page.tsx:

TSX
import { useState } from "react";
interface Message {
role: "user" | "assistant";
content: string;
}
export default function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
return <div>chat goes here</div>;
}

interface Message is the order form for one chat line: a role that's either "user" or "assistant", and a content string. Then useState gives the component two pieces of memory: messages, the conversation so far (starting empty), and input, whatever the user is currently typing. Each call hands back the current value and a function to change it.

Save the file, and the dev server falls over instead of reloading clean:

A dark terminal running npm run dev. After the Next.js 16.2.9 ready banner, an error: './app/page.tsx:4:34. You're importing a component that needs useState. This React hook only works in a Client Component. To fix, mark the file (or its parent) with the use client directive.' A code frame points at line 4 where useState is called.
The error every App Router beginner meets. It's not a bug in your code; it's React asking you to declare which side of the fence this component lives on.

Read it like Part 2 taught you, except this one is friendly enough to read top-down: useState only works in a Client Component. Here's the idea behind that sentence. Next.js renders most components on the server, ahead of time, where there's no browser, no clicks, and no state that changes. A component that holds state and responds to typing has to run in the browser instead. Next.js won't guess which kind you meant; you have to say so, with one line at the very top of the file:

TSX
"use client";
import { useState } from "react";

Save again, and the reload is quiet. That one string is a boundary marker: everything in a file tagged "use client" ships to the browser and may use state, effects, and event handlers.

Dress the window

Right now the page renders the words "chat goes here". Time to render the actual conversation. First, bring in the shadcn pieces by adding to your imports:

TSX
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";

Now replace that placeholder return with a real layout: a card holding a header, a scrolling list of message bubbles, nothing else yet.

TSX
return (
<main className="mx-auto flex h-dvh max-w-2xl flex-col p-4">
<Card className="flex flex-1 flex-col overflow-hidden">
<div className="border-b px-5 py-4 font-semibold">Chatbot</div>
<div className="flex-1 space-y-4 overflow-y-auto p-5">
{messages.map((m, i) => (
<div key={i} className={m.role === "user" ? "text-right" : "text-left"}>
<span className={`inline-block max-w-[75%] rounded-2xl px-4 py-2 ${
m.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted"
}`}>
{m.content}
</span>
</div>
))}
</div>
</Card>
</main>
);

The line that matters is messages.map(...). It walks the messages array and turns each one into a bubble. User messages get pushed to the right with a dark fill; assistant messages stay left in a soft gray. The key={i} gives React a handle on each row so it can update the list efficiently. Everything else is Tailwind spacing.

There's one problem: messages starts empty, so there's nothing to see. To check your styling before the backend is wired, hand useState two fake messages for a moment:

TSX
const [messages, setMessages] = useState<Message[]>([
{ role: "user", content: "Is this thing on?" },
{ role: "assistant", content: "Loud and clear. This bubble is just sample data for now." },
]);
The chat card titled Chatbot showing two sample bubbles: a right-aligned dark user bubble reading 'Is this thing on?' and a left-aligned gray assistant bubble reading 'Loud and clear. This bubble is just sample data for now.' An input with a Send button sits at the bottom.
Fake data, real layout. The map turned two objects into two bubbles. Now delete the sample messages and set the state back to an empty array.

It works. Now put the state back to useState<Message[]>([]); real messages are about to arrive the honest way.

A box to type in

A chat needs an input and a Send button, and something to happen when you submit. Add a small handler inside the component, above the return:

TSX
function sendMessage(e: FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text) return;
setMessages((prev) => [...prev, { role: "user", content: text }]);
setInput("");
}

e.preventDefault() stops the browser's default form behavior (a full page reload, a relic from the 1990s). Then it trims the text, ignores empty submits, appends a new user message to the list, and clears the input. Note the (prev) => [...prev, ...] shape: it builds a new array from the old one plus the new message, because React only notices changes when you hand it a new array, never when you poke the old one.

That handler uses a FormEvent type, so widen your React import to bring it in:

TSX
import { useState, type FormEvent } from "react";

Now the form itself. Add it inside the Card, right after the messages <div>:

TSX
<form onSubmit={sendMessage} className="flex gap-2 border-t p-4">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask me anything..."
/>
<Button type="submit">Send</Button>
</form>

The Input is controlled: its value is always input from state, and every keystroke fires onChange, which writes the new text back to state. The form's onSubmit runs your sendMessage whether the reader clicks Send or just presses Enter.

Save, type something, hit Send. Your message appears as a bubble, the box clears, and then... nothing. No reply. Of course not: you never told it to call the backend. The UI is gorgeous and completely deaf.

Comic in two panels. Panel one: Yad, a bearded developer with headphones, beams and presents a gorgeous chat app on a monitor lit by a candle and a small plant; the chat shows a user bubble "HI!"; he says "SO PRETTY!". Panel two: a beat later the bot has replied with only "..." and nothing else; behind the monitor an unplugged cable tagged "FETCH" dangles loose; Yad sweats and says "ANY SECOND NOW".
A beautiful frontend with no fetch wired to the backend is a gorgeous car with no engine. The dots never turn into words until you connect the cable.

Wire it to the brain

Here's the cable. Your sendMessage currently adds the user's bubble and stops. It needs to take the next step: send that text to the backend, wait for the reply, and add the answer as a second bubble. Replace sendMessage with this:

TSX
async function sendMessage(e: FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text || loading) return;
setMessages((prev) => [...prev, { role: "user", content: text }]);
setInput("");
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
if (!res.ok) throw new Error();
const data = await res.json();
setMessages((prev) => [...prev, { role: "assistant", content: data.reply }]);
} catch {
setError("Could not reach the backend. Is it running on :8000?");
} finally {
setLoading(false);
}
}

Look past the new loading and error lines for a second (they get their own section next) and read the middle. That fetch is the entire round trip. It's the exact same POST /chat you've been sending with curl since Part 2, with the same Content-Type header and the same {"message": ...} body, except a browser sends it now. await pauses until the reply comes back, res.json() parses it, and data.reply is the string your backend returned. You append it as an assistant message, and the map you wrote earlier paints it as a bubble.

Two small things hold this together. First, API_BASE. Add it near the top of the file, just under the imports:

TSX
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;

That reads the backend URL you saved in frontend/.env.local back in Part 1. You also need the two new state variables the handler uses; widen your state block:

TSX
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

This is the moment the backend has to be awake. In a second terminal, start it the usual way (from backend/, with (.venv) active):

BASH
uvicorn app.main:app --reload

Now go back to the browser, type a real question, and hit Send. After a short pause, an answer appears in a bubble, composed by the model on the other end of your one-node graph. That's the screen from the very top of this page, except this time you built every layer of it: the bubble, the fetch, the graph, the model call. Sit with it for a second. You shipped a chatbot.

A diagram of the request round trip. On the left, a browser box at localhost:3000 holds a chat input and the React state 'messages, input'. An accent arrow labeled 'fetch( /chat )' with '{ message }' points right to a server box at localhost:8000 running POST /chat and graph.invoke. A return arrow labeled '200 OK' and '{ reply }' points back left. A caption reads that your click sends the words out and the reply rides back to become a bubble.
The whole trip. Same path as your Part 2 curl, except a browser makes it and React paints the answer. The backend is the Part 3 one-node graph, untouched.

Give it a pulse and a safety net

Two rough edges remain, and you already wrote half the fix. While the model thinks, the UI just sits there; and if the backend is down, the message vanishes into silence. The loading and error state you added handle both, but nothing shows them yet. Add two small pieces to your JSX.

First, a thinking indicator. Inside the messages <div>, right after the .map(...), add:

TSX
{loading && <p className="text-muted-foreground">Thinking...</p>}

Then an error banner and a disabled input while a request is in flight. Put the banner just above the <form>, and add disabled={loading} to both the Input and the Button:

TSX
{error && (
<p className="mx-5 mb-2 rounded-md bg-red-50 px-4 py-2 text-sm text-red-700">
{error}
</p>
)}

The loading && ... and error && ... are React's plain-JavaScript way of saying "render this only when that's true". When loading is false, false renders nothing; flip it true and the indicator appears. Send a message now and you get a "Thinking..." line and a frozen input until the reply lands:

The chat card with the user's message 'Explain recursion in one sentence.' showing, and below it a 'Thinking...' indicator with three dots while the assistant's reply is still being fetched. The input box is dimmed.
The pulse. While fetch is in flight, loading is true, so the indicator shows and the input is disabled. It flips back the instant the reply arrives.

Now prove the safety net works by breaking it on purpose. Click into the backend's terminal and press CTRL+C to stop it, then send another message:

The chat card with the user message 'Explain recursion in one sentence.' and a red error banner reading 'Could not reach the backend. Is it running on :8000?' above the input box.
With the backend stopped, fetch throws, the catch block runs, and the reader gets an honest sentence instead of a spinner that never stops. Restart the backend and it works again.

With nothing listening on :8000, fetch throws, your catch block runs, and the reader sees a plain explanation instead of a dead screen. Start the backend again (uvicorn app.main:app --reload) and the chat works exactly as before. A real app spends a surprising amount of its code on these "what if it fails" paths; you just wrote your first one.

Right now you have: a chat UI in the browser that takes a message, posts it to your FastAPI backend, runs it through the Part 3 graph, and shows the model's reply in a bubble, with a thinking indicator while it waits and an honest error when it can't connect. That dotted line from Part 1 is now a solid wire.

Here's the whole file, in case a piece drifted out of place while you built it up:

TSX
"use client";
import { useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";
interface Message {
role: "user" | "assistant";
content: string;
}
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL;
export default function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function sendMessage(e: FormEvent) {
e.preventDefault();
const text = input.trim();
if (!text || loading) return;
setMessages((prev) => [...prev, { role: "user", content: text }]);
setInput("");
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
if (!res.ok) throw new Error();
const data = await res.json();
setMessages((prev) => [...prev, { role: "assistant", content: data.reply }]);
} catch {
setError("Could not reach the backend. Is it running on :8000?");
} finally {
setLoading(false);
}
}
return (
<main className="mx-auto flex h-dvh max-w-2xl flex-col p-4">
<Card className="flex flex-1 flex-col overflow-hidden">
<div className="border-b px-5 py-4 font-semibold">Chatbot</div>
<div className="flex-1 space-y-4 overflow-y-auto p-5">
{messages.map((m, i) => (
<div key={i} className={m.role === "user" ? "text-right" : "text-left"}>
<span className={`inline-block max-w-[75%] rounded-2xl px-4 py-2 ${
m.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted"
}`}>
{m.content}
</span>
</div>
))}
{loading && <p className="text-muted-foreground">Thinking...</p>}
</div>
{error && (
<p className="mx-5 mb-2 rounded-md bg-red-50 px-4 py-2 text-sm text-red-700">
{error}
</p>
)}
<form onSubmit={sendMessage} className="flex gap-2 border-t p-4">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask me anything..."
disabled={loading}
/>
<Button type="submit" disabled={loading}>
Send
</Button>
</form>
</Card>
</main>
);
}

What you built

Part 4
  • A real chat UI in the browser: a scrolling message list, a text input, and a Send button, built from React state and shadcn/ui components.
  • The use client boundary in your bones: you know state and event handlers need it, and you've met the exact error you get when you forget.
  • A fetch call wiring the frontend to the backend, posting to /chat and appending the JSON reply as a new bubble. The Part 1 dotted line is finally a solid wire.
  • Loading and error states: a thinking indicator and a disabled input while you wait, and an honest banner when the backend is asleep.
  • The whole round trip in your head: your words leave as JSON, the graph thinks, the reply rides back, and React paints it on screen.

Test yourself

Score ··
01

You add useState to a component and the dev server errors with 'This React hook only works in a Client Component.' What fixes it?

02

Why read the backend URL from process.env.NEXT_PUBLIC_API_BASE_URL instead of hardcoding http://localhost:8000?

03

Your frontend calls the backend from the browser and it works, no CORS error in the console. Why, given curl never needed CORS?

04

Inside sendMessage, why is the new list written as setMessages((prev) => [...prev, newMessage]) rather than messages.push(newMessage)?

05

You stop the backend, send a message, and the UI shows 'Could not reach the backend.' Which part of the code produced that?

The commit, from the project root, in any terminal that isn't hosting a server:

BASH
git add .
git commit -m "part 4: a real chat UI wired to the backend over fetch"

There's still a pause in the middle of every conversation, the few seconds where the UI just says "Thinking..." and waits for the whole reply to land at once. Real chat apps don't make you wait like that; the words appear as the model writes them. In Part 5 you'll replace that pause with text streaming in one token at a time.