---
name: forge-frontend-nextjs
description: Next.js 16 with React Server Components and Tailwind 4. Server-first by default, streaming with Suspense, server actions over API routes, app router conventions, async page params, prompt-cached metadata. Contains worked RSC page + server action + loading.tsx + error.tsx pattern. Use whenever writing Next.js 13+ (app router) code; pair with `forge-frontend` for visual taste.
license: MIT
---

# forge-frontend-nextjs

You are writing Next.js 16 with the app router, React Server Components, and Tailwind 4. Default agent-written Next code defaults everything to a client component, fetches data in `useEffect`, ignores streaming, and produces type errors on the async params. This skill exists to enforce the modern patterns the framework was built for.

The mental model: **server is the default, client is opt-in. Data fetching happens where the data lives. Streaming and Suspense are how you ship fast UIs without sacrificing UX.**

## Quick reference (the things you must never ship)

1. `'use client'` at the top of `layout.tsx` or `page.tsx` (pushes the whole subtree client).
2. `useEffect(() => { fetch(...) }, [])` for initial data in a route component.
3. An API route handler for a single mutation that has no external consumers.
4. `params` destructured synchronously: `function Page({ params }: { params: { id: string } })`.
5. `await fetch(...)` in a server component WITHOUT `next:` cache controls (or explicit `cache: 'no-store'`).
6. Big static metadata expressed via JSX or DOM operations.
7. Storing auth tokens in `localStorage` inside a Next.js client component.
8. Importing a `next/router` (pages router) symbol in app-router code.
9. `tailwind.config.js` with custom tokens in Tailwind 4 (move to `@theme`).
10. Inline `<script>` for analytics; use `next/script` with proper `strategy`.

## Hard rules

### Server vs client components

**1. Default is server. `'use client'` is opt-in and minimal.** Never mark a layout, page, or top-level component as client. Push the client boundary as deep into the tree as possible.

```tsx
// BAD: client at the root
'use client';
export default function Page() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <Header />
      <Hero />
      <button onClick={() => setCount(count + 1)}>{count}</button>
    </div>
  );
}

// GOOD: server page, client leaf
export default function Page() {
  return (
    <div>
      <Header />
      <Hero />
      <Counter />   {/* this file has 'use client'; rest stays server */}
    </div>
  );
}
```

**2. Client boundary is a single line at the top of one file.** Components below inherit. The skill is finding the smallest possible client subtree.

**3. Pass serializable data across server/client boundary.** Server components can pass children and serializable props. Functions, classes, Dates pre-serialization are not allowed.

**4. Server components cannot use hooks.** No `useState`, no `useEffect`, no `useRef`, no `useContext`. If you need any of these, you have a client component.

### Data fetching

**5. Fetch in server components, not `useEffect`.**

```tsx
// BAD: client + useEffect
'use client';
export default function OrdersPage() {
  const [orders, setOrders] = useState([]);
  useEffect(() => {
    fetch('/api/orders').then((r) => r.json()).then(setOrders);
  }, []);
  return <OrderList orders={orders} />;
}

// GOOD: server component, direct await
export default async function OrdersPage() {
  const orders = await db.orders.findMany({ orderBy: { created_at: 'desc' }, take: 50 });
  return <OrderList orders={orders} />;
}
```

**6. Use the extended `fetch` cache controls.**

```tsx
// time-based revalidation
const res = await fetch(url, { next: { revalidate: 60 } });   // every 60s

// tag-based revalidation
const res = await fetch(url, { next: { tags: ['orders'] } });
// then somewhere else: revalidateTag('orders');

// explicit no-cache for highly dynamic
const res = await fetch(url, { cache: 'no-store' });
```

**7. Parallel fetches via `Promise.all`.** Sequential awaits in a server component are sequential network requests.

```tsx
// BAD: serial
const user = await getUser();
const orders = await getOrders();
const invoices = await getInvoices();

// GOOD: parallel
const [user, orders, invoices] = await Promise.all([
  getUser(),
  getOrders(),
  getInvoices(),
]);
```

**8. Server actions for mutations.** `'use server'` at the top of an action file, then call directly from a form's `action` prop or from a client component. No API route needed for simple mutations.

```tsx
// app/actions/orders.ts
'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';

const Input = z.object({
  customer_id: z.string().uuid(),
  items: z.array(z.object({ sku: z.string(), quantity: z.number().int().positive() })).min(1),
});

type Result = { ok: true; orderId: string } | { ok: false; error: string };

export async function createOrder(formData: FormData): Promise<Result> {
  const raw = Object.fromEntries(formData);
  const parsed = Input.safeParse(raw);
  if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? 'validation_failed' };

  const order = await db.orders.create(parsed.data);
  revalidatePath('/orders');
  return { ok: true, orderId: order.id };
}
```

**9. Server actions return data, do not throw to the client.** Wrap in try/catch, return a `Result` shape. The client renders the failure state.

### Streaming and Suspense

**10. Wrap slow components in `<Suspense fallback={...}>`.** Framework streams the rest of the page while the slow component resolves.

```tsx
import { Suspense } from 'react';

export default function Page() {
  return (
    <>
      <Header />          {/* renders immediately */}
      <Suspense fallback={<SkeletonOrders />}>
        <OrdersList />   {/* slow async server component */}
      </Suspense>
      <Footer />
    </>
  );
}
```

**11. `loading.tsx` at any segment provides a Suspense fallback for that segment.** Use it; free progressive enhancement.

**12. `error.tsx` at any segment catches errors below it.** Without it, errors crash the whole route.

```tsx
// app/orders/error.tsx
'use client';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <h2>Could not load orders.</h2>
      <button onClick={reset}>Try again</button>
    </div>
  );
}
```

**13. `not-found.tsx` for 404s within a segment.** Call `notFound()` from a server component to trigger it.

### Routing

**14. Dynamic routes use `[param]`; in Next 16, params and searchParams are Promises.**

```tsx
// app/orders/[id]/page.tsx
type Props = {
  params: Promise<{ id: string }>;
  searchParams: Promise<{ tab?: string }>;
};

export default async function Page({ params, searchParams }: Props) {
  const { id } = await params;
  const { tab } = await searchParams;
  const order = await db.orders.findById(id);
  if (!order) notFound();
  return <OrderDetail order={order} activeTab={tab ?? 'summary'} />;
}
```

**15. Use `redirect()` for server-side redirects, not `<meta>` or client navigation.**

**16. Parallel routes (`@slot`) for independent panels.** Avoid stacking complex client state to coordinate panels.

**17. Route groups `(group)` organize without affecting URLs.** Use for layouts that apply to a subset.

### Metadata

**18. `export const metadata` for static, `generateMetadata` for dynamic.** Both go in `page.tsx` or `layout.tsx`, not a separate config file.

```tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Orders',
  description: 'Manage and view your orders.',
};

// dynamic
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { id } = await params;
  const order = await db.orders.findById(id);
  return { title: order ? `Order ${order.id.slice(0, 8)}` : 'Order' };
}
```

**19. `metadata.openGraph.images` needs explicit `url` and dimensions.** Without dimensions, Twitter and Facebook cards do not render properly.

**20. Use `dynamic = 'force-static' | 'force-dynamic'` only when default inference is wrong.**

### Caching

**21. Understand the four cache layers: Request Memoization, Data Cache, Full Route Cache, Router Cache.** Most cache "bugs" are one of these doing its job.

**22. `revalidatePath` and `revalidateTag` in server actions after mutations.** Without these, the client sees stale data.

**23. `unstable_cache` for caching arbitrary functions.** Wrap expensive computations, key by inputs.

### Styling (Tailwind 4)

**24. `@theme` block in `globals.css` defines tokens.** Not a `tailwind.config.js`.

```css
@import "tailwindcss";

@theme {
  --font-display: "Bricolage Grotesque", "Inter", sans-serif;
  --color-brand-100: #FBE4D5;
  --color-brand-500: #FF5C2B;
  --color-brand-900: #5A1B0B;
  --spacing-section: 6rem;
}
```

**25. Use `@layer base` for resets, `@layer components` for reusable patterns.** Utility classes cover the 95% case.

**26. Dark mode via `prefers-color-scheme`, not via a class toggle, unless you specifically need user-controlled themes.**

### Anti-patterns

| Pattern | Why wrong | Fix |
| --- | --- | --- |
| `'use client'` at top of layout.tsx | Whole subtree pushed client | Push boundary into a leaf component |
| `useEffect` initial fetch in a route | Defeats RSC | Async server component with await |
| API route for a single POST mutation | Boilerplate | Server action |
| `params.id` synchronous | Type error in Next 16 | `const { id } = await params` |
| `next/router` import | Pages router only | `next/navigation` for app router |
| `tailwind.config.js` with custom theme | Tailwind 4 dropped JS config | `@theme` in globals.css |
| Inline `<script>` for tracking | Blocks render | `<Script strategy="afterInteractive" />` |
| JWT in localStorage | XSS-readable | HttpOnly cookie via server action |
| Serial awaits in RSC | N round-trips | `Promise.all` |
| Manual `<meta>` tags in JSX | Brittle, not crawled the same | `metadata` export |

## Worked example: a complete app router page

```tsx
// app/orders/[id]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { db } from '@/lib/db';
import { OrderHeader } from './OrderHeader';
import { OrderItems } from './OrderItems';
import { OrderEvents } from './OrderEvents';

type Props = { params: Promise<{ id: string }> };

export const dynamic = 'force-dynamic';

export async function generateMetadata({ params }: Props) {
  const { id } = await params;
  const order = await db.orders.findById(id);
  return { title: order ? `Order ${order.id.slice(0, 8)}` : 'Order' };
}

export default async function OrderPage({ params }: Props) {
  const { id } = await params;
  const order = await db.orders.findById(id);
  if (!order) notFound();

  return (
    <div className="mx-auto max-w-4xl px-6 py-12">
      <OrderHeader order={order} />

      <Suspense fallback={<ItemsSkeleton />}>
        <OrderItems orderId={order.id} />
      </Suspense>

      <Suspense fallback={<EventsSkeleton />}>
        <OrderEvents orderId={order.id} />
      </Suspense>
    </div>
  );
}

function ItemsSkeleton() {
  return <div className="h-32 animate-pulse bg-zinc-100 rounded-md" />;
}
function EventsSkeleton() {
  return <div className="h-48 animate-pulse bg-zinc-100 rounded-md" />;
}
```

```tsx
// app/orders/[id]/loading.tsx
export default function Loading() {
  return <div className="mx-auto max-w-4xl px-6 py-12 animate-pulse">Loading...</div>;
}
```

```tsx
// app/orders/[id]/error.tsx
'use client';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div className="mx-auto max-w-4xl px-6 py-12">
      <h2 className="text-2xl font-medium mb-2">Could not load this order.</h2>
      <p className="text-zinc-600 mb-6">{error.message}</p>
      <button onClick={reset} className="bg-zinc-900 text-white px-4 py-2">Try again</button>
    </div>
  );
}
```

What this demonstrates: async params (rule 14); dynamic metadata (rule 18); server-component data fetching (rule 5); Suspense for slow children (rule 10); loading.tsx + error.tsx per segment (rules 11-12); `notFound()` for missing resource (rule 13); minimal client surface (only `error.tsx` is client).

## Workflow

When building a Next.js route:

1. **Start in `page.tsx` as a server component.** Default to server until proven otherwise.
2. **Fetch data inline. Use `Promise.all` for parallel server fetches.**
3. **Wrap slow sections in `Suspense` with a fallback.**
4. **Add `loading.tsx` and `error.tsx` to the segment.**
5. **Only when you need state or interaction, extract the smallest client subtree.**
6. **For mutations, write a server action. No API route needed.**
7. **Add metadata via `export const metadata`.**

## Verification

```bash
bash skills/design/forge-frontend-nextjs/verify/check_nextjs.sh path/to/file
```

Flags: `'use client'` in layout.tsx, useEffect+fetch in a client component, single-POST API routes.

## When to skip this skill

- Pages router (legacy Next.js).
- Other React frameworks (Remix, TanStack Start).
- Vite-based React apps - different routing model entirely.

## Related skills

- [`forge-frontend`](../forge-frontend/SKILL.md) - visual taste (compose with this skill).
- [`forge-api-design`](../../backend/forge-api-design/SKILL.md) - parallels for server-action return shapes.
- [`forge-error-handling`](../../backend/forge-error-handling/SKILL.md) - Result types in server actions.
