Skip to content
AI Full-Stack Wiki

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.

Difficulty: Intermediate16 min readUpdated
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:

HookUse it forAI app example
useStateValues that change and re-render the UIThe message list, the input text, the "streaming" flag
useRefValues that persist without re-renderingThe current AbortController, a scroll anchor
useEffectSynchronizing with something outside ReactSubscribing to a stream, focusing the input
useActionStateForm submissions with pending and error stateSending 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.

Server and Client Components in one treeA page is a tree of Server Components that render on the server and can read data directly, with Client Component islands that hydrate in the browser for interactivity.BROWSERpage.tsxServer ComponentChatHistoryServer ComponentChatBox"use client"MessageServer ComponentComposerClient Component

Hover or tap a component to see what it does.

In the Next.js App Router, pages are Server Components by default; only the interactive parts, such as a chat composer, are Client Components that hydrate in the browser.
Show 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.

Rendering a Next.js App Router pageThe browser requests a page; the Next.js server queries the database while rendering Server Components, streams HTML and the RSC payload, and the browser hydrates Client Components.BrowserNext.jsserverPostgresGET /chat/42SELECT messages(in a ServerComponent)rowsHTML + RSCpayload,streamedhydrate ClientComponentsonly
Request flow for a Next.js page: the server renders Server Components with data, streams HTML plus the RSC payload, and the browser hydrates only the Client Components.
Show text description

The sequence of a first page load in the App Router. Participants, left to right: Browser, Next.js server, Postgres.

  1. Browser → Next.js server: GET /chat/42
  2. Next.js server → Postgres: SELECT messages (in a Server Component)
  3. Postgres → Next.js server: rows
  4. Next.js server → Browser: HTML + RSC payload, streamed (streamed)
  5. 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.

lib/notes.ts
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;
}
app/notes/actions.ts
"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 };
}
app/notes/note-form.tsx
"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>
  );
}
app/notes/page.tsx
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.

app/api/notes/route.ts
import { listNotes } from "../../../lib/notes";
 
export async function GET() {
  const notes = await listNotes();
  return Response.json({ notes: notes.map(({ id, title }) => ({ id, title })) });
}
NeedUse
Read data to render a pageAsync Server Component
A form or button that changes dataServer Action
Stream model output to the browserRoute Handler returning a streaming Response
A webhook, a mobile app, or a third party calls youRoute Handler
Interactivity: typing, toggles, live updatesClient 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 revalidatePath or revalidateTag for the data you changed.

Try it

Add a delete button

  1. Add a deleteNote(id) function to lib/notes.ts.
  2. Write a removeNote Server Action that validates the id with Zod and revalidates /notes.
  3. Render a small Client Component button per note that calls the action and shows a pending state.
  4. Explain in one sentence why the list itself can stay a Server Component.

Check your understanding

Which component in a chat page should usually be a Client Component?
Why must Server Actions validate input even when the form already uses `required`?
You need to stream a model's answer to the browser. Which tool fits best?

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.