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.
- You wrote
const { id } = paramsin Next 16 and gotundefined - 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.tsstopped 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 navigationpage.tsx— the leaf UI rendered for the segment's URLloading.tsx— auto-wraps thepagein<Suspense>with this fallbackerror.tsx— auto-wraps the segment in anErrorBoundarynot-found.tsx— rendered whennotFound()is calledtemplate.tsx— like layout but re-mounts on navigationroute.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.tsxat 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+cacheTagover manualunstable_cachewrappers. - Use
revalidateTagafter 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.tswhen migrating to Next 16.

