Agent skill

nextjs-app-router-patterns

Next.js App Router patterns — server vs client components, data fetching, caching, streaming, parallel routes, and error boundaries

Stars 163
Forks 31

Install this agent skill to your Project

npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/other/other/nextjs-app-router-patterns-generaljerel-chalk-skills

SKILL.md

Next.js App Router Patterns

Overview

Reference guide for idiomatic Next.js 14+ App Router patterns. Apply these when building pages, layouts, and data flows to fully leverage server components, streaming, and the caching system.

Server vs Client Component Decision Tree

Does the component need...
├── Browser APIs (window, localStorage, navigator)?
│   └── YES → "use client"
├── Event listeners (onClick, onChange, onSubmit)?
│   └── YES → "use client"
├── useState, useEffect, useRef, or custom hooks?
│   └── YES → "use client"
├── Only data fetching and rendering?
│   └── YES → Server Component (default) ✓
├── Third-party library that uses hooks or browser APIs?
│   └── YES → "use client" wrapper component
└── Static content (no interactivity)?
    └── YES → Server Component (default) ✓

Key Principle: Push "use client" Down

Keep the boundary as low as possible. Only the interactive leaf should be a client component.

app/
  dashboard/
    page.tsx          ← Server Component (fetches data)
    DashboardGrid.tsx ← Server Component (layout)
    MetricCard.tsx    ← Server Component (displays data)
    ChartWidget.tsx   ← "use client" (uses chart library with hooks)
    FilterBar.tsx     ← "use client" (has onChange handlers)
tsx
// app/dashboard/page.tsx — Server Component
import { getMetrics } from "@/lib/data";
import { DashboardGrid } from "./DashboardGrid";
import { FilterBar } from "./FilterBar";

export default async function DashboardPage() {
  const metrics = await getMetrics(); // Direct DB/API call, no useEffect

  return (
    <div>
      <FilterBar /> {/* Client island for interactivity */}
      <DashboardGrid metrics={metrics} /> {/* Server component */}
    </div>
  );
}

Data Fetching Patterns

Server Components: Direct Async

tsx
// app/users/page.tsx
import { db } from "@/lib/db";

export default async function UsersPage() {
  const users = await db.user.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Parallel Data Fetching

Fetch multiple resources in parallel with Promise.all to avoid waterfalls.

tsx
export default async function ProfilePage({ params }: { params: { id: string } }) {
  // Parallel — both start immediately
  const [user, posts] = await Promise.all([
    getUser(params.id),
    getUserPosts(params.id),
  ]);

  return (
    <>
      <UserHeader user={user} />
      <PostList posts={posts} />
    </>
  );
}

Data Fetching with Preloading

For components deeper in the tree, use the preload pattern.

tsx
// lib/data.ts
import { cache } from "react";

export const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } });
});

// Preload function — call early, consume later
export const preloadUser = (id: string) => {
  void getUser(id);
};

// app/user/[id]/page.tsx
import { getUser, preloadUser } from "@/lib/data";

export default async function UserPage({ params }: { params: { id: string } }) {
  preloadUser(params.id); // Start fetch early
  // ... other work ...
  const user = await getUser(params.id); // Deduplicated, may already be resolved
  return <Profile user={user} />;
}

Caching Strategies

Fetch Cache (Request Deduplication)

tsx
// These two calls in the same render are automatically deduplicated
async function UserName({ id }: { id: string }) {
  const user = await fetch(`/api/users/${id}`); // cached
  return <span>{user.name}</span>;
}

async function UserAvatar({ id }: { id: string }) {
  const user = await fetch(`/api/users/${id}`); // same URL = deduplicated
  return <img src={user.avatar} />;
}

Revalidation

tsx
// Time-based revalidation
const data = await fetch(url, { next: { revalidate: 60 } }); // Revalidate every 60s

// On-demand revalidation (in a Server Action or Route Handler)
import { revalidatePath, revalidateTag } from "next/cache";

// By path
revalidatePath("/dashboard");

// By tag
const data = await fetch(url, { next: { tags: ["users"] } });
// Later:
revalidateTag("users");

// Opt out of caching
const data = await fetch(url, { cache: "no-store" });

Route Segment Config

tsx
// app/dashboard/layout.tsx
export const dynamic = "force-dynamic"; // Never cache this segment
export const revalidate = 60; // Revalidate every 60s

Streaming with Suspense

Wrap slow parts in Suspense to stream the page progressively.

tsx
// app/dashboard/page.tsx
import { Suspense } from "react";
import { SlowAnalytics } from "./SlowAnalytics";
import { QuickSummary } from "./QuickSummary";

export default function DashboardPage() {
  return (
    <div>
      {/* Renders immediately */}
      <QuickSummary />

      {/* Streams in when ready */}
      <Suspense fallback={<AnalyticsSkeleton />}>
        <SlowAnalytics />
      </Suspense>
    </div>
  );
}

Loading States (file convention)

app/
  dashboard/
    page.tsx
    loading.tsx    ← Automatic Suspense boundary for the page
    error.tsx      ← Error boundary for the segment
    not-found.tsx  ← Shown when notFound() is called
tsx
// app/dashboard/loading.tsx
export default function Loading() {
  return <DashboardSkeleton />;
}

Parallel Routes

Render multiple pages in the same layout simultaneously.

app/
  @analytics/
    page.tsx       ← Analytics panel
    loading.tsx
  @feed/
    page.tsx       ← Feed panel
    loading.tsx
  layout.tsx       ← Receives both as props
tsx
// app/layout.tsx
export default function Layout({
  children,
  analytics,
  feed,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  feed: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-3">
      <main className="col-span-2">{children}</main>
      <aside>
        {analytics}
        {feed}
      </aside>
    </div>
  );
}

Intercepting Routes

Show a route in a modal while preserving the background page.

app/
  photos/
    [id]/
      page.tsx        ← Full photo page (direct navigation)
  @modal/
    (.)photos/[id]/
      page.tsx        ← Modal overlay (intercepted from feed)
    default.tsx       ← Returns null when no modal active
  layout.tsx
  page.tsx            ← Photo feed
tsx
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from "@/components/Modal";
import { getPhoto } from "@/lib/data";

export default async function PhotoModal({ params }: { params: { id: string } }) {
  const photo = await getPhoto(params.id);
  return (
    <Modal>
      <img src={photo.url} alt={photo.alt} />
    </Modal>
  );
}

Error Boundaries

tsx
// app/dashboard/error.tsx
"use client"; // Error boundaries must be client components

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div role="alert">
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Anti-patterns

"use client" on Everything

Adding "use client" to every component defeats the purpose of RSC. You lose direct data access, increase bundle size, and add unnecessary hydration.

Client-side Data Fetching When Server Would Work

tsx
// BAD: useEffect + fetch in a page component
"use client";
export default function UsersPage() {
  const [users, setUsers] = useState([]);
  useEffect(() => { fetch("/api/users").then(/* ... */) }, []);
}

// GOOD: Server component with direct data access
export default async function UsersPage() {
  const users = await db.user.findMany();
  return <UserList users={users} />;
}

Misunderstanding Caching

  • fetch in server components is cached by default in production
  • POST requests are NOT cached
  • Route Handlers with GET are cached when using export const dynamic = "auto"
  • Dynamic functions (cookies(), headers(), searchParams) opt the entire route out of static rendering

Not Leveraging Layouts

Layouts persist across navigations and do not re-render. Put shared UI (nav, sidebar) in layouts, not in every page.

Expand your agent's capabilities with these related and highly-rated skills.

Didn't find tool you were looking for?

Be as detailed as possible for better results