Introduction
The App Router changes the performance model of a Next.js application by making server components the default and pushing client JavaScript to the edges where it is actually needed. Next.js 16 sharpens that model further: Turbopack is now the default bundler, dev startup runs roughly four times faster, and Cache Components turn caching into a decision you state in code rather than a default you discover later. That default is powerful, but it only pays off when a team understands which work belongs on the server and which truly needs the client. Performance in this model is less about micro optimizing React and more about drawing the server and client boundary in the right place and using streaming and caching with intent. Devyst measures before and after every optimization on its full stack builds, because intuition about web performance is frequently wrong and a real metric settles the argument. This guide covers the patterns that consistently move numbers: shrinking the client bundle, streaming slow data, caching deliberately, and optimizing the heaviest assets. Each section pairs the idea with code you can apply directly.
Server Components and Bundle Size
Every component in the App Router is a server component unless it opts into the client, and that default is the single biggest lever on bundle size. Server components run only on the server, so their code and their dependencies never ship to the browser, which means a heavy formatting or markdown library used in a server component adds nothing to the client bundle. The discipline is to add the client directive as late and as narrowly as possible, isolating interactivity into small leaf components rather than marking a whole page as a client tree. Devyst keeps data fetching and heavy dependencies in server components and pushes only the genuinely interactive parts to the client, which often cuts the JavaScript a page ships by a large margin. A common mistake is marking a layout or page as a client component for one small interactive piece, which drags everything below it onto the client. The example shows a server component that fetches and renders data with zero client JavaScript.
// app/dashboard/stats.tsx
// Server Component by default, ships no JavaScript to the client.
import { db } from '@/lib/db'
export async function Stats({ tenantId }: { tenantId: string }) {
const rows = await db.metric.findMany({ where: { tenantId } })
const total = rows.reduce((sum, row) => sum + row.value, 0)
return (
<section>
<h2>Total</h2>
<p>{total.toLocaleString()}</p>
</section>
)
}The client directive is contagious downward. A component marked for the client pulls its entire subtree onto the client, so place it on the smallest leaf that needs interactivity.
Streaming and Suspense Patterns
Streaming lets the server send the fast parts of a page immediately while slower data continues to load, rather than blocking the whole response on the slowest query. Wrapping a slow component in a Suspense boundary tells Next.js to send a fallback for that region first and stream the real content in when it resolves, so the user sees a meaningful layout sooner. The skill is in choosing boundaries: put them around genuinely slow, independent data so the rest of the page is not held hostage to one slow call. Devyst wraps each slow data region in its own Suspense boundary with a skeleton fallback, which improves perceived performance and keeps a single slow dependency from delaying the whole view. Avoid wrapping everything in one giant boundary, since that collapses back to the blocking behavior streaming was meant to fix. The example shows independent boundaries that let a fast section render while a slow one streams in behind a skeleton.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { Stats } from './stats'
import { SlowReport } from './slow-report'
import { ReportSkeleton } from './report-skeleton'
export default function DashboardPage({ tenantId }: { tenantId: string }) {
return (
<main>
{/* Renders immediately */}
<Stats tenantId={tenantId} />
{/* Streams in when ready, without blocking Stats */}
<Suspense fallback={<ReportSkeleton />}>
<SlowReport tenantId={tenantId} />
</Suspense>
</main>
)
}