Next.js App Router principles. Server Components, data fetching, routing patterns.
Principles for Next.js App Router development.
Does it need...?
│
├── useState, useEffect, event handlers
│ └── Client Component ('use client')
│
├── Direct data fetching, no interactivity
│ └── Server Component (default)
│
└── Both?
└── Split: Server parent + Client child
| Type | Use |
|---|---|
| Server | Data fetching, layout, static content |
| Client | Forms, buttons, interactive UI |
Next.js 16 breaking change:
fetch()is not cached by default anymore. The defaultautobehavior fetches fresh on every request unless you explicitly opt in.
| Pattern | How | Use |
|---|---|---|
use cache (preferred) | 'use cache' directive + cacheLife | Caching in Next.js 16; works for components, functions, data |
| Force-cache | fetch(url, { cache: 'force-cache' }) | Explicitly cache a single fetch call |
| Revalidate | fetch(url, { next: { revalidate: 60 } }) | ISR (time-based refresh) |
| No-store | fetch(url, { cache: 'no-store' }) | Always fetch fresh (dynamic) |
| No cache (default) | fetch(url) | Not cached — runs on every request |
| Source | Pattern |
|---|---|
| Database | Server Component fetch |
| API | fetch with caching |
| User input | Client state + server action |
| File | Purpose |
|---|---|
page.tsx | Route UI |
layout.tsx | Shared layout |
loading.tsx | Loading state |
error.tsx | Error boundary |
not-found.tsx | 404 page |
| Pattern | Use |
|---|---|
Route groups (name) | Organize without URL |
Parallel routes @slot | Multiple same-level pages |
Intercepting (.) | Modal overlays |
| Method | Use |
|---|---|
| GET | Read data |
| POST | Create data |
| PUT/PATCH | Update data |
| DELETE | Remove data |
lib/ modules and call directly| Type | Use |
|---|---|
| Static export | Fixed metadata |
| generateMetadata | Dynamic per-route |
use cache Directive (Cache Components)Next.js 16 introduces Cache Components as the primary caching mechanism. Enable it in next.config.ts:
const nextConfig: NextConfig = { cacheComponents: true };
Then use the 'use cache' directive in components, Server Actions, or utility functions:
import { cacheLife, cacheTag } from "next/cache";
async function getProducts() {
"use cache";
cacheLife("hours"); // built-in profile: seconds / minutes / hours / days / max
cacheTag("products"); // tag for on-demand invalidation
return db.query("SELECT * FROM products");
}
| Layer | Control |
|---|---|
| Component/Function | 'use cache' directive + cacheLife + cacheTag |
| Individual fetch | fetch(url, { cache: 'force-cache' }) |
| Data tags | cacheTag / revalidateTag / updateTag |
| Full route | route segment config |
| Method | Where | Use |
|---|---|---|
revalidateTag(tag, 'max') | Server Actions, Route Handlers | Stale-while-revalidate |
updateTag(tag) | Server Actions only | Immediate expiry (read-your-own-writes) |
revalidatePath(path) | Server Actions, Route Handlers | Invalidate a route's full cache |
cacheLife('hours') | inside 'use cache' scope | Time-based TTL |
fetch(url, { cache: 'no-store' }) | Server Components | Dynamic, never cached |
Legacy patterns still work (
unstable_cache,fetchwithnext.revalidate) but preferuse cachefor new code.
| ❌ Don't | ✅ Do |
|---|---|
| 'use client' everywhere | Server by default |
| Fetch in client components | Fetch in server |
| Skip loading states | Use loading.tsx |
| Ignore error boundaries | Use error.tsx |
| Large client bundles | Dynamic imports |
app/
├── (marketing)/ # Route group
│ └── page.tsx
├── (dashboard)/
│ ├── layout.tsx # Dashboard layout
│ └── page.tsx
├── api/
│ └── [resource]/
│ └── route.ts
└── components/
└── ui/
Remember: Server Components are the default for a reason. Start there, add client only when needed.
Invoke this skill when:
'use cache', cacheLife, cacheTag) to a Server Component or functionroute.ts)app/ files// BAD — adding "use client" to a whole page just to handle a button click
"use client";
export default function ProductPage() {
const [count, setCount] = useState(0);
const products = await db.getProducts(); // not allowed in client component anyway
return <div>...</div>;
}
// GOOD — Server Component fetches data; thin Client Component handles interaction
// app/products/page.tsx (Server Component — no directive needed)
export default async function ProductPage() {
const products = await db.getProducts();
return <ProductList products={products} />;
}
// components/add-to-cart-button.tsx
("use client");
export function AddToCartButton({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false);
return (
<button onClick={() => handleAdd(productId, setLoading)}>
Add to cart
</button>
);
}
// BAD — calls route handler from a server component (creates unnecessary HTTP round-trip)
// app/dashboard/page.tsx
export default async function DashboardPage() {
const res = await fetch("/api/stats"); // ❌ calls own API from server
const stats = await res.json();
return <Stats data={stats} />;
}
// GOOD — call shared lib function directly from Server Component
// app/dashboard/page.tsx
import { getStats } from "@/lib/stats";
export default async function DashboardPage() {
const stats = await getStats(); // ✅ direct call, caching works correctly
return <Stats data={stats} />;
}
// lib/stats.ts
export async function getStats() {
"use cache";
cacheLife("minutes");
cacheTag("stats");
return db.query("SELECT ...");
}
// BAD — no validation, trusting req.json() blindly
// app/api/users/route.ts
export async function POST(req: Request) {
const body = await req.json();
await db.createUser(body); // ❌ body is unknown shape
}
// GOOD — validate with Zod at the API boundary
import { userCreateSchema } from "@/lib/schemas/user.schema";
export async function POST(req: Request) {
const raw = await req.json();
const result = userCreateSchema.safeParse(raw);
if (!result.success) {
return Response.json({ error: result.error.flatten() }, { status: 400 });
}
await db.createUser(result.data); // ✅ fully typed and validated
}
'use client' only where it's strictly needed?'use cache' + cacheLife + cacheTag for cacheable datanext/image, not <img>dynamic() if only needed client-sidegenerateMetadata or static export defined for new routes