s.
Step 1: Establish Server-First Route Structure
Place data fetching and layout composition in server components. Avoid "use client" unless the component requires browser APIs, event handlers, or state management.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { DashboardHeader } from '@/components/dashboard/header'
import { MetricsGrid } from '@/components/dashboard/metrics-grid'
import { ChartContainer } from '@/components/dashboard/chart-container'
import { LoadingSkeleton } from '@/components/shared/loading-skeleton'
export default async function DashboardPage() {
return (
<div className="grid gap-6 p-6">
<DashboardHeader />
<Suspense fallback={<LoadingSkeleton count={3} />}>
<MetricsGrid />
</Suspense>
<Suspense fallback={<LoadingSkeleton count={1} />}>
<ChartContainer />
</Suspense>
</div>
)
}
Step 2: Implement Parallel Data Fetching with Streaming
Server components can fetch data independently. Use Promise.all for parallel resolution, but stream UI at meaningful boundaries to prevent waterfall blocking.
// app/dashboard/metrics-grid.tsx
import { fetchMetrics } from '@/lib/api/metrics'
import { MetricCard } from '@/components/dashboard/metric-card'
export async function MetricsGrid() {
const [revenue, users, conversions] = await Promise.all([
fetchMetrics('revenue'),
fetchMetrics('users'),
fetchMetrics('conversions')
])
return (
<div className="grid grid-cols-3 gap-4">
<MetricCard title="Revenue" value={revenue} trend="up" />
<MetricCard title="Active Users" value={users} trend="stable" />
<MetricCard title="Conversions" value={conversions} trend="down" />
</div>
)
}
Step 3: Isolate Client Interactivity
Client components must be explicitly marked and should only wrap interactive logic. Pass serializable props from server components.
// components/dashboard/chart-container.tsx
'use client'
import { useState } from 'react'
import { TimeRangeSelector } from '@/components/shared/time-range-selector'
import { RevenueChart } from '@/components/shared/revenue-chart'
export function ChartContainer() {
const [range, setRange] = useState<'7d' | '30d' | '90d'>('30d')
return (
<div className="rounded-lg border p-4">
<TimeRangeSelector value={range} onChange={setRange} />
<RevenueChart range={range} />
</div>
)
}
The App Router caches fetch requests by default. Use cache tags and revalidation intervals to control data freshness without manual cache clearing.
// lib/api/metrics.ts
export async function fetchMetrics(type: string) {
const res = await fetch(`https://api.example.com/metrics/${type}`, {
next: {
revalidate: 300,
tags: [`metrics-${type}`]
}
})
if (!res.ok) throw new Error(`Failed to fetch ${type}`)
return res.json()
}
Architecture Decisions and Rationale
- Server-First Rendering: Server components execute on the server, eliminating client-side JavaScript for data fetching, markdown parsing, and static UI composition. This reduces bundle size and improves SEO.
- Streaming Boundaries:
<Suspense> and loading.tsx allow partial rendering. The shell loads instantly; heavy data resolves asynchronously. This prevents waterfall blocking and improves perceived performance.
- Client Component Isolation:
"use client" creates a hard boundary. Only interactive components (forms, charts, modals, stateful UI) cross this boundary. This prevents accidental serialization of server-only logic.
- Cache Tags & Revalidation:
next.tags enables selective cache invalidation via revalidateTag(). revalidate intervals provide predictable staleness windows. This replaces manual cache busting with declarative control.
- Parallel Fetching:
Promise.all resolves independent data streams concurrently. Combined with streaming, this ensures UI renders as soon as individual segments resolve.
Pitfall Guide
1. Overusing "use client"
Mistake: Marking entire route trees as client components because of a single interactive element.
Impact: Ships entire component trees to the browser, negating RSC benefits, increasing bundle size, and degrading TTI.
Best Practice: Isolate client components to the smallest possible subtree. Pass serializable props from server parents.
2. Ignoring Implicit Fetch Caching
Mistake: Assuming fetch behaves like traditional SSR or client-side requests.
Impact: Data appears stale in development and production. Cache invalidation becomes unpredictable.
Best Practice: Explicitly define next.revalidate or next.tags. Use fetch(..., { cache: 'no-store' }) only for real-time data. Prefer cache tags for selective invalidation.
3. Blocking Layouts with Synchronous Data
Mistake: Fetching heavy data in layout.tsx or root components without streaming.
Impact: Entire route tree blocks until data resolves. TTFB spikes. User sees blank screen.
Best Practice: Fetch only essential layout data (auth, navigation, theme). Stream heavy data in page or nested components using <Suspense>.
4. Misusing dynamic() Without Fallbacks
Mistake: Dynamically importing components without loading or ssr: false configuration.
Impact: Waterfall loading, hydration mismatches, or server-rendered components failing to mount.
Best Practice: Use dynamic(import(...), { loading: () => <Placeholder />, ssr: false }) for client-only libraries. Reserve dynamic() for code splitting, not data fetching.
5. Over-Fetching in Parallel Routes
Mistake: Fetching identical data across multiple parallel route segments.
Impact: Duplicate network requests, increased latency, wasted server compute.
Best Practice: Use React.cache() to deduplicate fetches across components in the same render tree. Colocate data fetching where it's consumed.
6. Treating Server Actions as Client-Side Handlers
Mistake: Calling server actions directly in client components without proper error boundaries or optimistic UI.
Impact: Unhandled network failures, broken UX, security vulnerabilities if inputs aren't validated.
Best Practice: Wrap server action calls in try/catch. Validate inputs with Zod. Implement optimistic updates with useOptimistic. Never trust client-side validation alone.
7. Not Leveraging generateStaticParams
Mistake: Rendering dynamic routes entirely on-demand without static pre-generation.
Impact: Cold starts, inconsistent performance, poor SEO for known routes.
Best Practice: Use generateStaticParams for routes with finite, known parameters. Combine with ISR for unknown or frequently updated paths.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Real-time dashboard with live metrics | Streaming + cache: 'no-store' + SWR on client | Data changes frequently; streaming prevents blocking; client-side polling handles updates | Higher server compute, lower latency |
| E-commerce product catalog | ISR with cache tags + static generation | High read volume, infrequent updates; cache tags enable bulk invalidation | Lower server cost, faster TTFB |
| User settings form | Server actions + Zod validation + optimistic UI | Requires mutation; server actions ensure security; optimistic updates improve UX | Moderate client JS, high data integrity |
| Marketing blog with MDX | Static generation + generateStaticParams | Content is static; pre-rendering eliminates server load entirely | Near-zero runtime cost, instant delivery |
| Admin analytics with heavy charts | Server component layout + client chart library via dynamic() | Charts require canvas/DOM; isolating client boundary preserves RSC benefits | Controlled bundle increase, optimal render pipeline |
Configuration Template
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
optimizePackageImports: ['@radix-ui/react-icons', 'lucide-react'],
serverComponentsExternalPackages: ['pg', 'redis']
},
compiler: {
removeConsole: process.env.NODE_ENV === 'production'
},
headers: async () => [
{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }
]
}
]
}
module.exports = nextConfig
// lib/cache.ts
import { revalidateTag } from 'next/cache'
export async function invalidateCache(tags: string[]) {
tags.forEach(tag => revalidateTag(tag))
}
export function fetchWithCache(url: string, tags: string[], revalidateSeconds = 60) {
return fetch(url, {
next: { tags, revalidate: revalidateSeconds }
})
}
// tsconfig.json (relevant sections)
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Quick Start Guide
- Scaffold a new project:
npx create-next-app@latest my-app --typescript --eslint --app --src-dir --import-alias "@/*"
- Create route structure:
mkdir -p src/app/dashboard && touch src/app/dashboard/page.tsx src/app/dashboard/loading.tsx
- Add a server component with streaming: paste the
DashboardPage and MetricsGrid examples from Core Solution into respective files
- Run development server:
npm run dev and verify streaming behavior by adding a 2-second delay to fetchMetrics
- Build and profile:
npx next build and inspect .next/static/chunks to confirm client component isolation and bundle reduction