React and Next.js for AI Apps: Components, Hooks, and Server Components
React and Next.js App Router concepts behind every AI interface: hooks, Server and Client Components, Server Actions, Route Handlers, and data fetching.
On this page· Components and hooks in five minutes
Why this matters
The interface is where an AI feature succeeds or fails: a streamed answer that appears in half a second feels fast; the same answer after a frozen eight-second spinner feels broken. React gives you the state model for streaming, cancelling, and retrying. The Next.js App Router adds something just as important for AI work: code that runs only on the server, next to your database and your API keys, without writing a separate backend.
Components and hooks in five minutes#
A React component is a function that returns UI from props and state. Hooks connect it to state and to the outside world. You will use four constantly:
| Hook | Use it for | AI app example |
|---|---|---|
useState | Values that change and re-render the UI | The message list, the input text, the "streaming" flag |
useRef | Values that persist without re-rendering | The current AbortController, a scroll anchor |
useEffect | Synchronizing with something outside React | Subscribing to a stream, focusing the input |
useActionState | Form submissions with pending and error state | Sending a prompt through a Server Action |
Two rules prevent most bugs: never mutate state (create a new array or object), and do not use useEffect to compute values you could compute during render.
Server Components and Client Components#
In the App Router, every component is a Server Component unless its file starts with "use client". Server Components run only on the server: they can be async, query the database directly, and read secrets, and they send zero JavaScript to the browser. Client Components are the interactive islands: they render once on the server for the initial HTML, then hydrate in the browser where they can hold state and handle events.
Hover or tap a component to see what it does.
Show text descriptionHide text description
Blue boxes render only on the server. Green boxes are Client Components: they render on the server for the first HTML and then hydrate in the browser, where they can hold state and handle events.
- page.tsx: Runs only on the server. Can be async, read the database, and use secrets. Ships no JavaScript to the browser.
- ChatHistory: Loads past messages from Postgres and renders them to HTML.
- ChatBox: Needs state and event handlers, so it is a Client Component. It is rendered on the server for the first HTML, then hydrated.
- Message: Pure rendering of one message; no interactivity needed.
- Composer: Textarea, submit, and streaming state live in the browser.
The practical rule: keep "use client" as far down the tree as possible. A chat page is mostly server-rendered history and layout; only the composer and the streaming message need to be Client Components.
Show text descriptionHide text description
The sequence of a first page load in the App Router. Participants, left to right: Browser, Next.js server, Postgres.
- Browser → Next.js server: GET /chat/42
- Next.js server → Postgres: SELECT messages (in a Server Component)
- Postgres → Next.js server: rows
- Next.js server → Browser: HTML + RSC payload, streamed (streamed)
- Browser → Browser: hydrate Client Components only
Props passed from a Server Component to a Client Component must be serializable (plain data, not functions or class instances), because they travel to the browser inside the RSC payload.
A complete feature: list and create#
This small notes feature uses every piece: an async Server Component that reads data, a Server Action that validates and writes, and a Client Component form with pending and error states. The storage module stands in for a real database.
export interface Note {
id: string;
title: string;
createdAt: Date;
}
// In-memory stand-in for a database table.
const notes: Note[] = [{ id: "1", title: "Read about Server Components", createdAt: new Date() }];
export async function listNotes(): Promise<Note[]> {
return [...notes].reverse();
}
export async function addNote(title: string): Promise<Note> {
const note = { id: crypto.randomUUID(), title, createdAt: new Date() };
notes.push(note);
return note;
}"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { addNote } from "../../lib/notes";
const NoteInput = z.object({
title: z.string().trim().min(1, "Write something first").max(120, "Keep it under 120 characters"),
});
export type FormState = { error: string | null };
// Server Actions are public HTTP endpoints: validate input (and check auth) inside every one.
export async function createNote(_prev: FormState, formData: FormData): Promise<FormState> {
const parsed = NoteInput.safeParse({ title: formData.get("title") });
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Invalid input" };
await addNote(parsed.data.title);
revalidatePath("/notes"); // re-render the page with the new note
return { error: null };
}"use client";
import { useActionState } from "react";
import { createNote, type FormState } from "./actions";
const initialState: FormState = { error: null };
export function NoteForm() {
const [state, formAction, pending] = useActionState(createNote, initialState);
return (
<form action={formAction}>
<label htmlFor="title">New note</label>
<input id="title" name="title" maxLength={120} required />
<button type="submit" disabled={pending}>
{pending ? "Saving…" : "Add note"}
</button>
{state.error ? <p role="alert">{state.error}</p> : null}
</form>
);
}import { listNotes } from "../../lib/notes";
import { NoteForm } from "./note-form";
// An async Server Component: it reads data directly, no API route or useEffect needed.
export default async function NotesPage() {
const notes = await listNotes();
return (
<main>
<h1>Notes</h1>
<NoteForm />
<ul>
{notes.map((note) => (
<li key={note.id}>
{note.title} <time dateTime={note.createdAt.toISOString()}>{note.createdAt.toDateString()}</time>
</li>
))}
</ul>
</main>
);
}The form works before JavaScript loads (it is a real <form> posting to the action), then upgrades to show the pending state once hydrated. That progressive enhancement comes free with Server Actions.
Route Handlers: when you need a real API#
Server Components and Server Actions cover most page data and mutations. Use a Route Handler (app/api/.../route.ts) when something other than your own React UI calls it, or when you need full control of the response, which is exactly the case for streaming model output.
import { listNotes } from "../../../lib/notes";
export async function GET() {
const notes = await listNotes();
return Response.json({ notes: notes.map(({ id, title }) => ({ id, title })) });
}| Need | Use |
|---|---|
| Read data to render a page | Async Server Component |
| A form or button that changes data | Server Action |
| Stream model output to the browser | Route Handler returning a streaming Response |
| A webhook, a mobile app, or a third party calls you | Route Handler |
| Interactivity: typing, toggles, live updates | Client Component |
Common mistakes#
"use client"at the top of the tree. Marking a layout or page as a Client Component turns everything below it into client code and ships it to the browser, including code you meant to keep server-only.- Fetching initial data in
useEffect. It adds a loading flash and an extra round trip. Fetch in a Server Component and pass the data down. - Leaking secrets into Client Components. Any variable read in a Client Component ends up in the bundle. Model API keys belong in server-only code; Next.js only exposes variables prefixed with
NEXT_PUBLIC_to the browser. - Passing functions or class instances as props to Client Components. Only serializable data crosses the server-to-client boundary; pass Server Actions explicitly instead of arbitrary callbacks.
- Forgetting to revalidate after a mutation. If the page does not update after a Server Action, call
revalidatePathorrevalidateTagfor the data you changed.
Try it
Add a delete button
- Add a
deleteNote(id)function tolib/notes.ts. - Write a
removeNoteServer Action that validates the id with Zod and revalidates/notes. - Render a small Client Component button per note that calls the action and shows a pending state.
- Explain in one sentence why the list itself can stay a Server Component.
Check your understanding
Key takeaways
- Components default to Server Components: async, close to the data, zero client JavaScript. Add
"use client"only for interactive leaves. - Server Actions handle mutations with built-in pending state and progressive enhancement, but they are public endpoints: validate and authorize inside them.
- Use Route Handlers for streaming responses and for clients other than your own UI.
- Keep secrets in server-only code; only
NEXT_PUBLIC_variables reach the browser.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Node.js Deep Dive: Event Loop, Async I/O, Streams, and ESMHow Node.js runs your code and why it suits AI apps: the event loop, microtasks, blocking, streams with backpressure, and ES modules, with runnable examples.
- Streaming LLM Responses to a React UI with Next.js and SSEStream Claude responses token by token from a Next.js Route Handler to a React client over Server-Sent Events, with stop, retry, error handling, and accessible updates.