All writing

Next.js 15 App Router: 3 Production Architecture Patterns for AI Apps

Three patterns I use for AI web apps with Next.js 15, React 19 Server Components, streaming UI, and strict TypeScript.

What is Next.js 15 App Router Architecture?

Next.js 15 App Router Architecture is a server-first web development model combining React 19 Server Components, asynchronous request lifecycle handling, and streaming HTTP responses. In production AI applications like UET GPT, this architecture moves data-fetching and rendering work to the server. The server sends rendered HTML before client JavaScript runs, and components that only run on the server keep their data access out of the browser bundle.


3 Production Patterns for Next.js 15

1. Server Components by Default

Keep data fetching, markdown processing, and schema generation on the server. A server component does not ship its own component JavaScript to the browser. Export client components ("use client") only for leaf interactive elements like theme toggles, search modals, and copy buttons. Those islands still ship the JavaScript they need.

2. Streaming AI UI with Vercel AI SDK

Instead of waiting for the whole LLM response before sending anything, stream tokens from a route handler. The UI reads the stream with useChat and renders each part as it arrives.

app/api/chat/route.ts
import { createGroq } from "@ai-sdk/groq";
import { convertToModelMessages, streamText, type UIMessage } from "ai";

const groq = createGroq({ apiKey: process.env.GROQ_API_KEY });

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: groq(process.env.GROQ_MODEL ?? "openai/gpt-oss-120b"),
    system: "You are an expert full-stack systems engineering assistant.",
    messages: await convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}

This illustrative example uses the portfolio's GROQ_MODEL setting and default. UET GPT is a separate application: its answer model is gpt-oss-20b on Groq, with Gemini 2.5 Flash fallback. Its model tasks are defined in a provider registry.

The client side uses the v7 transport and message shape:

app/assistant/page.tsx
"use client";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { type FormEvent } from "react";

export default function AssistantPage() {
  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({ api: "/api/chat" }),
  });

  const busy = status === "submitted" || status === "streaming";
  const onSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const text = String(new FormData(e.currentTarget).get("message") ?? "").trim();
    if (text) void sendMessage({ text });
  };

  return (
    <div>
      {messages.map((message) => (
        <p key={message.id}>
          {message.role}: {message.parts.map((part) => (part.type === "text" ? part.text : "")).join("")}
        </p>
      ))}
      <form onSubmit={onSubmit}>
        <input name="message" /> <button type="submit" disabled={busy}>Send</button>
      </form>
    </div>
  );
}

useChat must run inside a React component, so the sample exports AssistantPage as the page component. messages holds UIMessage objects whose content lives in parts; a text part is { type: "text", text: string } and its state shows whether the part is still streaming.

3. Server Actions with Zod Validation

A Server Action is a server function the form can call directly. The form submits a FormData object, the action validates it with Zod on the server, and the client gets a typed result back. No API route, no hand-written fetch, and no trust in client-side validation.

app/contact/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";

const briefSchema = z.object({
  email: z.string().email(),
  message: z.string().min(20).max(2000),
});

export type BriefState = {
  ok: boolean;
  errors?: Record<string, string[] | undefined>;
};

async function saveBrief(_data: z.infer<typeof briefSchema>) {
  // Replace with your database or email call.
}

export async function submitBrief(
  _previous: BriefState,
  formData: FormData
): Promise<BriefState> {
  const parsed = briefSchema.safeParse({
    email: formData.get("email"),
    message: formData.get("message"),
  });

  if (!parsed.success) {
    return { ok: false, errors: parsed.error.flatten().fieldErrors };
  }

  await saveBrief(parsed.data);
  revalidatePath("/contact");
  return { ok: true };
}
app/contact/BriefForm.tsx
"use client";

import { useActionState } from "react";
import { submitBrief, type BriefState } from "./actions";

const initialState: BriefState = { ok: false };

export function BriefForm() {
  const [state, formAction, pending] = useActionState(submitBrief, initialState);

  return (
    <form action={formAction}>
      <label>
        Email
        <input name="email" type="email" required />
      </label>
      {state.errors?.email?.map((error) => (
        <p key={error} role="alert">
          {error}
        </p>
      ))}

      <label>
        Brief
        <textarea name="message" minLength={20} maxLength={2000} required />
      </label>
      {state.errors?.message?.map((error) => (
        <p key={error} role="alert">
          {error}
        </p>
      ))}

      <button type="submit" disabled={pending}>
        {pending ? "Sending..." : "Send brief"}
      </button>
    </form>
  );
}

Three details make this work in Next.js 15 and React 19:

  • The action signature is (previousState, formData), which is what useActionState expects. A plain form action that only takes FormData still works, but then you lose the returned state.
  • useActionState returns [state, formAction, pending]. pending is the submit state, so the button does not need its own useState.
  • parsed.error.flatten().fieldErrors keeps the field names from the schema. The form renders them next to the inputs instead of showing one generic error.

The action runs on the server even though the component is a client component. Next.js handles the POST, the serialization, and the response. Validation is repeated in the browser only as a convenience through required, minLength and maxLength; the Zod check is the one that counts.

When not to use this

  • Highly interactive dashboards. If most of the screen is filters, charts and local state, a client-heavy app is simpler. Server Components do not remove the need for client state; they just move the initial render.
  • Offline-first apps. A server-first render assumes a server is reachable. If the app has to work from a local cache with no network, plan the client data layer first.
  • Teams without a Node runtime. Server Actions and streaming responses need a server that runs Next.js. A static export on a CDN-only host will not run them.

Architectural Comparison: Client-Heavy vs Server-First Next.js 15

DimensionLegacy Client-Heavy SPANext.js 15 Server-First RSC
Initial JS Bundle SizeShips the app's JS upfrontStatic content renders on the server; interactive islands ship their own component JS
First Contentful Paint (FCP)Waits on client-side data fetch + renderServer sends HTML before hydration; server-fetched data needs no client request waterfall
Streaming AI responsesStreaming is an API-client transport choice: a client-heavy SPA can consume the same token streamRenders tokens as they arrive by reading the stream with `useChat`, a transport any client architecture can also use
Search & AI DiscoverabilityRequires JS execution to indexCrawlers can read server-rendered HTML without executing JS
API Secret SecurityDanger of leaking tokens to browserKept on the server: `server-only` code never ships to the client bundle

If you are building an AI product and want these server/client boundaries set up rather than copied from a tutorial, that is the grounded RAG assistant service.


Production TypeScript Strictness

Enforce strict TypeScript boundaries across all layout, page, and API route parameters:

app/blog/[slug]/page.tsx
interface PageProps {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

export default async function BlogPostPage({ params }: PageProps) {
  // In Next.js 15, params and searchParams are Promises
  const { slug } = await params;
  const post = getPostBySlug(slug);
  if (!post) notFound();
  return <article>{/* Render Post */}</article>;
}

Frequently Asked Questions

Why did Next.js 15 make params and searchParams asynchronous?

Making route parameters asynchronous lets Next.js start rendering the layout shell before the dynamic segment resolves, instead of waiting for it. The response can begin streaming before the slow part finishes.

How does React 19 Server Components improve SEO and GEO?

Server Components render plain, semantic HTML directly on the server without requiring client JavaScript hydration. Search engines and AI crawlers (like GPTBot and ClaudeBot) can parse the page content without running client code.

What is the best way to handle streaming AI errors in Next.js 15?

Use onError inside streamText to log provider failures, and show the client a retry action when the stream errors.

Need this in your product?

I build the assistant and the web app around it: server-rendered pages, streaming chat, and the API routes in between.

AI assistants and RAG

Building something like this, or stuck on one that keeps failing?

Send a brief →