DOMAIN:FRONTEND:NEXTJS_APP_ROUTER¶
OWNER: floris (Team Alpha), floor (Team Beta)
UPDATED: 2026-03-24
SCOPE: all client projects using Next.js App Router
PREREQUISITE: React 19+ knowledge (see react-patterns.md)
SERVER_COMPONENTS_VS_CLIENT_COMPONENTS¶
RULE: Server Components are the DEFAULT. Every component is a Server Component unless it has "use client" at the top.
RULE: add "use client" ONLY when the component needs interactivity, browser APIs, or React hooks that use state/effects.
DECISION_TREE¶
Does this component need...
useState, useReducer, useActionState? → "use client"
useEffect, useLayoutEffect? → "use client"
onClick, onChange, onSubmit handlers? → "use client"
window, document, localStorage? → "use client"
IntersectionObserver, ResizeObserver? → "use client"
Third-party client library (e.g. chart)? → "use client"
None of the above? → Server Component (DEFAULT)
SERVER_COMPONENT_CAPABILITIES¶
CAN: fetch data directly (await in component body), access database, read files, use secrets/env vars, render async JSX
CANNOT: use useState, useEffect, useRef, event handlers, browser APIs, context providers
// app/dashboard/page.tsx — Server Component (no directive needed)
import { db } from "@/lib/db";
export default async function DashboardPage() {
const stats = await db.query.stats.findMany();
return (
<div>
<h1>Dashboard</h1>
<StatsList stats={stats} />
</div>
);
}
CLIENT_COMPONENT_CAPABILITIES¶
CAN: use all React hooks, event handlers, browser APIs, third-party client libraries
CANNOT: directly await data fetches in component body (use useEffect or receive as props from server parent)
NOTE: Client Components still render on the server during SSR — "use client" does NOT mean client-only
// components/counter.tsx
"use client";
import { useState } from "react";
export function Counter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
COMPOSITION_PATTERN¶
PATTERN: Server Component parent fetches data, passes to Client Component child as props.
PATTERN: Use children prop to nest Server Components inside Client Component wrappers.
// app/layout.tsx — Server Component
import { Sidebar } from "@/components/sidebar"; // Client Component with "use client"
import { Navigation } from "@/components/navigation"; // Server Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<Sidebar>
{/* Navigation is a Server Component rendered inside a Client Component via children */}
<Navigation />
{children}
</Sidebar>
);
}
ANTI_PATTERN: importing a Server Component directly inside a Client Component file — this converts it to a Client Component.
FIX: pass Server Components as children or other React node props to Client Components.
DATA_FETCHING¶
SERVER_ACTIONS¶
USE_WHEN: mutations (create, update, delete), form submissions, any data modification.
DEFINED_WITH: "use server" directive at top of function or file.
// lib/actions/update-profile.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { profileSchema } from "@/lib/schemas/profile";
export async function updateProfile(formData: FormData) {
const parsed = profileSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
await db.update(profiles).set(parsed.data).where(eq(profiles.userId, userId));
revalidatePath("/settings");
return { success: true };
}
ROUTE_HANDLERS¶
USE_WHEN: public APIs, webhooks from external services, streaming responses, file uploads, responses that need specific HTTP headers.
LOCATION: app/api/[...]/route.ts
// app/api/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
// validate webhook signature
// process webhook
return NextResponse.json({ received: true });
}
SERVER_COMPONENT_DATA_FETCHING¶
USE_WHEN: reading data for display (no mutation). Fetch directly in the component.
PATTERN: async component with await.
// app/products/page.tsx
import { db } from "@/lib/db";
export default async function ProductsPage() {
const products = await db.query.products.findMany({
where: eq(products.active, true),
});
return <ProductGrid products={products} />;
}
DECISION: SERVER_ACTIONS_VS_ROUTE_HANDLERS¶
IF mutation from within the app THEN use Server Action
IF external webhook or third-party integration THEN use Route Handler
IF need specific HTTP status codes or headers THEN use Route Handler
IF streaming large response THEN use Route Handler
IF form submission with progressive enhancement THEN use Server Action (works without JS)
CACHING¶
FOUR_CACHE_LAYERS¶
-
REQUEST_MEMOIZATION: auto-deduplicates identical fetch() calls within a single server render. No config needed. Only works during a single request lifecycle.
-
DATA_CACHE: persists fetch results across requests.
- DEFAULT:
fetch()is cached (force-cache) unless dynamic signal detected - DISABLE:
fetch(url, { cache: "no-store" }) - TIMED:
fetch(url, { next: { revalidate: 60 } })— ISR-style, revalidate every 60s -
TAGGED:
fetch(url, { next: { tags: ["products"] } })— invalidate withrevalidateTag("products") -
FULL_ROUTE_CACHE: caches rendered HTML + RSC payload for static routes.
- STATIC by default when no dynamic signals detected
- DYNAMIC when using: cookies(), headers(), searchParams, connection(), unstable_noStore()
- FORCE_STATIC:
export const dynamic = "force-static" - FORCE_DYNAMIC:
export const dynamic = "force-dynamic" -
ISR:
export const revalidate = 60 -
ROUTER_CACHE: client-side cache of visited routes. Enables instant back/forward navigation.
- AUTO: previously visited routes served from cache
- INVALIDATE:
router.refresh()or revalidatePath/revalidateTag in Server Action
CACHE_INVALIDATION¶
// In a Server Action after mutation:
import { revalidatePath, revalidateTag } from "next/cache";
export async function createProduct(data: ProductInput) {
"use server";
await db.insert(products).values(data);
// Option 1: revalidate specific path
revalidatePath("/products");
// Option 2: revalidate all fetches tagged with "products"
revalidateTag("products");
}
NON_FETCH_CACHING¶
IF using ORM/database directly (not fetch) THEN use import { unstable_cache } from "next/cache" or import { cache } from "react".
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";
const getCachedProducts = unstable_cache(
async () => db.query.products.findMany(),
["products"],
{ revalidate: 60, tags: ["products"] }
);
NOTE: react.cache() deduplicates within a single request (like request memoization). unstable_cache() persists across requests (like data cache).
STREAMING_WITH_SUSPENSE¶
PURPOSE: show UI progressively. Do not block entire page on slowest data fetch.
MECHANISM: wrap slow components in <Suspense> with a fallback. Server streams HTML chunks as each resolves.
LOADING.TSX¶
CONVENTION: loading.tsx in a route segment automatically wraps page.tsx in Suspense.
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return <DashboardSkeleton />;
}
MANUAL_SUSPENSE¶
USE_WHEN: multiple independent data fetches on one page. Each can stream independently.
// app/dashboard/page.tsx
import { Suspense } from "react";
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel /> {/* async server component */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* async server component, independent fetch */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders /> {/* async server component, independent fetch */}
</Suspense>
</div>
);
}
BENEFIT: header + layout render instantly. Each panel streams in as its data resolves. No waterfall.
ANTI_PATTERN: one giant fetch that blocks everything. Split into independent Suspense boundaries.
PARALLEL_ROUTES¶
PURPOSE: render multiple pages simultaneously in the same layout, each with independent loading/error states.
SYNTAX: @slot folder convention.
app/
dashboard/
@analytics/
page.tsx
loading.tsx
@activity/
page.tsx
loading.tsx
layout.tsx
page.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
activity,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
activity: React.ReactNode;
}) {
return (
<div className="grid grid-cols-2 gap-4">
<div>{children}</div>
<div>{analytics}</div>
<div>{activity}</div>
</div>
);
}
USE_WHEN: dashboard layouts with independent panels, split views, modals alongside main content.
INTERCEPTING_ROUTES¶
PURPOSE: show route content in a modal/overlay while keeping the current page visible. Direct navigation shows full page.
SYNTAX: (.)segment (same level), (..)segment (one level up), (...)segment (from root).
USE_CASE: photo gallery — clicking photo shows modal overlay, direct URL shows full photo page.
app/
feed/
page.tsx
@modal/
(..)photo/[id]/
page.tsx # Intercepted: shows as modal
default.tsx
photo/[id]/
page.tsx # Direct: shows as full page
MIDDLEWARE¶
LOCATION: middleware.ts at project root (next to app/).
RUNS: on every request, at the edge, before routing.
USE_FOR: authentication redirects, locale detection, A/B testing, rate limiting headers.
DO_NOT: heavy computation, database queries, complex logic — keep middleware fast and small.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const token = request.cookies.get("session-token");
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};
RULE: use matcher config to limit middleware scope. Do NOT run on every request including static assets.
ROUTE_GROUPS¶
PURPOSE: organize routes without affecting URL structure.
SYNTAX: (groupName) folder.
app/
(marketing)/
page.tsx # /
about/page.tsx # /about
pricing/page.tsx # /pricing
layout.tsx # Marketing layout (no sidebar)
(dashboard)/
dashboard/page.tsx # /dashboard
settings/page.tsx # /settings
layout.tsx # Dashboard layout (with sidebar)
USE_WHEN: different layouts for different sections. Marketing pages vs authenticated app pages.
METADATA¶
PATTERN: export metadata object or generateMetadata function from page/layout.
// app/products/[id]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const product = await getProduct(id);
return {
title: product.name,
description: product.description,
openGraph: { images: [product.image] },
};
}
NOTE: In Next.js 15+, params is a Promise. Must await params before accessing properties.
LLM_MISTAKES_WITH_APP_ROUTER¶
MISTAKE: generating Pages Router code (getServerSideProps, getStaticProps, _app.tsx, _document.tsx).
FIX: App Router uses page.tsx in app/ directory, data fetching in component body or Server Actions.
MISTAKE: wrapping entire page in "use client" to use one interactive element.
FIX: extract the interactive part into a small Client Component, keep the rest as Server Component.
MISTAKE: using useEffect for data fetching in Server Components.
FIX: await directly in the async Server Component body.
MISTAKE: not awaiting params or searchParams in Next.js 15+.
FIX: const { id } = await params; — these are now Promises.
MISTAKE: using router.push() for mutations instead of Server Actions.
FIX: use Server Actions for mutations. router.push() is for navigation only.
MISTAKE: importing from next/router instead of next/navigation.
FIX: App Router uses next/navigation for useRouter, usePathname, useSearchParams.
MISTAKE: creating API route at pages/api/ instead of app/api/[...]/route.ts.
FIX: Route Handlers use route.ts files in the app/ directory.
MISTAKE: using revalidate in Client Components.
FIX: revalidatePath and revalidateTag only work in Server Actions or Route Handlers.
SELF_CHECK¶
BEFORE_EVERY_PAGE:
- [ ] is the component a Server Component by default? Only "use client" where needed?
- [ ] is data fetched on the server, not in client useEffect?
- [ ] are slow data fetches wrapped in Suspense boundaries?
- [ ] is caching configured correctly (revalidate, tags)?
- [ ] does the page have metadata (title, description, OG)?
- [ ] is there a loading.tsx or Suspense fallback?
- [ ] is there an error.tsx error boundary?
- [ ] am I using next/navigation not next/router?
- [ ] are params/searchParams awaited (Next.js 15+)?