Skip to content

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

  1. REQUEST_MEMOIZATION: auto-deduplicates identical fetch() calls within a single server render. No config needed. Only works during a single request lifecycle.

  2. DATA_CACHE: persists fetch results across requests.

  3. DEFAULT: fetch() is cached (force-cache) unless dynamic signal detected
  4. DISABLE: fetch(url, { cache: "no-store" })
  5. TIMED: fetch(url, { next: { revalidate: 60 } }) — ISR-style, revalidate every 60s
  6. TAGGED: fetch(url, { next: { tags: ["products"] } }) — invalidate with revalidateTag("products")

  7. FULL_ROUTE_CACHE: caches rendered HTML + RSC payload for static routes.

  8. STATIC by default when no dynamic signals detected
  9. DYNAMIC when using: cookies(), headers(), searchParams, connection(), unstable_noStore()
  10. FORCE_STATIC: export const dynamic = "force-static"
  11. FORCE_DYNAMIC: export const dynamic = "force-dynamic"
  12. ISR: export const revalidate = 60

  13. ROUTER_CACHE: client-side cache of visited routes. Enables instant back/forward navigation.

  14. AUTO: previously visited routes served from cache
  15. 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+)?