Next.js: El App Router en Producción (2026)
Una guía técnica profunda para construir con Next.js 16 y el App Router: Server y Client Components, Server Actions, streaming con Suspense, obtención de datos y Cache Components, route handlers, middleware proxy, estrategias de renderizado (SSR/SSG/ISR/PPR), y cómo desplegar apps de IA con streaming usando el Vercel AI SDK.
Índice de Contenidos
- 1. App Router vs Pages Router
- 2. Server y Client Components
- 3. Obtención de Datos y Caché
- 4. Streaming, Suspense y use()
- 5. Server Actions y Mutaciones
- 6. Route Handlers y Middleware
- 7. Estrategias de Renderizado: SSR, SSG, ISR, PPR
- 8. Construyendo Apps de IA con el Vercel AI SDK
- 9. Despliegue y Rendimiento
1. App Router vs Pages Router
Dos Routers, Un Framework
Next.js incluye dos routers. El directorio app/ (App Router) es la opción por defecto y recomendada para todo proyecto nuevo: está construido sobre React Server Components, y cada funcionalidad agregada en los últimos tres releases mayores -- Server Actions, Cache Components, la API after(), params asíncronos, el Next.js DevTools MCP -- es exclusiva del App Router. El directorio pages/ (Pages Router) más antiguo sigue siendo totalmente soportado y puede coexistir en el mismo proyecto, lo que te permite migrar ruta por ruta. En el App Router una carpeta define un segmento de ruta y archivos especiales le dan a cada segmento su comportamiento.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)
Convenciones de Archivos y Primitivas de Routing
Las rutas se describen con archivos, no con un objeto de configuración. page.tsx hace que un segmento sea públicamente enrutable; layout.tsx lo envuelve junto a sus hijos y preserva el estado entre navegaciones. Además de segmentos estáticos, el App Router soporta segmentos dinámicos [id], catch-alls [...slug], grupos de rutas (group) que organizan archivos sin agregar un segmento a la URL, rutas paralelas @slot y rutas interceptoras (.) para modales. Un layout.tsx raíz es obligatorio y debe renderizar las etiquetas html y body.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>
);
}
Migrando desde el Pages Router
No hay un reemplazo uno a uno directo, pero el mapeo es limpio: getServerSideProps y getStaticProps se colapsan en un Server Component async que simplemente hace await de sus datos; _app y _document se convierten en el layout.tsx raíz; los handlers de pages/api se vuelven Route Handlers route.ts; y next/head se reemplaza por el export metadata. Como ambos routers corren en paralelo, puedes mover una ruta a la vez y publicar de forma incremental.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 y Client Components
El Modelo Server-First
En el App Router cada componente es un React Server Component (RSC) por defecto. Los Server Components se renderizan en el servidor, nunca envían su código al navegador y pueden ser async. Optas por incluir un subárbol en el navegador con la directiva "use client" cuando necesitas interactividad -- estado, efectos, manejadores de eventos o APIs exclusivas del navegador. El objetivo arquitectónico es mantener pequeñas las islas interactivas y empujar la obtención de datos y las dependencias pesadas al servidor, para que el bundle del cliente se mantenga liviano.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
Los Server Components corren solo en el servidor. Pueden leer la base de datos, el sistema de archivos y secretos directamente sin exponerlos, y aportan cero JavaScript al cliente. No pueden usar useState, useEffect ni manejadores de eventos. Como pueden ser async, la obtención de datos es simplemente await en el cuerpo del componente -- no hay baile de useEffect más estado de carga.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
Un archivo que comienza con "use client" (y todo lo que importa) se empaqueta para el navegador y se hidrata. Usa Client Components para cualquier cosa con estado o interactiva: formularios, dropdowns, gráficos, cualquier cosa que use useState/useEffect, o librerías que tocan window. Los Client Components igual se renderizan en el servidor para el HTML inicial -- "client" significa que también corren en el navegador, no que se saltan el SSR."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>
);
}
Composición: Pasar del Servidor al Cliente
No puedes importar un Server Component dentro de un Client Component, pero sí puedes pasar uno como children (o cualquier prop) desde un Server Component. Este patrón de slot permite que un pequeño Client Component interactivo envuelva contenido renderizado en el servidor sin arrastrar ese contenido -- ni sus dependencias -- al bundle del cliente. Los props que cruzan la frontera deben ser serializables (nada de funciones, instancias de clase o Dates no planos).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>
);
}
La Frontera "use client"
La directiva marca un punto de entrada, no un solo archivo: una vez que un módulo es un Client Component, cada módulo que importa también pasa a formar parte del grafo del cliente. Coloca "use client" lo más abajo posible en el árbol para que la frontera sea pequeña. El estado compartido entre muchas islas de cliente se maneja con un provider de Context ubicado en un Client Component, o con un store como Zustand/Jotai -- pero recurre a ellos solo después de que los valores por defecto server-first dejen de encajar."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. Obtención de Datos y Caché
En el App Router obtienes los datos donde los usas: dentro de Server Components async. Next.js 16 adopta el modelo Cache Components -- los datos son dinámicos (sin caché) por defecto, y optas por cachear trabajo específico con la directiva "use cache". Esto es lo inverso de las heurísticas antiguas donde fetch se cacheaba a menos que dijeras lo contrario, y convierte el caché en una decisión explícita y revisable en lugar de una sorpresa.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.
Obteniendo Datos en Server Components
Obtén datos con await -- ya sea el fetch nativo o un cliente de base de datos/ORM. Dentro de un mismo pase de renderizado, Next.js memoiza automáticamente las llamadas fetch idénticas (misma URL y opciones), de modo que varios componentes pueden pedir los mismos datos sin round-trips de red duplicados. Los fetches corren en paralelo cuando los inicias antes de hacer await, lo que evita las cascadas de requests.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>
);
}
Caché con "use cache"
Agrega "use cache" a un archivo, una función o un componente para cachear su salida. Controla la frescura con cacheLife() (perfiles con nombre como hours o ventanas personalizadas de stale/revalidate/expire) y adjunta cacheTag() para poder purgar bajo demanda con revalidateTag() desde un Server Action o Route Handler. Esto unifica los antiguos controles force-cache / revalidate / no-store en una sola API basada en directivas."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 y use()
Streaming con Suspense
Next.js renderiza los Server Components como un stream. Envolver una parte lenta del árbol en Suspense permite que el framework envíe el shell circundante de inmediato y descargue el HTML de la región lenta después, a medida que sus datos se resuelven. Los boundaries anidados producen estados de carga granulares: el marco de la página y el contenido rápido pintan al instante mientras las secciones independientes hacen streaming en sus propias líneas de tiempo, mejorando el time-to-first-byte y la velocidad percibida.<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 y Estados de Carga Instantáneos
Un archivo loading.tsx junto a un page.tsx es azúcar sintáctico para envolver ese segmento de ruta en un boundary de Suspense. Next.js lo muestra en el instante en que el usuario navega, antes de que los datos del segmento hayan cargado, y lo cambia por la UI real cuando el Server Component async se resuelve. Combinado con layouts anidados, esto te da skeletons a nivel de ruta gratis sin escribir ni un solo boundary de Suspense a mano.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} />;
}
Patrones y Trampas del Streaming
Tres patrones cubren la mayoría de los casos: (1) pon un Suspense alrededor de cada región lenta e independiente para que una consulta lenta nunca bloquee toda la página; (2) inicia los fetches temprano y pasa la promesa (no el valor ya resuelto) a un hijo para que el shell padre pueda renderizar mientras el hijo hace streaming; y (3) mantén el shell compartido en el layout para que nunca se vuelva a montar. La trampa principal es hacer await de todo al inicio de la página -- eso colapsa el streaming de vuelta a un único render bloqueante.<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.
El Hook use(): Desenvolviendo Promesas en el Cliente
Para transmitir datos a un Client Component interactivo, inicia el fetch en un Server Component y pasa la promesa sin await hacia abajo como prop. El Client Component la lee con el use() de React, que se suspende hasta que la promesa se resuelve. Envuelto en un boundary de Suspense, el padre renderiza de inmediato y la isla de cliente se llena cuando llegan los datos -- sin useEffect ni una máquina de estado de carga en el cliente.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(): Trabajo Después de la Respuesta
La API after() (estable en Next.js 16) agenda trabajo para ejecutarse después de que la respuesta haya terminado de hacer streaming al usuario. Es el lugar correcto para logging, analítica, calentamiento de caché o enviar una notificación -- efectos secundarios que no deben retrasar el time-to-first-byte. El callback corre aunque la respuesta ya esté descargada, así que el usuario nunca espera por él. Úsalo en Server Components, Route Handlers y Server Actions.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>;
}
Manejo de Errores Durante el Streaming
Un archivo error.tsx envuelve su segmento de ruta en un error boundary de React. Si un Server Component lanza un error -- incluso durante el streaming -- Next.js renderiza este fallback de Client Component en lugar de romper toda la página, y te da una función reset() para reintentar el segmento. Usa global-error.tsx para capturar fallos en el layout raíz, y notFound() más not-found.tsx para el caso esperado de recurso faltante.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>
);
}
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 y Mutaciones
Los Server Actions son funciones async marcadas con la directiva "use server" que corren en el servidor pero pueden llamarse directamente desde componentes cliente o servidor. Reemplazan los endpoints REST/GraphQL escritos a mano para mutaciones: pasas uno directo a un form action, y funciona incluso antes de que cargue el JavaScript (mejora progresiva). Combínalos con useActionState para el estado pendiente/error/resultado y useFormStatus para el flag pendiente de un botón de envío.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.
Definiendo y Llamando un 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>
);
}
Revalidación y UI Optimista
Después de una mutación, llama a revalidatePath() o revalidateTag() para que los datos cacheados afectados se vuelvan a obtener en el siguiente render -- sin malabares manuales de caché. Para feedback instantáneo, envuelve los datos actuales en useOptimistic y aplica el cambio localmente en el momento en que el usuario actúa; React reconcilia con el resultado del servidor (y revierte automáticamente si el action lanza un error). Los Server Actions también pueden invocarse de forma imperativa con startTransition cuando no hay un formulario que enviar.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>
));
}
6. Route Handlers y Middleware
Cuando necesitas un endpoint HTTP en lugar de una página -- un webhook, una API pública, un endpoint de streaming de IA -- crea un archivo route.ts. Los Route Handlers usan los objetos Request y Response de la plataforma web y exportan funciones nombradas según los verbos HTTP (GET, POST, PUT, DELETE, y demás). Son el reemplazo del App Router para pages/api. Ten en cuenta que un segmento no puede tener a la vez un page.tsx y un route.ts.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
La lógica transversal de las peticiones -- controles de auth, redirecciones, rewrites, inyección de headers, tests A/B -- vive en un único archivo de middleware. A partir de Next.js 16 este archivo se llama proxy.ts (el antiguo nombre middleware.ts está deprecado) para dejar claro que corre en la frontera de red, antes de que se resuelva una ruta. Se ejecuta en cada petición que su configuración matcher selecciona, y retorna un NextResponse para continuar, hacer rewrite o redirigir. Mantenlo rápido y compatible con el edge; haz las verificaciones pesadas dentro de la ruta.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. Estrategias de Renderizado: SSR, SSG, ISR, PPR
Renderizado Estático y Dinámico
Una ruta se renderiza de forma estática (en tiempo de build, como SSG) a menos que use una señal dinámica -- leer cookies(), headers(), searchParams, o datos sin caché -- en cuyo caso se vuelve dinámica (renderizada por petición, como SSR). Para una ruta dinámica con un conjunto conocido de paths, generateStaticParams pre-renderiza cada variante en tiempo de build, convirtiendo una ruta [slug] en un conjunto de páginas estáticas.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 sirve una página estática y la refresca en segundo plano según un horario, así obtienes rendimiento estático con datos que se mantienen razonablemente frescos. En el modelo Cache Components lo expresas con "use cache" más cacheLife() (o un revalidate por fetch). El ISR bajo demanda usa revalidateTag()/revalidatePath() para purgar exactamente cuando los datos subyacentes cambian -- por ejemplo desde un webhook de un CMS -- en lugar de esperar a un temporizador."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 rompe la dicotomía estático-vs-dinámico a nivel de ruta: Next.js pre-renderiza un shell HTML estático (servido al instante desde el edge/CDN) y transmite los huecos dinámicos -- cualquier cosa envuelta en Suspense -- dentro de él a medida que sus datos se resuelven. Con Cache Components habilitado en Next.js 16, PPR es el comportamiento por defecto del App Router; el antiguo flag experimental.ppr y la config de segmento experimental_ppr fueron removidos. Obtienes el SEO y el TTFB de lo estático con la frescura de lo dinámico en una sola ruta.<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.
Eligiendo una Estrategia
Páginas de marketing y documentación: totalmente estáticas (SSG). Dashboards y páginas de cuenta: dinámicas por petición (SSR). Contenido de alto tráfico que cambia ocasionalmente: ISR con purga basada en tags. La mayoría de las páginas reales son una mezcla -- un shell estático (nav, hero, layout) con unos pocos widgets dinámicos por usuario -- que es exactamente lo que PPR entrega. En lugar de elegir una etiqueta para toda la app, cachea lo estable con "use cache" y envuelve lo dinámico en Suspense."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. Construyendo Apps de IA con el Vercel AI SDK
Chat con Streaming: streamText más useChat
El Vercel AI SDK es la forma estándar de construir funcionalidades de IA en Next.js. La mitad del servidor es un Route Handler que llama a streamText con un modelo y la conversación, y luego retorna result.toUIMessageStreamResponse(). La mitad del cliente es el hook useChat, que administra la lista de mensajes, transmite los tokens a medida que llegan y expone sendMessage y un status. El modelo es intercambiable detrás de un paquete de proveedor (OpenAI, Anthropic, Google y otros) sin cambiar tu UI.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: Generación Aumentada por Recuperación
Un endpoint RAG ancla el modelo en tus propios datos: genera el embedding de la pregunta del usuario, recupera los fragmentos más similares de un vector store, los inyecta en el system prompt como contexto y transmite la respuesta. En Next.js esto es un solo Route Handler -- embed para el vector de la consulta, una búsqueda por similitud en el vector-DB, y luego streamText con el contexto recuperado. Como corre en el servidor, tus claves de embedding y tu base de datos permanecen privadas.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 y Generative UI
Dale herramientas al modelo y podrá llamar a tus funciones -- buscar un pedido, consultar la base de datos, golpear una API -- y entrelazar los resultados en su respuesta. Cada herramienta declara un inputSchema de Zod y una función execute. El SDK transmite las partes de llamada a herramienta y de resultado junto al texto, de modo que en el cliente puedes renderizar un componente real por cada resultado (un gráfico, una tarjeta, un mapa) en lugar de texto plano -- el patrón de generative UI.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
},
}),
},
});
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. Despliegue y Rendimiento
Next.js corre en cualquier lugar donde corra Node.js. Vercel es el camino de configuración cero -- Server Components, streaming, ISR y PPR funcionan de fábrica. Para auto-hospedar, usa output: "standalone" para que next build emita un bundle de servidor mínimo que puedes poner en una imagen Docker pequeña y correr detrás de cualquier balanceador de carga. Para un sitio puramente estático sin funcionalidades de servidor, output: "export" produce HTML plano que puedes servir desde cualquier CDN o almacenamiento de objetos.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.
Destinos de Despliegue
// 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"]
Buenas Prácticas de Rendimiento
Turbopack es el bundler por defecto en Next.js 16 -- builds de producción 2-5x más rápidos y Fast Refresh hasta 10x más rápido que el antiguo camino de webpack. Más allá de eso: usa next/image para redimensionamiento automático, lazy loading y formatos modernos; next/font para auto-hospedar fuentes con cero layout shift; mantén pequeña la frontera "use client" para que el bundle de JS se mantenga liviano; cachea datos estables con "use cache" y transmite las partes dinámicas con Suspense; y elige el runtime Edge para handlers livianos sensibles a la latencia mientras mantienes Node para lo que necesite el runtime completo.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.
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.Últimas Actualizaciones (2026)
Next.js 16: Turbopack por Defecto
Next.js 16 (octubre de 2025) convirtió a Turbopack en el bundler por defecto tanto para next dev como para next build, entregando builds de producción 2-5x más rápidos y Fast Refresh hasta 10x más rápido frente al pipeline heredado de webpack. También estandarizó React 19.2 e hizo asíncronos a params/searchParams (ahora son Promises que debes hacer await), lo que habilita streaming más temprano. El App Router es el camino recomendado para todo trabajo nuevo.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" y PPR por Defecto
La línea 16.2/16.3 estabilizó el modelo Cache Components. Con el flag cacheComponents habilitado, los datos son dinámicos por defecto y optas por cachear explícitamente con la directiva "use cache", ajustada vía cacheLife() e invalidada con cacheTag()/revalidateTag(). El Partial Prerendering es ahora el comportamiento por defecto -- un shell estático se transmite al instante mientras los huecos dinámicos se llenan -- por lo que el antiguo flag experimental.ppr fue removido. Los últimos parches 16.x son la línea estable actual a mediados de 2026.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() y el DevTools MCP
El middleware fue renombrado de middleware.ts a proxy.ts para hacer explícito su rol de frontera de red. La API after() es estable para ejecutar trabajo posterior a la respuesta fuera del camino crítico. Turbopack ganó caché de build persistente, evicción de memoria para apps grandes y soporte del React Compiler basado en Rust. Las Next.js DevTools ahora incluyen una integración con el Model Context Protocol (MCP) para que los agentes de código con IA puedan inspeccionar y depurar tu app en ejecución directamente.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.
"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.