GUIDE

Next.js: The App Router in Production (2026)

A deep technical guide to building with Next.js 16 and the App Router: Server and Client Components, Server Actions, streaming with Suspense, data fetching and Cache Components, route handlers, proxy middleware, rendering strategies (SSR/SSG/ISR/PPR), and shipping streaming AI apps with the Vercel AI SDK.

Next.js 16App RouterServer ComponentsServer ActionsTurbopackCache Componentsuse cachePPRRoute HandlersStreamingReact 19Vercel AI SDK

Table of Contents

  1. 1. App Router vs Pages Router
  2. 2. Server and Client Components
  3. 3. Data Fetching and Caching
  4. 4. Streaming, Suspense, and use()
  5. 5. Server Actions and Mutations
  6. 6. Route Handlers and Middleware
  7. 7. Rendering Strategies: SSR, SSG, ISR, PPR
  8. 8. Building AI Apps with the Vercel AI SDK
  9. 9. Deployment and Performance

1. App Router vs Pages Router

Two Routers, One Framework

Next.js ships two routers. The app/ directory (App Router) is the default and recommended choice for every new project: it is built on React Server Components, and every feature added over the last three major releases -- Server Actions, Cache Components, the after() API, async params, the Next.js DevTools MCP -- is App Router only. The older pages/ directory (Pages Router) is still fully supported and can coexist in the same project, which lets you migrate route by route. In the App Router a folder defines a route segment and special files give each segment its behavior.

app/
├── layout.tsx          # Root layout (replaces _app + _document)
├── page.tsx            # Route: /
├── loading.tsx         # Instant loading UI (auto Suspense boundary)
├── error.tsx           # Error boundary ("use client")
├── not-found.tsx       # 404 UI
├── dashboard/
│   ├── layout.tsx      # Nested, persistent layout for /dashboard/*
│   └── page.tsx        # Route: /dashboard
├── blog/
│   └── [slug]/
│       └── page.tsx    # Dynamic route: /blog/:slug
└── api/
    └── chat/
        └── route.ts    # Route Handler (Request → Response)

File Conventions and Routing Primitives

Routes are described by files, not a config object. page.tsx makes a segment publicly routable; layout.tsx wraps it and its children and preserves state across navigation. Beyond static segments, the App Router supports [id] dynamic segments, [...slug] catch-alls, (group) route groups (organize files without adding a URL segment), @slot parallel routes, and (.) intercepting routes for modals. A root layout.tsx is required and must render the <html> and <body> tags.

// app/layout.tsx - the required root layout (a Server Component)
import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  title: { default: 'Acme', template: '%s · Acme' },
  description: 'Built with the Next.js App Router',
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>
        <nav>{/* persists across navigation, never re-mounts */}</nav>
        <main>{children}</main>
      </body>
    </html>
  );
}

Migrating from the Pages Router

There is no direct one-to-one replacement, but the mapping is clean: getServerSideProps and getStaticProps collapse into an async Server Component that simply awaits its data; _app/_document become the root layout.tsx; pages/api/* handlers become route.ts Route Handlers; and next/head is replaced by the metadata export. Because both routers run side by side, you can move one route at a time and ship incrementally.

// BEFORE (Pages Router): pages/products/[id].tsx
export async function getServerSideProps({ params }) {
  const product = await db.product.findUnique({ where: { id: params.id } });
  return { props: { product } };
}
export default function ProductPage({ product }) {
  return <h1>{product.name}</h1>;
}

// AFTER (App Router): app/products/[id]/page.tsx
// In Next.js 16, params is a Promise and must be awaited.
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } });
  return <h1>{product.name}</h1>;
}

2. Server and Client Components

The Server-First Model

In the App Router every component is a React Server Component (RSC) by default. Server Components render on the server, never ship their code to the browser, and can be async. You opt a subtree into the browser with the "use client" directive when you need interactivity -- state, effects, event handlers, or browser-only APIs. The architectural goal is to keep the interactive "islands" small and push data fetching and heavy dependencies to the server, so the client bundle stays lean.

Server Components

Server Components run only on the server. They can read the database, the filesystem, and secrets directly without exposing them, and they contribute zero JavaScript to the client. They cannot use useState, useEffect, or event handlers. Because they can be async, data fetching is just await in the component body -- there is no useEffect + loading-state dance.

// app/products/page.tsx - a Server Component (default, no directive)
import { db } from '@/lib/db';
import { AddToCart } from './add-to-cart'; // a Client Component

// Runs on the server only: the DB client and secrets never reach the browser.
export default async function ProductsPage() {
  const products = await db.product.findMany({ orderBy: { name: 'asc' } });

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          <h2>{p.name}</h2>
          {/* Interactivity is isolated to a small Client Component */}
          <AddToCart productId={p.id} />
        </li>
      ))}
    </ul>
  );
}

Client Components

A file that begins with "use client" (and everything it imports) is bundled for the browser and hydrated. Use Client Components for anything stateful or interactive: forms, dropdowns, charts, anything using useState/useEffect, or libraries that touch window. Client Components still render on the server for the initial HTML -- "client" means they also run in the browser, not that they skip SSR.

// app/products/add-to-cart.tsx
'use client';

import { useState } from 'react';

export function AddToCart({ productId }: { productId: string }) {
  const [pending, setPending] = useState(false);

  async function add() {
    setPending(true);
    await fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify({ productId }),
    });
    setPending(false);
  }

  return (
    <button onClick={add} disabled={pending}>
      {pending ? 'Adding…' : 'Add to cart'}
    </button>
  );
}

Composition: Passing Server into Client

You cannot import a Server Component into a Client Component, but you can pass one as a children (or any prop) from a Server Component. This "slot" pattern lets a small interactive Client Component wrap server-rendered content without pulling that content -- and its dependencies -- into the client bundle. Props that cross the boundary must be serializable (no functions, class instances, or Dates that aren't plain).

// Client Component that provides interactive chrome around server content
'use client';
import { useState } from 'react';

export function Collapsible({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <section>
      <button onClick={() => setOpen((o) => !o)}>{open ? 'Hide' : 'Show'}</button>
      {open && children}
    </section>
  );
}

// Server Component composes a Server Component *through* the client one
export default async function Page() {
  return (
    <Collapsible>
      {/* <ServerStats/> stays on the server; only Collapsible ships JS */}
      <ServerStats />
    </Collapsible>
  );
}

The "use client" Boundary

The directive marks an entry point, not a single file: once a module is a Client Component, every module it imports also becomes part of the client graph. Place "use client" as far down the tree as possible so the boundary is small. Shared state across many client islands is handled with a Context provider placed in a Client Component, or a store like Zustand/Jotai -- but reach for those only after the server-first defaults stop fitting.

3. Data Fetching and Caching

In the App Router you fetch data where you use it: inside async Server Components. Next.js 16 adopts the Cache Components model -- data is dynamic (uncached) by default, and you opt specific work into the cache with the "use cache" directive. This is the inverse of the older heuristics where fetch was cached unless you said otherwise, and it makes caching an explicit, reviewable decision rather than a surprise.

Fetching in Server Components

Fetch with await -- either the native fetch or a database/ORM client. Within a single render pass, Next.js automatically memoizes identical fetch calls (same URL and options), so multiple components can request the same data without duplicate network round-trips. Fetches run in parallel when you kick them off before awaiting, which avoids request waterfalls.

// app/dashboard/page.tsx - parallel fetching, no waterfall
async function getUser(id: string) {
  const res = await fetch(`https://api.example.com/users/${id}`);
  if (!res.ok) throw new Error('Failed to load user');
  return res.json();
}

export default async function Dashboard() {
  // Start both requests, THEN await → they run concurrently
  const userPromise = getUser('u_123');
  const statsPromise = fetch('https://api.example.com/stats').then((r) => r.json());

  const [user, stats] = await Promise.all([userPromise, statsPromise]);

  return (
    <main>
      <h1>Welcome, {user.name}</h1>
      <p>{stats.activeUsers} active users</p>
    </main>
  );
}

Caching with "use cache"

Add "use cache" to a file, a function, or a component to cache its output. Control freshness with cacheLife() (named profiles like 'hours' or custom stale/revalidate/expire windows) and attach cacheTag() so you can purge on demand with revalidateTag() from a Server Action or Route Handler. This unifies the old force-cache / revalidate / no-store knobs into one directive-based API.

// app/lib/products.ts - a cached data function
import { cacheLife, cacheTag } from 'next/cache';

export async function getFeaturedProducts() {
  'use cache';
  cacheLife('hours');        // stale/revalidate/expire preset
  cacheTag('products');      // enables targeted invalidation

  const res = await fetch('https://api.example.com/products/featured');
  return res.json();
}

// app/actions.ts - invalidate the tag after a mutation
'use server';
import { revalidateTag } from 'next/cache';

export async function publishProduct(data: FormData) {
  await db.product.create({ data: { name: String(data.get('name')) } });
  revalidateTag('products'); // next read of getFeaturedProducts() refetches
}

4. Streaming, Suspense, and use()

Streaming with Suspense

Next.js renders Server Components as a stream. Wrapping a slow part of the tree in <Suspense> lets the framework send the surrounding shell immediately and flush the slow region's HTML later, as its data resolves. Nested boundaries produce fine-grained loading states: the page frame and fast content paint instantly while independent sections stream in on their own timelines, improving time-to-first-byte and perceived speed.

// app/dashboard/page.tsx - independent streaming regions
import { Suspense } from 'react';

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>      {/* shell flushes immediately */}
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />               {/* async Server Component, streams when ready */}
      </Suspense>
      <div className="grid">
        <Suspense fallback={<FeedSkeleton />}>
          <ActivityFeed />      {/* streams independently */}
        </Suspense>
        <Suspense fallback={<OrdersSkeleton />}>
          <RecentOrders />      {/* streams independently */}
        </Suspense>
      </div>
    </main>
  );
}

loading.js and Instant Loading States

A loading.tsx file next to a page.tsx is sugar for wrapping that route segment in a Suspense boundary. Next.js shows it the instant a user navigates, before the segment's data has loaded, and swaps in the real UI when the async Server Component resolves. Combined with nested layouts, this gives you route-level skeletons for free without writing a single Suspense boundary by hand.

// app/products/loading.tsx - shown instantly on navigation
export default function Loading() {
  return <ProductGridSkeleton />;
}

// app/products/page.tsx - the slow async page it wraps
export default async function ProductsPage() {
  const products = await db.product.findMany(); // segment "suspends" here
  return <ProductGrid products={products} />;
}

Streaming Patterns and Pitfalls

Three patterns cover most cases: (1) put a <Suspense> around each slow, independent region so one slow query never blocks the whole page; (2) start fetches early and pass the promise (not the awaited value) into a child so the parent shell can render while the child streams; and (3) keep the shared shell in the layout so it never re-mounts. The main pitfall is awaiting everything at the top of the page -- that collapses streaming back into a single blocking render.

A practical rule from production Next.js apps: give every independently-fetched section its own Suspense boundary with a real skeleton. On a content-heavy dashboard, moving from a single top-level await to per-widget boundaries dropped time-to-first-byte from ~1.4s to under 200ms because the navigation shell and above-the-fold widgets no longer waited on the slowest analytics query.

The use() Hook: Unwrapping Promises on the Client

To stream data into an interactive Client Component, start the fetch in a Server Component and pass the unawaited promise down as a prop. The Client Component reads it with React's use() hook, which suspends until the promise resolves. Wrapped in a <Suspense> boundary, the parent renders immediately and the client island fills in when the data arrives -- no useEffect and no client-side loading state machine.

// Server Component: start the fetch, pass the promise (do NOT await it here)
import { Suspense } from 'react';
import { Comments } from './comments';

export default function PostPage({ postId }: { postId: string }) {
  const commentsPromise = fetch(`/api/posts/${postId}/comments`).then((r) => r.json());
  return (
    <article>
      <h1>Post</h1>
      <Suspense fallback={<p>Loading comments…</p>}>
        <Comments commentsPromise={commentsPromise} />
      </Suspense>
    </article>
  );
}

// Client Component: unwrap the streamed promise with use()
'use client';
import { use } from 'react';

export function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  const comments = use(commentsPromise); // suspends until resolved
  return <ul>{comments.map((c) => <li key={c.id}>{c.body}</li>)}</ul>;
}

after(): Work After the Response

The after() API (stable in Next.js 16) schedules work to run after the response has finished streaming to the user. It is the right place for logging, analytics, cache warming, or sending a notification -- side effects that should not delay time-to-first-byte. The callback runs even though the response is already flushed, so the user never waits on it. Use it in Server Components, Route Handlers, and Server Actions.

// app/product/[id]/page.tsx - log a view without blocking the render
import { after } from 'next/server';
import { logAnalytics } from '@/lib/analytics';

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } });

  // Runs after the HTML is streamed to the client — off the critical path.
  after(() => {
    logAnalytics({ event: 'product_view', productId: id });
  });

  return <h1>{product.name}</h1>;
}

Error Handling During Streaming

An error.tsx file wraps its route segment in a React error boundary. If a Server Component throws -- including while streaming -- Next.js renders this Client Component fallback instead of crashing the whole page, and gives you a reset() function to retry the segment. Use global-error.tsx to catch failures in the root layout, and notFound() plus not-found.tsx for the expected "missing resource" case.

// app/dashboard/error.tsx - error boundaries must be Client Components
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div role="alert">
      <h2>Something went wrong loading the dashboard.</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}
Together, streaming + Suspense, use(), after(), and error.tsx replace most of the client-side data-loading machinery earlier React apps needed. Fetch on the server, stream to the client, unwrap with use(), push side effects into after(), and let segment-level error boundaries contain failures. Adopt these from the start on new projects; on existing apps, introduce them one route at a time.

5. Server Actions and Mutations

Server Actions are async functions marked with the "use server" directive that run on the server but can be called directly from client or server components. They replace hand-written REST/GraphQL endpoints for mutations: you pass one straight to a <form action={...}>, and it works even before JavaScript loads (progressive enhancement). Pair them with useActionState for pending/error/result state and useFormStatus for a submit button's pending flag.

Defining and Calling a Server Action

// app/actions.ts - a Server Action with Zod validation
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

const schema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
});

export type State = { error?: string };

export async function createContact(_prev: State, formData: FormData): Promise<State> {
  const parsed = schema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return { error: parsed.error.issues[0].message };
  }
  await db.contact.create({ data: parsed.data });
  revalidatePath('/contacts');   // refresh the cached list
  redirect('/contacts');         // navigate on success
}
// app/contacts/new/form.tsx - wire the action into a form
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createContact, type State } from '@/app/actions';

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Saving…' : 'Save contact'}</button>;
}

export function ContactForm() {
  const [state, formAction] = useActionState<State, FormData>(createContact, {});
  return (
    <form action={formAction}>
      <input name="name" required />
      <input name="email" type="email" required />
      {state.error && <p role="alert">{state.error}</p>}
      <SubmitButton />
    </form>
  );
}

Revalidation and Optimistic UI

After a mutation, call revalidatePath() or revalidateTag() so the affected cached data refetches on the next render -- no manual cache juggling. For instant feedback, wrap the current data in useOptimistic and apply the change locally the moment the user acts; React reconciles with the server result (and reverts automatically if the action throws). Server Actions can also be invoked imperatively -- startTransition(() => myAction(input)) -- when there is no form to submit.

// Optimistic list updates driven by a Server Action
'use client';
import { useOptimistic, startTransition } from 'react';
import { toggleDone } from '@/app/actions';

export function Todos({ todos }: { todos: Todo[] }) {
  const [optimistic, setOptimistic] = useOptimistic(
    todos,
    (state, id: string) => state.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
  );

  return optimistic.map((t) => (
    <label key={t.id}>
      <input
        type="checkbox"
        checked={t.done}
        onChange={() => startTransition(() => {
          setOptimistic(t.id);   // instant UI update
          toggleDone(t.id);      // Server Action; revalidates on the server
        })}
      />
      {t.title}
    </label>
  ));
}
Treat Server Actions as trust boundaries, not just convenience. They are publicly reachable POST endpoints, so validate every input (Zod is a good fit), authenticate and authorize inside the action itself, and never rely on the client to enforce rules. The ergonomics are excellent, but "runs on the server" does not mean "only your UI can call it."

6. Route Handlers and Middleware

When you need an HTTP endpoint rather than a page -- a webhook, a public API, an AI streaming endpoint -- create a route.ts file. Route Handlers use the Web platform Request and Response objects and export functions named after HTTP verbs (GET, POST, PUT, DELETE, etc.). They are the App Router replacement for pages/api. Note that a segment cannot have both a page.tsx and a route.ts.

Route Handlers

// app/api/products/[id]/route.ts
import { NextResponse } from 'next/server';

// GET /api/products/:id  — params is a Promise in Next.js 16
export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } });
  if (!product) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 });
  }
  return NextResponse.json(product);
}

// POST /api/products
export async function POST(req: Request) {
  const body = await req.json();
  const created = await db.product.create({ data: body });
  return NextResponse.json(created, { status: 201 });
}

Middleware: proxy.ts

Cross-cutting request logic -- auth gates, redirects, rewrites, header injection, A/B splits -- lives in a single middleware file. As of Next.js 16 this file is named proxy.ts (the old middleware.ts name is deprecated) to clarify that it runs at the network boundary, before a route is matched. It runs on every request that its matcher config selects, and returns a NextResponse to continue, rewrite, or redirect. Keep it fast and edge-friendly; do heavyweight checks inside the route instead.

// proxy.ts (project root) - runs before matched routes
import { NextResponse, type NextRequest } from 'next/server';

export function proxy(request: NextRequest) {
  const token = request.cookies.get('session')?.value;

  // Redirect unauthenticated users away from the dashboard
  if (request.nextUrl.pathname.startsWith('/dashboard') && !token) {
    const url = request.nextUrl.clone();
    url.pathname = '/login';
    url.searchParams.set('from', request.nextUrl.pathname);
    return NextResponse.redirect(url);
  }
  return NextResponse.next();
}

// Only run on the paths that need it
export const config = {
  matcher: ['/dashboard/:path*', '/account/:path*'],
};

7. Rendering Strategies: SSR, SSG, ISR, PPR

Static and Dynamic Rendering

A route is rendered statically (at build time, like SSG) unless it uses a dynamic signal -- reading cookies(), headers(), searchParams, or uncached data -- in which case it becomes dynamic (rendered per request, like SSR). For a dynamic route with a known set of paths, generateStaticParams pre-renders each variant at build time, turning a [slug] route into a set of static pages.

// app/blog/[slug]/page.tsx - statically generate one page per post
export async function generateStaticParams() {
  const posts = await db.post.findMany({ select: { slug: true } });
  return posts.map((p) => ({ slug: p.slug })); // pre-rendered at build time
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await db.post.findUnique({ where: { slug } });
  return <article><h1>{post.title}</h1>{/* … */}</article>;
}

Incremental Static Regeneration (ISR)

ISR serves a static page and refreshes it in the background on a schedule, so you get static performance with data that stays reasonably fresh. In the Cache Components model you express this with "use cache" plus cacheLife() (or a per-fetch revalidate). On-demand ISR uses revalidateTag()/revalidatePath() to purge exactly when the underlying data changes -- e.g. from a CMS webhook -- instead of waiting for a timer.

// Time-based revalidation with the Cache Components API
import { cacheLife, cacheTag } from 'next/cache';

async function getHomeFeed() {
  'use cache';
  cacheLife('minutes'); // serve cached, revalidate in the background
  cacheTag('home-feed');
  return db.post.findMany({ take: 20, orderBy: { publishedAt: 'desc' } });
}

// On-demand: a webhook Route Handler purges the tag when content changes
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
export async function POST() {
  revalidateTag('home-feed');
  return Response.json({ revalidated: true });
}

Partial Prerendering (PPR)

PPR breaks the static-vs-dynamic either/or at the route level: Next.js prerenders a static HTML shell (served instantly from the edge/CDN) and streams the dynamic "holes" -- anything wrapped in <Suspense> -- into it as their data resolves. With Cache Components enabled in Next.js 16, PPR is the default behavior of the App Router; the old experimental.ppr flag and experimental_ppr segment config have been removed. You get the SEO and TTFB of static with the freshness of dynamic in a single route.

Choosing a Strategy

Marketing and docs pages: fully static (SSG). Dashboards and account pages: dynamic per request (SSR). High-traffic content that changes occasionally: ISR with tag-based purging. Most real pages are a mix -- a static shell (nav, hero, layout) with a few dynamic, per-user widgets -- which is exactly what PPR delivers. Rather than picking one label for the whole app, cache what is stable with "use cache" and wrap what is dynamic in <Suspense>.

// One PPR route: static shell + a dynamic, per-user hole
import { Suspense } from 'react';
import { cookies } from 'next/headers';

async function Greeting() {
  const session = (await cookies()).get('session')?.value; // dynamic
  const user = await getUser(session);
  return <p>Welcome back, {user.name}</p>;
}

export default function Page() {
  return (
    <main>
      <Hero />                    {/* static shell, prerendered */}
      <Suspense fallback={<p>Loading…</p>}>
        <Greeting />              {/* dynamic hole, streamed in */}
      </Suspense>
    </main>
  );
}

8. Building AI Apps with the Vercel AI SDK

Streaming Chat: streamText + useChat

The Vercel AI SDK is the standard way to build AI features on Next.js. The server half is a Route Handler that calls streamText with a model and the conversation, then returns result.toUIMessageStreamResponse(). The client half is the useChat hook, which manages the message list, streams tokens in as they arrive, and exposes sendMessage and a status. The model is swappable behind a provider package (OpenAI, Anthropic, Google, and others) without changing your UI.

// app/api/chat/route.ts - streaming chat endpoint
import { openai } from '@ai-sdk/openai';
import { streamText, convertToModelMessages, type UIMessage } from 'ai';

export const maxDuration = 30; // allow long streams

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

  const result = streamText({
    model: openai('gpt-5.1'),
    system: 'You are a concise, helpful assistant.',
    messages: convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}
// app/chat/page.tsx - the client UI
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';

export default function Chat() {
  const { messages, sendMessage, status } = useChat();
  const [input, setInput] = useState('');

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.role}:</strong>
          {m.parts.map((part, i) =>
            part.type === 'text' ? <span key={i}>{part.text}</span> : null,
          )}
        </div>
      ))}
      <form onSubmit={(e) => { e.preventDefault(); sendMessage({ text: input }); setInput(''); }}>
        <input value={input} onChange={(e) => setInput(e.target.value)}
               disabled={status !== 'ready'} placeholder="Ask something…" />
      </form>
    </div>
  );
}

RAG: Retrieval-Augmented Generation

A RAG endpoint grounds the model in your own data: embed the user's question, retrieve the most similar chunks from a vector store, inject them into the system prompt as context, and stream the answer. On Next.js this is one Route Handler -- embed for the query vector, a vector-DB similarity search, then streamText with the retrieved context. Because it runs on the server, your embedding keys and database stay private.

// app/api/rag/route.ts - retrieval-augmented streaming answer
import { openai } from '@ai-sdk/openai';
import { embed, streamText, convertToModelMessages, type UIMessage } from 'ai';

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();
  const question = messages.at(-1)!.parts.find((p) => p.type === 'text')!.text;

  // 1) Embed the question, 2) retrieve nearest chunks from the vector store
  const { embedding } = await embed({
    model: openai.embedding('text-embedding-3-small'),
    value: question,
  });
  const chunks = await vectorDb.similaritySearch(embedding, { topK: 5 });
  const context = chunks.map((c) => c.content).join('\n---\n');

  // 3) Ground the model in the retrieved context and stream the answer
  const result = streamText({
    model: openai('gpt-5.1'),
    system: `Answer using ONLY the context below. If it is not there, say you don't know.\n\n${context}`,
    messages: convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}

Tool Calling and Generative UI

Give the model tools and it can call your functions -- look up an order, query the database, hit an API -- and weave the results into its answer. Each tool declares a Zod inputSchema and an execute function. The SDK streams tool-call and tool-result parts alongside text, so on the client you can render a real component for each tool result (a chart, a card, a map) instead of plain text -- the "generative UI" pattern.

// A tool the model can invoke during streamText
import { tool, streamText, stepCountIs } from 'ai';
import { z } from 'zod';

const result = streamText({
  model: openai('gpt-5.1'),
  messages: convertToModelMessages(messages),
  stopWhen: stepCountIs(5), // allow multi-step tool use
  tools: {
    getWeather: tool({
      description: 'Get the current weather for a city',
      inputSchema: z.object({ city: z.string() }),
      execute: async ({ city }) => {
        const res = await fetch(`https://api.example.com/weather?city=${city}`);
        return res.json(); // returned to the model AND streamed to the client
      },
    }),
  },
});
Keep every model call on the server in a Route Handler or Server Action: it protects your API keys, lets you enforce rate limits and auth, and keeps prompts out of the client bundle. The client only ever talks to your own endpoint via useChat. For SEO-critical first responses you can render an initial answer server-side, but treat the experimental RSC streaming (streamUI) as not-yet-production and prefer the useChat path.

9. Deployment and Performance

Next.js runs anywhere Node.js does. Vercel is the zero-config path -- Server Components, streaming, ISR, and PPR work out of the box. To self-host, set output: 'standalone' so next build emits a minimal server bundle you can put in a small Docker image and run behind any load balancer. For a purely static site with no server features, output: 'export' produces plain HTML you can serve from any CDN or object store.

Deployment Targets

// next.config.ts - build a self-contained server bundle
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  output: 'standalone',      // emits .next/standalone with a minimal server
  cacheComponents: true,     // enable "use cache" + PPR (Next.js 16)
};

export default nextConfig;
# Dockerfile - run the standalone output on Node
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

Performance Best Practices

Turbopack is the default bundler in Next.js 16 -- 2-5x faster production builds and up to 10x faster Fast Refresh than the old webpack path. Beyond that: use next/image for automatic resizing, lazy loading, and modern formats; next/font to self-host fonts with zero layout shift; keep the "use client" boundary small so the JS bundle stays lean; cache stable data with "use cache" and stream dynamic parts with <Suspense>; and choose the Edge runtime for latency-sensitive, lightweight handlers while keeping Node for anything that needs the full runtime.

A reliable production baseline: enable cacheComponents, put a <Suspense> boundary around every independently-fetched region, serve images through next/image, and self-host with output: 'standalone' in a slim Alpine image. Measure real routes with the built-in Next.js DevTools (and its MCP integration for AI-assisted debugging) rather than guessing -- the biggest wins usually come from removing request waterfalls and shrinking the client boundary, not micro-optimizing components.

Latest Updates (2026)

Next.js 16: Turbopack by Default

Next.js 16 (October 2025) made Turbopack the default bundler for both next dev and next build, delivering 2-5x faster production builds and up to 10x faster Fast Refresh versus the legacy webpack pipeline. It also standardized on React 19.2 and made params/searchParams asynchronous (they are now Promises you must await), which unlocks earlier streaming. The App Router is the recommended path for all new work.

Cache Components, "use cache", and PPR by Default

The 16.2/16.3 line stabilized the Cache Components model. With the cacheComponents flag enabled, data is dynamic by default and you opt into caching explicitly with the "use cache" directive, tuned via cacheLife() and invalidated with cacheTag()/revalidateTag(). Partial Prerendering is now the default behavior -- a static shell streams instantly while dynamic holes fill in -- so the old experimental.ppr flag has been removed. The latest 16.x patches are the current stable line as of mid-2026.

proxy.ts, after(), and the DevTools MCP

Middleware was renamed from middleware.ts to proxy.ts to make the network-boundary role explicit. The after() API is stable for running post-response work off the critical path. Turbopack gained persistent build caching, memory eviction for large apps, and Rust-based React Compiler support. Next.js DevTools now ships a Model Context Protocol (MCP) integration so AI coding agents can inspect and debug your running app directly.

The 2026 Next.js baseline is settled: App Router everywhere, Turbopack by default, Server Components for data fetching, Server Actions for mutations, "use cache" for explicit caching, and PPR streaming a static shell with dynamic holes. Start new projects with cacheComponents enabled; migrate existing apps one route at a time, since the Pages Router still runs side by side with the App Router.

More Guides