Series · LangGraph from Scratch · Part 4 of 8
· 34 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.
Almost everything new today lives in one file, frontend/app/page.tsx, about two hundred and thirty lines of TypeScript by the time you're done, most of it presentation. Three tools do the heavy lifting, all of them already in your frontend/ folder since Part 1's create-next-app:
| Tool | Version used here |
|---|---|
| Next.js | 16.2.9 |
| React | 19.2.4 |
| Tailwind CSS | 4.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:
cd frontendnpm run devThat 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/:
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 importlayout.tsx is the outer shell: it renders the <html> and <body> tags once and wraps every page inside them. You'll touch it exactly once today, in the metadata export near the top, so the browser tab says Lattice instead of "Create Next App". globals.css holds your global styles and the one line that pulls in Tailwind; shadcn is about to write a block of color variables in there, and you'll come back and give them the palette you actually want. 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:
npx shadcn@latest initIt 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:
npx shadcn@latest add button input cardPaint it Lattice
The components shadcn just gave you don't hardcode a single color. They reference variables, --primary, --card, --border, and the init command filled those variables with a neutral gray scale, because it has no idea what your app is. You do. So open globals.css and overwrite the :root block with a palette:
:root { color-scheme: light; --background: oklch(0.968 0.012 263); --foreground: oklch(0.24 0.035 262); --card: oklch(0.995 0.004 263); --card-foreground: oklch(0.24 0.035 262); --popover: oklch(1 0 0); --popover-foreground: oklch(0.24 0.035 262); --primary: oklch(0.55 0.22 278); --primary-foreground: oklch(0.99 0.004 263); --secondary: oklch(0.935 0.022 263); --secondary-foreground: oklch(0.28 0.05 267); --muted: oklch(0.945 0.014 263); --muted-foreground: oklch(0.49 0.035 263); --accent: oklch(0.91 0.045 196); --accent-foreground: oklch(0.28 0.055 220); --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.88 0.025 263); --input: oklch(0.88 0.025 263); --ring: oklch(0.61 0.18 278);That's an excerpt; View full file on the block above shows the rest, including the matching .dark block and a body rule that lays two very soft radial gradients under the whole page, violet in one corner and teal in the other. oklch() is a color notation, lightness then chroma then hue, and --primary: oklch(0.55 0.22 278) is the indigo-violet you'll see on every button, avatar, and user bubble for the rest of this series. Change that one number, 278, and the whole app changes clothes.
The lines at the end of the file are less about taste and more about function, because the chat depends on them:
.chat-scrollbar { scrollbar-color: color-mix(in oklch, var(--muted-foreground) 32%, transparent) transparent; scrollbar-width: thin;}
@keyframes lattice-typing { 0%, 80%, 100% { opacity: 0.28; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); }}
.typing-dot { animation: lattice-typing 1.2s ease-in-out infinite;}
.typing-dot:nth-child(2) { animation-delay: 120ms; }.typing-dot:nth-child(3) { animation-delay: 240ms; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }}.chat-scrollbar turns the message list's scrollbar into a thin gray hairline instead of the operating system's default slab. lattice-typing is the animation behind the three bouncing dots that appear while the model thinks; each dot gets the same 1.2-second loop, offset by 120 milliseconds, which is what makes them look like a wave instead of a strobe. And the last block respects prefers-reduced-motion: a reader who has asked their operating system for less movement gets the dots without the bouncing.
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:
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:
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:
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 and a handful of icons by adding to your imports:
import { Bot, CircleAlert, Send, Sparkles, UserRound } from "lucide-react";import { Button } from "@/components/ui/button";import { Input } from "@/components/ui/input";import { Card } from "@/components/ui/card";You didn't install that first line's package: shadcn init did, because its own components draw their icons with lucide-react. It's already in your node_modules, so the whole icon set is sitting there waiting, and five of them do the visual work today.
Now replace that placeholder return with the product shell: a branded header, a generous scrolling canvas, and a message list with explicit accessibility labels. The small Brand, EmptyState, and ChatBubble helpers in the complete file keep this return block readable (a fourth one, ThinkingBubble, arrives later when the UI learns to wait); copy them from View full file on the snippet when you build along.
return ( <main className="relative min-h-dvh overflow-hidden p-3 sm:p-5 lg:p-7"> <Card className="mx-auto flex h-[calc(100dvh-1.5rem)] max-w-6xl flex-col gap-0 overflow-hidden rounded-[1.75rem] bg-card/95 py-0 shadow-2xl"> <header className="flex h-20 items-center justify-between border-b px-4 sm:px-7"> <Brand /> <div className="flex items-center gap-2 rounded-full border bg-background/70 px-3 py-1.5 text-xs font-medium text-muted-foreground shadow-sm"> <span className="size-2 rounded-full bg-emerald-500 ring-4 ring-emerald-500/15" /> <span className="hidden sm:inline">Local workspace</span> <span className="sm:hidden">Local</span> </div> </header> <div className="chat-scrollbar flex-1 overflow-y-auto px-4 py-5 sm:px-7" role="log" aria-live="polite"> {messages.length === 0 && !loading ? <EmptyState onPick={setInput} /> : ( <div className="mx-auto max-w-3xl space-y-6"> {messages.map((message, index) => ( <ChatBubble key={`${message.role}-${index}`} message={message} /> ))} </div> )} </div> </Card> </main>);Two things in there run ahead of you on purpose. The status pill is a hairline-bordered capsule with an emerald dot, and it swaps its own label at the sm breakpoint: "Local workspace" on a laptop, "Local" on a phone. And the guard says messages.length === 0 && !loading, which mentions a flag you don't have yet. It's one line, const [loading, setLoading] = useState(false);, and the fetch section a bit further down is where it earns its keep; add it next to the other two now if a red underline in your editor would bother you.
The line that matters is messages.map(...). It walks the messages array and turns each one into a bubble. Your messages sit on the right in a filled violet bubble under a small YOU label; Lattice answers on the left in a bordered card under LATTICE, and each side gets its own little avatar chip. The composite key, the role plus the index, gives React a stable handle on each row so it can update the list instead of redrawing it. Everything else is Tailwind spacing.
When messages is empty, none of that shows. EmptyState fills the canvas instead: a small eyebrow reading "Your first conversation", a headline asking "What can we figure out together?", and three starter prompts as clickable cards. Those cards are the reason EmptyState takes an onPick prop, and the reason you pass it setInput directly: clicking one drops the prompt straight into the composer, so a reader with no idea what to ask still has something to send.
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:
const [messages, setMessages] = useState<Message[]>([ { role: "user", content: "Is this thing on?" }, { role: "assistant", content: "Loud and clear. Your chat UI is ready." },]);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:
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, and two more hooks are coming before this part is over, so widen your React import once and be done with it:
Now the form itself. Add it inside the Card, below the messages <div>:
<form onSubmit={sendMessage} className="mx-auto flex max-w-3xl items-center gap-2 rounded-2xl border bg-background/85 p-2 shadow-lg shadow-slate-950/5 transition focus-within:border-primary/45 focus-within:ring-4 focus-within:ring-primary/10"> <label htmlFor="chat-message" className="sr-only"> Message Lattice </label> <Input id="chat-message" value={input} onChange={(e) => setInput(e.target.value)} placeholder="Message Lattice…" autoComplete="off" disabled={loading} className="h-11 flex-1 border-0 bg-transparent px-3 text-sm shadow-none focus-visible:ring-0" />The Input is controlled: its value is always input from state, and every keystroke fires onChange, which writes the new text back to state. It carries no border of its own, because the border belongs to the <form> around it, and focus-within on that form is what makes the whole capsule light up when the cursor lands inside. The <label> is sr-only, present for screen readers and invisible on screen, since the placeholder already tells sighted readers what the box is for.
Then the button, and the closing tag:
<Button type="submit" aria-label="Send message" disabled={loading || !input.trim()} className="h-11 rounded-xl px-4 shadow-md shadow-primary/20" > <span className="hidden sm:inline">Send</span> <Send className="size-4" aria-hidden="true" /> </Button></form><p className="mt-2 text-center text-[11px] text-muted-foreground"> Answers are generated by your local LangGraph workflow.</p>The button holds three small decisions. disabled={loading || !input.trim()} means it refuses both empty messages and double-sends. The word "Send" hides below the sm breakpoint, leaving the paper-plane icon alone on narrow screens, which is why the aria-label is there: a screen reader announces "Send message" either way, and aria-hidden on the icon keeps it from being read as a second thing.
Under the composer sits one line of fine print, "Answers are generated by your local LangGraph workflow", a quiet reminder that nothing typed here is going to somebody else's cloud. And the form's onSubmit runs your sendMessage whether the reader clicks the button or 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.
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:
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:
That reads the backend URL you saved in frontend/.env.local back in Part 1. The handler also leans on two new state variables, so widen your state block (if you already added loading back when you built the shell, this is only the second line):
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):
uvicorn app.main:app --reloadNow 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.
Ask it six questions and it walks off the screen
Keep talking to it and a small irritation shows up: once the conversation is taller than the canvas, new bubbles land below the fold and you have to scroll down yourself to read the answer you just asked for. Chat apps pin themselves to the bottom instead. Add these two pieces at the top of the component, next to your state:
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" });}, [messages, loading]);useRef holds a handle to a real DOM node, and unlike state, changing it never re-renders anything; it's a pointer you keep, not a value React watches. useEffect runs code after the browser has painted, and its second argument is the watch list: whenever messages or loading changes, scroll that node into view.
Then give the ref something to point at. Drop <div ref={bottomRef} /> in as the very last child inside the message list <div>, below the bubbles: an empty div with no styling and no content, whose entire job is to be the bottom of the conversation. React fills bottomRef.current with it on the first render, and from then on every new message quietly slides the list down to meet it.
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 sits there looking asleep; 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. One more helper and two small pieces of JSX close that gap.
First, the thinking indicator itself. It's the fourth helper, and it goes next to ChatBubble, above your Chat component:
function ThinkingBubble() { return ( <div className="flex items-end gap-3" aria-label="Lattice is thinking"> <div className="grid size-8 shrink-0 place-items-center rounded-xl bg-primary/10 text-primary"> <Bot className="size-4" aria-hidden="true" /> </div> <div className="flex items-center gap-1.5 rounded-[1.35rem] rounded-bl-md border bg-card px-4 py-4 shadow-sm"> <span className="typing-dot size-1.5 rounded-full bg-primary" /> <span className="typing-dot size-1.5 rounded-full bg-primary" /> <span className="typing-dot size-1.5 rounded-full bg-primary" /> </div> </div> );}It's the same shape as an assistant bubble, avatar chip and all, with three dots where the words go. Those typing-dot spans are the payoff for the CSS you wrote at the start of this part: the class attaches the lattice-typing keyframes, and the nth-child delays stagger the second and third dot so the row ripples. The aria-label says out loud what the animation says visually, because a screen reader can't see dots bounce. Now render it inside the messages <div>, right after the .map(...):
Then the safety net. The Input and the Button already carry disabled={loading}, so the composer freezes itself while a request is in flight; what's missing is the banner. Put it in the footer area, above the <form>:
{error ? ( <div role="alert" className="mx-auto mb-3 flex max-w-3xl items-start gap-2 rounded-xl border border-destructive/25 bg-destructive/10 px-3 py-2.5 text-sm text-destructive" > <CircleAlert className="mt-0.5 size-4 shrink-0" aria-hidden="true" /> <span>{error}</span> </div>) : null}role="alert" is the important attribute: it tells assistive tech to announce this the moment it appears, rather than waiting for the reader to wander over to it. The CircleAlert icon and the destructive tokens do the visual half of the same job.
The loading ? ... : null and error ? ... : null expressions are React's plain-JavaScript way of saying "render this only when that's true". When loading flips on, a compact three-dot assistant bubble appears and the composer freezes until the reply lands:
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:
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:
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 clientboundary in your bones: you know state and event handlers need it, and you've met the exact error you get when you forget. - A
fetchcall wiring the frontend to the backend, posting to/chatand 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
You add useState to a component and the dev server errors with 'This React hook only works in a Client Component.' What fixes it?
Why read the backend URL from process.env.NEXT_PUBLIC_API_BASE_URL instead of hardcoding http://localhost:8000?
Your frontend calls the backend from the browser and it works, no CORS error in the console. Why, given curl never needed CORS?
Inside sendMessage, why is the new list written as setMessages((prev) => [...prev, newMessage]) rather than messages.push(newMessage)?
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:
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 animated dots wait for the whole reply to land at once. Real chat apps let 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.
The complete, tested code for this part lives in part-04-nextjs-frontend in the companion repo. Code blocks with a GitHub icon link straight to the exact file; "View full file" shows the whole file in place.