Next.js App Router Best Practices
Overview
Enforces high-performance architectural patterns for Next.js App Router based on Vercel Engineering guidelines: React Server Components (RSC), zero-waterfall async pipelines, request deduplication via React.cache(), bundle optimization, and secure Server Actions.
When to Use
Activate whenever building, refactoring, or reviewing Next.js pages, layouts, Route Handlers (app/api), Server Actions, or components in the app/ directory.
Negative Constraints (What NOT to Do)
- NEVER use barrel imports for UI libraries: Avoid
import { Button, Dialog } from '@/components'. Import directly from the exact file (import { Button } from '@/components/ui/button') to prevent bundler tree-shaking failures and trace bloat. - NEVER trust client-provided data or session state in Server Actions: Always authenticate session and authorize tenant ownership inside the Server Action handler itself before mutating data.
- NEVER introduce sequential
awaitwaterfalls for independent data: Always usePromise.all()or parallel streaming<Suspense>boundaries. - NEVER pass large unneeded serialized data from Server to Client Components: Only pass the specific primitive fields required by the client component (
server-dedup-props). - NEVER use
useEffectfor data fetching: Fetch directly in Server Components or use TanStack Query / SWR for client-side queries. - NEVER import server-only modules in client components: Use the
server-onlypackage in data access layers to catch accidental client imports at build time.
Rules & Patterns
1. Eliminating Async Waterfalls (Critical)
- Parallel Fetching: Fetch independent data concurrently at the top of the route or component.
- Granular Streaming: Wrap slow, non-critical subtrees in
<Suspense fallback={<Skeleton />}>so critical above-the-fold content streams immediately. - Defer Awaits: Check cheap synchronous conditions before awaiting remote resources.
2. Request Deduplication & Caching (server-cache-react)
- Use
React.cache()to deduplicate identical database or service calls across multiple components rendered in the same server request lifecycle.
import { cache } from 'react';
import { db } from '@/lib/db';
export const getCurrentUser = cache(async (userId: string) => {
return await db.user.findUnique({
where: { id: userId },
select: { id: true, name: true, role: true, email: true }
});
});
3. Secure Server Actions (server-auth-actions)
- Treat every Server Action as a public HTTP endpoint. Always validate session, authorization, and input schema with Zod.
'use server';
import { z } from 'zod';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
const UpdateProfileSchema = z.object({
name: z.string().min(2).max(50),
});
export async function updateProfile(formData: FormData) {
const session = await auth();
if (!session?.userId) throw new Error('Unauthorized');
const result = UpdateProfileSchema.safeParse({ name: formData.get('name') });
if (!result.success) return { error: 'Invalid input', issues: result.error.flatten() };
await db.user.update({
where: { id: session.userId },
data: { name: result.data.name },
});
revalidatePath('/settings');
return { success: true };
}
4. Bundle Optimization & Dynamic Imports (bundle-dynamic-imports)
- Heavy interactive client components (charts, rich-text editors, video players) must be dynamically loaded with
next/dynamic.
import dynamic from 'next/dynamic';
const AnalyticsChart = dynamic(
() => import('@/components/analytics/chart').then(mod => mod.AnalyticsChart),
{
loading: () => <div className="h-64 animate-pulse bg-muted rounded-lg" />,
ssr: false,
}
);
5. Next.js 15+ Async Request APIs (async-params)
In Next.js 15+, params, searchParams, cookies(), and headers() are asynchronous and must be awaited:
// [GOOD] Next.js 15+ Page Component
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function UserPage({ params, searchParams }: PageProps) {
const { id } = await params;
const { tab } = await searchParams;
const user = await getUser(id);
return <UserProfile user={user} activeTab={tab as string} />;
}
6. Non-Blocking Background Tasks with after()
To execute logging, analytics, or cache priming without delaying the user's HTTP response:
import { after } from 'next/server';
export async function POST(request: Request) {
const data = await request.json();
const result = await processOrder(data);
// Executes asynchronously AFTER the response stream has completed
after(async () => {
await sendSlackNotification(result);
await indexOrderInSearch(result.id);
});
return Response.json({ success: true, orderId: result.id });
}
Code Examples
See EXAMPLES.md for detailed code examples and component templates.
Validation Checklist
- All database queries in RSC layers use
React.cache()if called across multiple components. - No barrel imports (
from '@/components'); all imports point to exact component modules. - Server Actions have explicit auth checks and Zod input validation.
- Heavy client widgets (charts, editors) use
next/dynamic. - Images use
next/imagewith explicitsizesandpriorityon LCP elements.
Common Mistakes
- Using
'use client'at page level instead of leaf components. - Relying on client-side authentication checks for Server Actions without server-side validation.
- Chaining sequential awaits for independent data models.
Integration Notes
- Pairs with
reactandui-ux-profor component design and state management. - Pairs with
securityfor session authorization and input sanitization.
nextjs Examples — Anti-patterns vs ContextOS Standard
Example 1: Server Components vs Client Components
Anti-pattern: Marking the Entire Page as Client Component
// BAD: app/dashboard/page.tsx with 'use client' at top
// Bloats client bundle, loses SEO benefits, eliminates direct DB access
'use client';
export default function DashboardPage() {
const [data, setData] = useState(null);
useEffect(() => { fetch('/api/dashboard').then(...) }, []);
return <div>...</div>;
}
Best practice: ContextOS Standard (RSC by Default, Client Leaf Nodes)
// GOOD: Server Component fetches data directly with zero bundle cost
// app/dashboard/page.tsx (Server Component)
import { Suspense } from 'react';
import { db } from '@/lib/db';
import { InteractiveChart } from './InteractiveChart'; // 'use client' leaf component
export default async function DashboardPage() {
const stats = await db.analytics.getStats();
return (
<main>
<h1>Dashboard</h1>
<p>Total Revenue: {stats.revenue}</p>
<Suspense fallback={<ChartSkeleton />}>
<InteractiveChart initialData={stats.chartData} />
</Suspense>
</main>
);
}
nextjs Troubleshooting & Common Mistakes
1. Hydration Mismatch Errors
- Symptom: "Text content does not match server-rendered HTML".
- Root Cause: Rendering dates, window dimensions, or local storage data that differs between server render and client hydration.
- Fix: Use suppressHydrationWarning on localized timestamps or load client-only state inside a useEffect after mount.
2. Accidental Server Code Bundled to Client
- Symptom: "Module not found: Can't resolve 'fs' or 'pg' in client bundle".
- Root Cause: Client component importing a utility that transitively imports server-only database code.
- Fix: Separate server utilities into *.server.ts and install import 'server-only'; at the top of server files.
3. Waterfall Fetches in Server Components
- Symptom: Page takes 3 seconds to load due to sequential await statements.
- Root Cause: Awaiting independent data sources one after another.
- Fix: Use Promise.all([fetchUsers(), fetchProducts()]) or separate into nested boundaries.