The Next.js App Router introduced a fundamentally different mental model from the Pages Router: components are server components by default, data fetching happens in components rather than getServerSideProps, and mutations use server actions instead of API routes. These changes are powerful but require rethinking patterns that worked well before.
Claude Code generates App Router code correctly — understanding when to use use client, how cache invalidation works, and where server actions fit versus route handlers.
App Router Fundamentals
CLAUDE.md for App Router Projects
## Next.js App Router Conventions
- Next.js 14 with App Router (src/app/)
- TypeScript strict mode
- All DB queries go in server components or server actions — never in client components
- Client components: only for interactivity, forms, and browser APIs
- Server actions in separate files: src/app/actions/{resource}.ts
- Caching: use unstable_cache for expensive DB queries, revalidatePath/revalidateTag for invalidation
- Error boundaries: error.tsx per segment, global in src/app/error.tsx
- Loading states: loading.tsx per segment for automatic Suspense
## Key patterns
- Data fetching in server components: async function + await db.query() directly
- Mutations via server actions: "use server" directive, revalidatePath/revalidateTag after mutation
- Forms: use action={serverAction} or useActionState hook
- Don't use useEffect for data fetching — it doesn't work in server components anyway
Server Components vs Client Components
I keep getting "useState is not available in Server Components".
Explain when to use "use client" and restructure this component.
The core rule: components are server components unless they need browser APIs or interactivity.
// src/app/dashboard/page.tsx
// Server component — fetch data directly
import { db } from '@/lib/db';
import { DashboardClient } from './DashboardClient';
import { StatsCard } from './StatsCard'; // Another server component
// No "use client" — runs on server
export default async function DashboardPage() {
// Direct DB access — runs at request time (or cached)
const [stats, recentOrders] = await Promise.all([
db.query('SELECT COUNT(*) as total, SUM(amount_cents) as revenue FROM orders WHERE month = current_month'),
db.query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 10'),
]);
return (
<main>
<h1>Dashboard</h1>
{/* Stats render on server — no JS needed */}
<div className="stats-grid">
<StatsCard label="Orders" value={stats.total} />
<StatsCard label="Revenue" value={`$${(stats.revenue / 100).toFixed(2)}`} />
</div>
{/* Pass serializable data to client component for interactivity */}
<DashboardClient initialOrders={recentOrders} />
</main>
);
}
// src/app/dashboard/DashboardClient.tsx
'use client';
// Client component — handles filtering, sorting, and UI state
import { useState, useTransition } from 'react';
import type { Order } from '@/types';
export function DashboardClient({ initialOrders }: { initialOrders: Order[] }) {
const [filter, setFilter] = useState<'all' | 'pending' | 'completed'>('all');
const [isPending, startTransition] = useTransition();
const filteredOrders = initialOrders.filter(
order => filter === 'all' || order.status === filter,
);
return (
<section>
<div role="group" aria-label="Order filter">
{(['all', 'pending', 'completed'] as const).map(f => (
<button
key={f}
onClick={() => startTransition(() => setFilter(f))}
aria-pressed={filter === f}
>
{f}
</button>
))}
</div>
<div aria-busy={isPending}>
{filteredOrders.map(order => <OrderRow key={order.id} order={order} />)}
</div>
</section>
);
}
Server Actions
Create a server action for creating an order.
It should validate input, write to the database, send a confirmation email,
and revalidate the dashboard cache.
// src/app/actions/orders.ts
'use server';
import { z } from 'zod';
import { revalidatePath, revalidateTag } from 'next/cache';
import { db } from '@/lib/db';
import { sendOrderConfirmation } from '@/lib/email';
import { auth } from '@/lib/auth';
const createOrderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
})).min(1).max(50),
shippingAddressId: z.string().uuid(),
});
// Return type for useActionState
type ActionResult = { success: true; orderId: string } | { success: false; error: string; fieldErrors?: Record<string, string[]> };
export async function createOrder(
_prevState: ActionResult | null,
formData: FormData,
): Promise<ActionResult> {
// Auth check
const session = await auth();
if (!session?.user) {
return { success: false, error: 'Unauthorized' };
}
// Parse and validate input
const rawData = {
items: JSON.parse(formData.get('items') as string),
shippingAddressId: formData.get('shippingAddressId') as string,
};
const parseResult = createOrderSchema.safeParse(rawData);
if (!parseResult.success) {
return {
success: false,
error: 'Validation failed',
fieldErrors: parseResult.error.flatten().fieldErrors,
};
}
const { items, shippingAddressId } = parseResult.data;
try {
// Database transaction
const order = await db.transaction(async (trx) => {
// Calculate total
const products = await trx('products')
.whereIn('id', items.map(i => i.productId))
.select('id', 'price_cents', 'stock');
const total = items.reduce((sum, item) => {
const product = products.find(p => p.id === item.productId);
if (!product) throw new Error(`Product ${item.productId} not found`);
if (product.stock < item.quantity) throw new Error(`Insufficient stock for ${item.productId}`);
return sum + (product.price_cents * item.quantity);
}, 0);
const [order] = await trx('orders')
.insert({
user_id: session.user.id,
shipping_address_id: shippingAddressId,
total_cents: total,
status: 'pending',
})
.returning('*');
await trx('order_items').insert(
items.map(item => ({ order_id: order.id, product_id: item.productId, quantity: item.quantity }))
);
return order;
});
// Side effects after successful transaction
await sendOrderConfirmation(session.user.email, order.id);
// Invalidate caches
revalidatePath('/dashboard');
revalidateTag('user-orders');
return { success: true, orderId: order.id };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
Using Server Actions in Forms
// src/app/checkout/CheckoutForm.tsx
'use client';
import { useActionState } from 'react';
import { createOrder } from '@/app/actions/orders';
export function CheckoutForm({ items, addresses }) {
const [state, formAction, isPending] = useActionState(createOrder, null);
// Redirect on success
useEffect(() => {
if (state?.success) {
router.push(`/orders/${state.orderId}/confirmation`);
}
}, [state]);
return (
<form action={formAction}>
<input
type="hidden"
name="items"
value={JSON.stringify(items)}
/>
<select name="shippingAddressId">
{addresses.map(addr => (
<option key={addr.id} value={addr.id}>{addr.label}</option>
))}
</select>
{state && !state.success && (
<p role="alert" className="error">{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Placing order...' : 'Place order'}
</button>
</form>
);
}
Streaming with Suspense
The dashboard loads slowly because it waits for all data before
rendering anything. Stream each section independently.
// src/app/dashboard/page.tsx
import { Suspense } from 'react';
import { StatsCards, StatsCardsSkeleton } from './StatsCards';
import { RecentOrders, RecentOrdersSkeleton } from './RecentOrders';
import { ActivityFeed, ActivityFeedSkeleton } from './ActivityFeed';
export default function DashboardPage() {
// No await here — Suspense handles loading state
return (
<main>
<h1>Dashboard</h1>
{/* Each section loads independently */}
<Suspense fallback={<StatsCardsSkeleton />}>
<StatsCards /> {/* Fetches its own data */}
</Suspense>
<div className="dashboard-grid">
<Suspense fallback={<RecentOrdersSkeleton />}>
<RecentOrders /> {/* Slow query — doesn't block other sections */}
</Suspense>
<Suspense fallback={<ActivityFeedSkeleton />}>
<ActivityFeed /> {/* Independent data source */}
</Suspense>
</div>
</main>
);
}
// src/app/dashboard/StatsCards.tsx
async function StatsCards() {
const stats = await db.query(`
SELECT
COUNT(*) FILTER (WHERE status = 'completed') AS completed_orders,
SUM(total_cents) FILTER (WHERE status = 'completed') AS revenue,
COUNT(*) FILTER (WHERE status = 'pending') AS pending_orders
FROM orders WHERE created_at > NOW() - INTERVAL '30 days'
`);
return (
<div className="stats-grid">
{/* Renders when this query resolves — independently of other sections */}
</div>
);
}
Users see content as it loads rather than waiting for the slowest query. The HTML streams to the browser — React fills in each Suspense boundary as its data resolves.
Caching Strategy
The product catalog changes infrequently but gets hundreds of
requests per minute. Cache it aggressively but invalidate when
products are updated.
// src/lib/queries/products.ts
import { unstable_cache } from 'next/cache';
// Cache for 1 hour, tagged for targeted invalidation
export const getCatalog = unstable_cache(
async () => {
return db.query('SELECT * FROM products WHERE active = true ORDER BY category, name');
},
['product-catalog'],
{
revalidate: 3600, // 1 hour max staleness
tags: ['products'], // Can be invalidated with revalidateTag('products')
},
);
// When a product is updated (server action)
export async function updateProduct(productId: string, data: ProductUpdate) {
await db('products').where('id', productId).update(data);
// Invalidate catalog cache — next request will re-fetch
revalidateTag('products');
// Also invalidate the specific product page
revalidatePath(`/products/${productId}`);
}
For the foundational Next.js patterns including App Router routing and API routes, see the Next.js guide. For testing Next.js server components and server actions with Vitest and testing-library, the testing guide covers the setup. The Claude Skills 360 bundle includes Next.js App Router skill sets for server component patterns, caching strategies, and migration guides. Start with the free tier to try App Router code generation.