Complete ruleset for Next.js App Router — server vs client component boundaries, data fetching patterns, caching strategies, streaming with Suspense, and route handler best practices. Covers Next.js 14-16.
You are an expert Next.js engineer. Follow these rules for all App Router code.
Server components are the default. Only add 'use client' when the component itself needs browser APIs, hooks, or event handlers.
useState, useEffect, useRef, or other hooksonClick, onChange, or other event handlerswindow, document, localStorage)Push 'use client' as deep as possible. Don't mark a parent as client just because one child needs interactivity.
// BAD: Entire page is client
'use client'
export default function SettingsPage() {
const [name, setName] = useState('');
return (
<div>
<Breadcrumb items={[...]} /> {/* Static — doesn't need client */}
<h1>Settings</h1> {/* Static */}
<SettingsForm /> {/* Interactive */}
</div>
);
}
// GOOD: Only the form is client
// page.tsx (server)
export default function SettingsPage() {
return (
<div>
<Breadcrumb items={[...]} />
<h1>Settings</h1>
<SettingsForm /> {/* 'use client' is in this file only */}
</div>
);
}
Always fetch data in server components using async/await. Never use useEffect + fetch for initial page data.
// page.tsx — server component
export default async function DashboardPage() {
const data = await getAnalytics(); // Direct DB/service call
return <AnalyticsChart data={data} />;
}
When a page needs multiple independent data sources, fetch them in parallel:
export default async function DashboardPage() {
const [user, stats, notifications] = await Promise.all([
getUser(),
getStats(),
getNotifications(),
]);
return <Dashboard user={user} stats={stats} notifications={notifications} />;
}
unstable_cache with tags for data that changes on mutations.revalidateTag() in mutation functions to bust stale data.import { unstable_cache, revalidateTag } from 'next/cache';
export const getCachedUser = unstable_cache(
async (userId: string) => {
return db.user.findUnique({ where: { id: userId } });
},
['user'],
{ revalidate: 120, tags: ['users'] }
);
// In mutation functions:
export async function updateUser(id: string, data: UpdateData) {
await db.user.update({ where: { id }, data });
revalidateTag('users');
}
Every route segment should have a loading.tsx with a skeleton that matches the page layout. This gives instant navigation feedback.
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-32" />
))}
</div>
</div>
);
}
Response.json() for responses.// app/api/posts/route.ts
export async function POST(req: Request) {
try {
const userId = await requireAuth();
const body = await req.json();
const parsed = createPostSchema.safeParse(body);
if (!parsed.success) {
return Response.json({ error: parsed.error.issues }, { status: 400 });
}
const post = await createPost(parsed.data);
return Response.json(post, { status: 201 });
} catch (err) {
if (err instanceof Response) return err;
return Response.json({ error: 'Internal server error' }, { status: 500 });
}
}
In Next.js 15+, both params and searchParams are Promises that must be awaited:
export default async function SkillPage({
params,
}: {
params: Promise<{ skillId: string }>
}) {
const { skillId } = await params;
const skill = await getSkill(skillId);
// ...
}
Add generateMetadata to dynamic pages for SEO:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await getPost(id);
return {
title: post?.title ?? 'Not Found',
description: post?.excerpt,
};
}