Where You Read Request Data Decides What Can Be Cached

A store's product pages were identical for every visitor, and every one of them rendered from scratch on each request. The cause was a single line: the root layout read the session cookie to show a cart count in the header. That one dynamic read made the whole tree dynamic, so database load followed traffic directly.

The App Router gives you layouts, streaming and fine-grained caching, and it rewards knowing which parts of a page are shared and which are per-user. Below is how routes, layouts and loading states fit together, how Next.js 16 caching works with "use cache", tags and invalidation, and how to keep the personalised parts small.

If your pages render on every request
  • You wrote const { id } = params in Next 16 and got undefined
  • You don't know whether to put fetch() in a layout or a page
  • Your data refetches on every navigation and you can't tell what cached, what didn't, or why
  • You moved from Pages Router and your middleware.ts stopped working

Layouts, Routes and Rendering in Next.js 16

Folders are routes; files are UI. Inside any segment folder you can place a small set of file conventions:

  • layout.tsx — wraps every page in this segment and below; preserves state on navigation
  • page.tsx— the leaf UI rendered for the segment's URL
  • loading.tsx — auto-wraps the page in <Suspense> with this fallback
  • error.tsx — auto-wraps the segment in an ErrorBoundary
  • not-found.tsx — rendered when notFound() is called
  • template.tsx — like layout but re-mounts on navigation
  • route.ts — HTTP method handlers (GET, POST, …)
  • default.tsx — fallback for parallel route slots

flowchart TD
    A["app/layout.tsx (root)"] --> B["app/(marketing)/layout.tsx"]
    A --> C["app/dashboard/layout.tsx"]
    B --> D["app/(marketing)/page.tsx → /"]
    B --> E["app/(marketing)/about/page.tsx → /about"]
    C --> F["app/dashboard/page.tsx → /dashboard"]
    C --> G["app/dashboard/[id]/page.tsx → /dashboard/:id"]
    C --> H["app/dashboard/loading.tsx (Suspense fallback)"]
    C --> I["app/dashboard/error.tsx (ErrorBoundary)"]
    G --> J["app/dashboard/[id]/@analytics/page.tsx (parallel slot)"]

    style A fill:#4f46e5,color:#fff,stroke:#4338ca
    style B fill:#059669,color:#fff,stroke:#047857
    style C fill:#059669,color:#fff,stroke:#047857

Building Routes That Cache Well

Step 1: Root layout — required, must contain html and body

// app/layout.tsx
import { Providers } from './providers';
import './globals.css';

export const metadata = {
  title: { template: '%s | AI Wisdom', default: 'AI Wisdom' },
  description: 'Production patterns for AI engineers',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Step 2: Async page with Promise params (Next 16)

// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { db } from '@/lib/db';

type Props = {
  params: Promise<{ slug: string }>;          // Next 16: Promise
  searchParams: Promise<{ tab?: string }>;    // Next 16: Promise
};

export default async function PostPage({ params, searchParams }: Props) {
  const { slug } = await params;
  const { tab = 'read' } = await searchParams;

  const post = await db.post.findUnique({ where: { slug } });
  if (!post) notFound(); // renders nearest not-found.tsx

  return <Article post={post} initialTab={tab} />;
}

// Static generation hints — pre-render known slugs at build
export async function generateStaticParams() {
  const posts = await db.post.findMany({ select: { slug: true } });
  return posts.map(p => ({ slug: p.slug }));
}

Step 3: Loading and error boundaries — colocated

// app/posts/[slug]/loading.tsx — wraps page.tsx in <Suspense>
export default function Loading() {
  return <ArticleSkeleton />;
}

// app/posts/[slug]/error.tsx — must be a Client Component
'use client';
export default function Error({
  error,
  reset,
}: { error: Error & { digest?: string }; reset: () => void }) {
  return (
    <div>
      <p>Something broke: {error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Step 4: Cache Components — opt-in caching with 'use cache'

// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
  cacheComponents: true, // Next 16: opt into the new caching model
};
export default config;

// app/lib/data.ts — data-level cache
import { cacheLife, cacheTag } from 'next/cache';

export async function getPost(slug: string) {
  'use cache';                    // arguments become part of the cache key
  cacheLife('hours');             // refresh after 1h, serve stale up to 1d
  cacheTag(`post:${slug}`);     // tag for targeted revalidation
  return db.post.findUnique({ where: { slug } });
}

// app/page.tsx — UI-level cache
import { Suspense } from 'react';
import { cookies } from 'next/headers';

export default function Page() {
  return (
    <>
      <CachedHeader />
      <Suspense fallback={<UserSkeleton />}>
        <UserGreeting />
      </Suspense>
    </>
  );
}

async function CachedHeader() {
  'use cache';
  cacheLife('days');
  return <header>{await getStaticHeaderData()}</header>;
}

async function UserGreeting() {
  // Reads cookies → must be uncached and inside Suspense
  const theme = (await cookies()).get('theme')?.value ?? 'light';
  return <p>Theme: {theme}</p>;
}

Step 5: Server Actions for mutations + targeted revalidation

// app/posts/[slug]/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
import { db } from '@/lib/db';

export async function publishPost(slug: string) {
  await db.post.update({ where: { slug }, data: { published: true } });
  revalidateTag(`post:${slug}`); // bust the cached entry by tag
}

Step 6: Route Handlers — app/api/.../route.ts

// app/api/posts/route.ts
import { NextResponse } from 'next/server';

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const limit = Number(searchParams.get('limit') ?? 20);
  const posts = await db.post.findMany({ take: limit });
  return NextResponse.json(posts);
}

export async function POST(req: Request) {
  const body = await req.json();
  const created = await db.post.create({ data: body });
  return NextResponse.json(created, { status: 201 });
}

Step 7: Route groups, parallel routes, intercepting routes

// Route groups: parens don't appear in the URL — used to share a layout
// app/(marketing)/layout.tsx → wraps /, /about, /pricing
// app/(app)/dashboard/layout.tsx → wraps authenticated routes only

// Parallel routes: @slot folders render simultaneously into the layout
// app/dashboard/layout.tsx receives both children AND analytics props
export default function Layout({
  children, analytics,
}: { children: React.ReactNode; analytics: React.ReactNode }) {
  return (
    <div className="grid grid-cols-2 gap-4">
      <section>{children}</section>
      <aside>{analytics}</aside>
    </div>
  );
}

// Intercepting routes: app/feed/(.)photo/[id]/page.tsx
// Renders the photo as a modal when navigated from /feed,
// but as a full page on direct visit.

How App Router Projects Go Wrong

1. Forgetting params is async. Next 16 changed this. Destructuring without await gives undefined. Same for searchParams, cookies(), headers(), draftMode().

2. Confusing layouts and templates. Layouts persist across navigation (state preserved). Templates re-mount on every navigation (state reset). Use templates for things like enter animations, layouts for everything else.

3. error.tsx not catching root errors. An error.tsx in a segment catches errors in that segmentand below. To catch root errors, use app/global-error.tsx — it must include its own html and body.

4. Mixing the old caching mental model with Cache Components. With cacheComponents: true, fetch is no longer auto-cached. Cache explicitly with 'use cache'. Without Cache Components, the old fetch options (cache, next.revalidate) still apply.

5. Middleware called "middleware". Next 16 renamed it to Proxy. The file is nowapp/proxy.ts (or proxy.ts at the project root) — same Edge runtime, refined matcher API.

6. Server Actions called from forms without action. Use <form action={serverAction}> oruseActionState. Calling a Server Action inside onClick works but loses progressive enhancement.

7. Putting a route group inside another route group with the same parent layout. Multiple root-level layout.tsx files are allowed at most one per route — duplicates throw at build.

What I Would Do on a New Project

  • The default in Next 16 is: Server Components, async params/searchParams, opt-in caching via Cache Components.
  • Use loading.tsx + error.tsx + not-found.tsx at every meaningful segment — they auto-wrap the page.
  • Layouts preserve state on navigation; templates remount. Pick the right one.
  • Push 'use client' as deep into the tree as possible; Server Components are the default for a reason.
  • For caching, prefer 'use cache' + cacheLife + cacheTag over manual unstable_cache wrappers.
  • Use revalidateTag after Server Actions for surgical cache invalidation.
  • Route groups for shared layouts; parallel routes for dashboards; intercepting routes for modals from a feed.
  • Middleware is now Proxy — update middleware.ts → proxy.ts when migrating to Next 16.