Problem Context

The Pages Router served Next.js for years โ€” file-based routing under pages/, getServerSideProps /getStaticProps for data, a single client-rendered tree. The App Router (app/), now the default in Next.js 16, replaces all of that with React Server Components, nested layouts, streaming, server actions, and a new caching model called Cache Components.

Migration confusion is real: params is now a Promise, the data-fetching API is gone, getServerSidePropsdoesn't exist, middleware became Proxy, and caching opt-in is now via the 'use cache' directive pluscacheLife/cacheTag primitives. This guide is the working mental model for App Router in 2026.

๐Ÿค” Sound familiar?
  • 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

This guide covers Next.js 16 routing primitives โ€” layouts, pages, loading/error files, route groups, parallel routes, and the new caching model.

Concept Explanation

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

Implementation

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.

Pitfalls

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.

Practical Takeaways

  • 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.