Cursor Rules for Next.js 15 App Router: The Complete Guide to AI-Assisted Next.js Development
Next.js 15 with the App Router is the framework that lets you ship a statically-rendered marketing site in a morning and a tangled mess of 'use client' directives, uncached data fetches, and stale ISR by the end of the quarter. The first regression is almost always a hydration mismatch: a Server Component renders new Date() into a page, a Client Component re-renders with a slightly different timestamp, and every page in production logs a hydration warning that nobody reads. The second is 'use client' pasted at the top of a layout because "the search bar needs state" — turning the entire subtree into client-rendered JavaScript, shipping hundreds of kilobytes of the app down to the browser, and losing the Server Components performance wins. The third is fetch(url) without cache: 'force-cache' or next: { revalidate: ... }, which in Next 15 defaults to no-store — every request hits the origin every time, the build gets slow, and the CDN is bypassed for no reason.
Then you add an AI assistant.
Cursor and Claude Code were trained on Next.js code that spans a decade — Pages Router with getServerSideProps / getStaticProps / getInitialProps, _app.tsx with global Redux, Pages-style API routes in pages/api/*, useEffect hooks that fetch on mount from Client Components that should be Server Components, next/head instead of the Metadata API, next/image with layout="responsive" (deprecated since Next 13), image optimization bypassed with unoptimized, import "server-only" absent from modules that touch secrets, cookies() and headers() called in the wrong scope, revalidatePath() at random without a clear cache strategy, and server actions accidentally included in a Client Component. Ask for "a page that shows a list of posts with a search box," and you get 'use client' at the top, useState + useEffect + fetch('/api/posts'), no caching, no loading state, no error boundary, no metadata, and a search handler that router.push(?q=${q}) without debounce. It runs. It is not the Next.js you should ship in 2026.
The fix is .cursorrules — one file in the repo that tells the AI what idiomatic modern App Router looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.
How Cursor Rules Work for Next.js Projects
Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended). For Next.js 15 I recommend modular rules so the App Router's conventions don't bleed into legacy Pages code (if you still have any), and so cross-cutting concerns like caching and metadata stay visible:
.cursor/rules/
nextjs-components.mdc # RSC default, 'use client' boundary
nextjs-data.mdc # fetch caching, revalidate, tags
nextjs-actions.mdc # Server Actions vs route handlers
nextjs-streaming.mdc # loading.tsx, error.tsx, Suspense
nextjs-metadata.mdc # generateMetadata, Open Graph, robots
nextjs-middleware.mdc # edge-safe middleware, matchers
nextjs-forms.mdc # progressive enhancement, useActionState
nextjs-testing.mdc # Playwright e2e + Vitest unit + MSW
Frontmatter controls activation: globs: ["app/**/*.{ts,tsx}", "middleware.ts", "next.config.*"] with alwaysApply: false. Now the rules.
Rule 1: Server Components By Default — 'use client' Only For Interactivity, And At The Lowest Level
The App Router's biggest lever is that every component is a Server Component unless you opt out. Cursor's training is weighted toward the Pages-era default of "React component → Client Component," so it sprinkles 'use client' at the top of every file that imports useState — and often at the top of layouts, which turns the entire subtree into a client-rendered SPA. The rule: Server Components are default, Client Components are scoped to the smallest leaf that actually needs them (a form, a search input, a modal). Server-to-Client boundaries pass only serializable props, never closures or class instances.
The rule:
Every component is a Server Component by default. Only add `'use client'`
when the component specifically needs one of:
- React state/effects (useState, useEffect, useReducer).
- Browser-only APIs (window, document, localStorage, IntersectionObserver).
- Event handlers that require interactive-JS behavior (onClick to open
a modal, onSubmit with client-side handling).
- Third-party components that are client-only (Framer Motion, some
chart libs, custom hooks inside them).
`'use client'` belongs at the top of the LEAF that needs it, not the
layout or page that imports it. Example:
app/(marketing)/page.tsx — Server Component.
Contains a <NewsletterForm /> — that file has 'use client'.
Layouts, pages, and generic container components are ALWAYS Server
Components. A Client layout is a sign the boundary is in the wrong place.
Server-to-Client prop passing:
- Props are serializable: primitives, plain objects, arrays, Date,
Map, Set.
- Passing functions to Client Components is only valid when they are
Server Actions (see Rule 3). Otherwise it is a TypeError at runtime.
- Never pass a class instance, a Promise (unless intentionally
streamed and awaited in the client), or a function that isn't a
Server Action.
Client Components CAN import and render Server Components passed as
`children` / slots. This is the "thin client wrapper" pattern:
// ClientShell.tsx ('use client')
export function ClientShell({ children }: { children: ReactNode }) { ... }
// page.tsx (Server Component)
<ClientShell><ServerContent /></ClientShell>
`import "server-only"` is at the top of any module that imports secrets,
database clients, or any code that must never ship to the browser.
`import "client-only"` at the top of modules that use browser-only
APIs, so Next throws at build if a Server Component imports them.
Context providers that need to wrap a tree (ThemeProvider, QueryClient-
Provider): wrap inside `app/providers.tsx` ('use client'), and import
into `app/layout.tsx` as `<Providers>{children}</Providers>`. The
providers file is the ONE 'use client' at the top of the tree; the
layout itself remains a Server Component.
No `useEffect` in the top-level page to fetch initial data. That is a
Server Component's job (see Rule 2).
Before — 'use client' at the layout, useEffect fetch, no streaming:
// app/(shop)/layout.tsx
'use client';
import { useEffect, useState } from 'react';
export default function ShopLayout({ children }: { children: React.ReactNode }) {
const [categories, setCategories] = useState([]);
useEffect(() => {
fetch('/api/categories').then(r => r.json()).then(setCategories);
}, []);
return <div><Sidebar categories={categories} />{children}</div>;
}
Entire (shop) group is client-rendered. Categories refetch on every navigation. No SSR, no caching.
After — Server Component layout, client leaf only where needed:
// app/(shop)/layout.tsx
import { getCategories } from '@/lib/data/categories';
import { Sidebar } from '@/components/Sidebar';
export default async function ShopLayout({ children }: { children: React.ReactNode }) {
const categories = await getCategories();
return (
<div className="flex">
<Sidebar categories={categories} />
<main className="flex-1">{children}</main>
</div>
);
}
// components/Sidebar.tsx (Server Component)
import { CategorySearch } from './CategorySearch';
export function Sidebar({ categories }: { categories: Category[] }) {
return (
<aside>
<CategorySearch />
<ul>{categories.map(c => <li key={c.id}>{c.name}</li>)}</ul>
</aside>
);
}
// components/CategorySearch.tsx ('use client')
'use client';
import { useState } from 'react';
export function CategorySearch() {
const [q, setQ] = useState('');
return <input value={q} onChange={e => setQ(e.target.value)} />;
}
Only the tiny search input ships as client JavaScript. Categories are fetched once on the server, cached per Rule 2.
Rule 2: Data Fetching — fetch With Explicit cache and revalidate, Tag-Based Invalidation, Never useEffect
Next 15 changed the fetch default from force-cache to no-store. That means every AI-generated await fetch(...) without options is uncached — every request hits the origin, the build never produces static HTML, CDN headers get wrong. The rule: every fetch in a Server Component or server-only module explicitly declares its caching strategy, data fetches live in lib/data/*.ts modules (not inline in components), and invalidation is tag-based through revalidateTag.
The rule:
Every `fetch` in a Server Component or server module declares one of:
- `cache: 'force-cache'` — static, cached until invalidated.
- `next: { revalidate: <seconds> }` — time-based revalidation (ISR).
- `next: { tags: ['orders', 'user:42'] }` — tag-based invalidation
via `revalidateTag`.
- `cache: 'no-store'` — always fresh, never cached. Only for genuinely
per-request data (auth-scoped dashboards, uncacheable queries).
A `fetch` without an options object is a code-review reject. In Next 15
the default is no-store, so the build won't fail — but the production
cost of uncached traffic is real.
Data-access helpers live in `lib/data/*.ts` or `lib/db/*.ts`, never
inline in page components. Each helper:
- Has `import 'server-only'` at the top.
- Returns typed results (Zod-parsed or Drizzle-inferred, never `any`).
- Declares its caching via `unstable_cache` or `fetch`'s next options.
`unstable_cache` for database queries (no fetch boundary):
export const getOrdersForUser = unstable_cache(
async (userId: string) => db.select(...).where(eq(orders.userId, userId)),
['orders-for-user'],
{ revalidate: 60, tags: ['orders', `user:${userId}`] }
);
`cookies()`, `headers()`, `draftMode()`, `noStore()` in a page or server
module marks the route as dynamic. If your page needs auth, be aware
this opts it out of static rendering — consider a mix of cached data
and per-request auth checks.
`revalidatePath('/orders')` and `revalidateTag('orders')` are called
from Server Actions or route handlers after a mutation that changes
cached content. Every mutation writes both: the action modifies data,
then invalidates the affected tags/paths.
Never fetch in a `useEffect` on initial render for data a Server
Component can fetch. Client-side fetching is reserved for:
- Interactive state that changes post-load (infinite scroll,
client-only filters).
- Data fetched in response to user action (not on mount).
- Data that must be fresh on every tab focus (use SWR / React Query
with `staleTime`).
React Query / SWR lives inside Client Components only. The initial
data is hydrated from the Server Component via `initialData` /
`fallback`.
Parallel data fetches:
- `const [a, b] = await Promise.all([fetchA(), fetchB()]);` — both
requests fire concurrently.
- Sequential fetches (where B depends on A) are a waterfall and must
be justified.
`searchParams` / `params`: typed in the page component with Zod
validation. Never passed through untyped.
Before — inline fetch with no caching, client-side fetch on mount:
'use client';
import { useEffect, useState } from 'react';
export default function OrdersPage() {
const [orders, setOrders] = useState([]);
useEffect(() => {
fetch('/api/orders').then(r => r.json()).then(setOrders);
}, []);
return <ul>{orders.map(o => <li key={o.id}>{o.total}</li>)}</ul>;
}
Client waterfall. No caching. Empty list for 300ms while the client awaits the API. No error handling.
After — server component, typed data helper, tagged cache:
// lib/data/orders.ts
import 'server-only';
import { unstable_cache } from 'next/cache';
import { z } from 'zod';
import { db } from './db';
const OrderSchema = z.object({
id: z.number(),
total: z.number(),
status: z.enum(['pending', 'shipped', 'delivered', 'cancelled']),
createdAt: z.coerce.date(),
});
export type Order = z.infer<typeof OrderSchema>;
export const getOrdersForUser = (userId: string) => unstable_cache(
async () => {
const rows = await db.select().from(orders).where(eq(orders.userId, userId));
return z.array(OrderSchema).parse(rows);
},
['orders-for-user', userId],
{ revalidate: 60, tags: ['orders', `user:${userId}`] }
)();
// app/orders/page.tsx (Server Component)
import { Suspense } from 'react';
import { getCurrentUser } from '@/lib/auth';
import { getOrdersForUser } from '@/lib/data/orders';
import { OrderList } from './OrderList';
export default async function OrdersPage() {
const user = await getCurrentUser();
const orders = await getOrdersForUser(user.id);
return (
<Suspense fallback={<p>Loading orders…</p>}>
<OrderList orders={orders} />
</Suspense>
);
}
Server-rendered HTML on first byte. Cached for 60s. Invalidated by revalidateTag('orders') from the Server Action that creates an order.
Rule 3: Server Actions Over Route Handlers For Mutations — Progressive Enhancement, useActionState
Cursor's default for "handle a form submission" is a fetch('/api/create-order', { method: 'POST' }) from a Client Component. That works but re-invents the wheel: no progressive enhancement (form breaks without JS), no integrated revalidation, no access to server-only modules, no built-in CSRF. Server Actions (stable in Next 14.1, refined in 15) are the idiomatic path: a function with 'use server', called directly from a form's action prop, useActionState for returning validation errors and pending state.
The rule:
Mutations use Server Actions. Route handlers (`route.ts`) are reserved
for:
- Webhook receivers (third-party calls YOU, where you need a URL).
- OAuth callback endpoints.
- Endpoints consumed by non-browser clients (mobile apps, cron jobs,
integrations).
- Streaming or non-JSON response bodies.
Server Actions live in one of:
- A file with `'use server'` at the top (actions-only file).
- A file where each exported async function starts with `'use server'`
in its body (per-function tagging).
They are imported into Server OR Client Components; the framework
handles the RPC.
Every Server Action:
- Accepts `FormData` (when invoked from a form `action`) or typed
args (when invoked programmatically).
- Parses input with Zod at the top. `formData.get('name')` is
never used directly.
- Returns a discriminated result: { success: true, data: T } |
{ success: false, error: { field?: string; message: string } }.
- Calls `revalidateTag` or `revalidatePath` for affected caches.
- Authorizes the caller (reads session, checks permissions) — DO
NOT rely on middleware for authorization inside actions.
Forms use `<form action={action}>`, not `onSubmit` with a fetch. This
gives progressive enhancement — forms work before JS loads.
`useActionState(action, initialState)` (formerly `useFormState`) in
Client Components to surface pending state and result.
`useFormStatus()` inside a submit button for the `pending` boolean,
not a sibling `useState` managed manually.
Redirect after mutation via `redirect('/orders/123')` from inside the
action — it throws a special error that Next intercepts.
Do NOT call Server Actions inside useEffect / on mount — that is a
waterfall. Initial data is fetched by the Server Component; actions
fire on user interaction.
File uploads: multipart via FormData; server-side streaming to a
temp file, then S3 via the SDK. Never load the whole file into memory
in the action.
Rate limiting (Upstash, Redis) lives in action bodies for any public
surface. It is NOT middleware's job.
Error boundary: actions throw on infrastructure failures (DB down);
those propagate to the nearest `error.tsx`. Validation errors are
returned in the result, NOT thrown.
Before — client fetch, no validation, no revalidation:
'use client';
export default function CreateOrder() {
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
await fetch('/api/orders', { method: 'POST', body: fd });
location.reload();
};
return <form onSubmit={onSubmit}>...</form>;
}
Form requires JS. location.reload is a hammer. No validation errors surface.
After — Server Action, Zod input, tag invalidation, useActionState:
// app/orders/actions.ts
'use server';
import { z } from 'zod';
import { redirect } from 'next/navigation';
import { revalidateTag } from 'next/cache';
import { getCurrentUser } from '@/lib/auth';
import { createOrder } from '@/lib/data/orders';
const Schema = z.object({
productIds: z.array(z.coerce.number().int().positive()).min(1),
shippingAddressId: z.coerce.number().int().positive(),
});
export type CreateOrderState =
| { status: 'idle' }
| { status: 'error'; message: string; fieldErrors?: Record<string, string[]> };
export async function createOrderAction(
_prev: CreateOrderState,
formData: FormData
): Promise<CreateOrderState> {
const user = await getCurrentUser();
const parsed = Schema.safeParse({
productIds: formData.getAll('productIds'),
shippingAddressId: formData.get('shippingAddressId'),
});
if (!parsed.success) {
return { status: 'error', message: 'Validation failed', fieldErrors: parsed.error.flatten().fieldErrors };
}
const order = await createOrder({ userId: user.id, ...parsed.data });
revalidateTag('orders');
revalidateTag(`user:${user.id}`);
redirect(`/orders/${order.id}`);
}
// app/orders/new/page.tsx
import { CreateOrderForm } from './CreateOrderForm';
export default function Page() {
return <CreateOrderForm />;
}
// app/orders/new/CreateOrderForm.tsx
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createOrderAction } from '../actions';
function SubmitButton() {
const { pending } = useFormStatus();
return <button type="submit" disabled={pending}>{pending ? 'Creating…' : 'Create order'}</button>;
}
export function CreateOrderForm() {
const [state, formAction] = useActionState(createOrderAction, { status: 'idle' });
return (
<form action={formAction}>
<select name="shippingAddressId">{/* ... */}</select>
<select name="productIds" multiple>{/* ... */}</select>
{state.status === 'error' && <p role="alert">{state.message}</p>}
<SubmitButton />
</form>
);
}
Form submits without JS. Validation errors surface under the field. Invalidation is tag-precise. Redirect happens from the action.
Rule 4: Caching and Revalidation Strategy — Pick One Per Route, Document It, Keep It Consistent
Next 15's caching story has four layers: Request Memoization (per-request), the Data Cache (fetch results), the Full Route Cache (rendered HTML), and the Router Cache (client-side prefetch). Cursor tends to mix strategies across a single route: one fetch with revalidate: 60, another with cache: 'no-store', and a Server Action that calls revalidatePath('/') nuking the homepage. The rule: pick a caching strategy per route, document it in a comment at the top of the page, and make invalidation explicit and scoped.
The rule:
Every route declares its caching strategy at the top of page.tsx or
layout.tsx in a comment:
// CACHE: static, revalidate: 3600 (content is CMS-driven, hourly stale OK)
// CACHE: dynamic (per-user dashboard, cookies() used)
// CACHE: hybrid (shell static, order list per-user via <Suspense>)
Route-level primitives (at module scope):
- `export const dynamic = 'force-static' | 'force-dynamic' | 'auto'`
- `export const revalidate = <seconds> | false`
- `export const fetchCache = 'force-cache' | ...`
Prefer per-fetch `next: { revalidate }` over `export const revalidate`.
Static routes: no `cookies()`/`headers()`/`noStore()`/`draftMode()`, all
fetches cached. The page is pre-rendered at build time, cached on the
CDN.
Dynamic routes: per-request data (auth). Accept the performance cost
and make the shell small, stream the body.
Hybrid: static shell (Server Component), dynamic children via
`<Suspense>` that reads cookies / user data.
Revalidation scope:
- `revalidateTag('tag')` — invalidate all caches tagged with that
string.
- `revalidatePath('/path')` — invalidate the Full Route Cache for
the specified path.
- Tags are preferred: scoped, predictable.
- `revalidatePath('/')` wipes the home page; use sparingly.
Draft mode:
- `draftMode().enable()` from a /preview route for CMS previews.
- Content authors see live drafts; other traffic sees the static
build.
Caching of Server Actions:
- Actions are never cached — they run per invocation.
- If an action needs a cached read, it calls into `unstable_cache` /
`getCached*` helpers.
Router cache (client prefetching):
- `<Link prefetch />` is the default; disable only on large
infrequently-visited routes.
- `router.refresh()` after a client-only mutation that should reflow
Server Component data.
CDN headers:
- `next.config.ts` sets `headers()` for cache-control on static
assets.
- API routes set Cache-Control explicitly when they return cacheable
data.
Before — mixed strategies, revalidatePath('/') as nuclear option:
// app/products/page.tsx
export default async function ProductsPage() {
const products = await fetch('https://api.x/products').then(r => r.json());
const user = await fetch('/api/me', { cache: 'no-store' }).then(r => r.json());
return <ProductGrid products={products} user={user} />;
}
// Elsewhere, after a mutation:
revalidatePath('/');
Home page wiped on every mutation. Product list uncached by default. Per-user data baked into a potentially-static page.
After — hybrid with Suspense, tag invalidation, documented intent:
// app/products/page.tsx
// CACHE: hybrid — product grid is cached with tag 'products',
// per-user recommendation strip streams per-request via <Suspense>.
import { Suspense } from 'react';
import { ProductGrid } from './ProductGrid';
import { Recommendations } from './Recommendations';
import { getProducts } from '@/lib/data/products';
export default async function ProductsPage() {
const products = await getProducts();
return (
<>
<ProductGrid products={products} />
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</>
);
}
// lib/data/products.ts
import 'server-only';
export async function getProducts() {
return fetch('https://api.x/products', {
next: { revalidate: 300, tags: ['products'] }
}).then(r => r.json());
}
// app/admin/products/actions.ts
'use server';
export async function updateProduct(id: number, data: UpdateInput) {
await db.update(products).set(data).where(eq(products.id, id));
revalidateTag('products');
}
Product grid is static per 5-min window, invalidated on write. Recommendations stream per-request under a Suspense boundary without blocking the shell.
Rule 5: Streaming With loading.tsx and error.tsx — Never A Blank Page During Fetch
The App Router's killer feature is streaming: a page can render a shell immediately, then stream in data-dependent sections as they resolve. Cursor often misses this entirely — its default is an async function Page() with all fetches in parallel at the top, and a blank white screen until everything's done. The fix is loading.tsx, error.tsx, and intentional <Suspense> boundaries around expensive sections so users see skeletons immediately.
The rule:
Every route segment that does async work has a `loading.tsx` OR is
wrapped in an explicit `<Suspense fallback={...}>` inside its page.
`loading.tsx` is the segment-level skeleton. It renders INSTANTLY while
the page's server work executes. Keep it fast to render (no data
fetching inside).
Inside a page:
- Cheap, fast fetches go in the page body (no Suspense needed).
- Expensive fetches are wrapped in `<Suspense>` so the shell streams
immediately.
- Parallel expensive fetches each get their own Suspense boundary so
slow ones don't block fast ones.
`error.tsx` ('use client') is the segment-level error boundary.
Required on any route that might fail (any route that fetches data).
It receives `{ error, reset }` and MUST either:
- Render a user-facing message with a retry button (calls `reset()`).
- Log the error to monitoring (Sentry / Axiom) in `useEffect`.
`not-found.tsx` is the segment-level 404. Called via `notFound()` from
the page when a required resource is missing. `notFound()` throws and
is caught by the nearest boundary.
`global-error.tsx` at the root is the last-resort boundary. It replaces
the entire layout. Keep it minimal: just a "Something went wrong"
message and a logger call.
Skeletons in `loading.tsx` match the layout of the final UI. A table
skeleton has the right number of rows and columns; a grid skeleton has
the right number of cells. Not "loading…" text.
`<Suspense>` boundaries are conscious decisions. Wrapping everything
in one Suspense defeats the purpose (the shell still waits for the
slowest child).
Client-side:
- Route prefetching via `<Link>` preloads the target segment's
`loading.tsx` as well — instant visual feedback on navigation.
- `router.push(path)` followed by `router.refresh()` only when the
server-rendered content needs to be re-fetched after a client-side
state change.
Before — no loading, no error boundary, blocking fetch:
// app/orders/[id]/page.tsx
export default async function OrderPage({ params }: { params: { id: string } }) {
const order = await fetch(`https://api.x/orders/${params.id}`).then(r => r.json());
const shipments = await fetch(`https://api.x/orders/${params.id}/shipments`).then(r => r.json());
const recommendations = await fetch(`https://api.x/recs?orderId=${params.id}`).then(r => r.json());
return <OrderDetail order={order} shipments={shipments} recommendations={recommendations} />;
}
Blank screen until all three resolve. Slowest fetch gates the whole page. One error crashes everything.
After — streamed shell, per-section Suspense, error boundary:
// app/orders/[id]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { getOrder } from '@/lib/data/orders';
import { Shipments } from './Shipments';
import { Recommendations } from './Recommendations';
import { ShipmentsSkeleton, RecsSkeleton } from './skeletons';
export default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const order = await getOrder(Number(id));
if (!order) notFound();
return (
<div className="grid gap-6">
<OrderHeader order={order} />
<Suspense fallback={<ShipmentsSkeleton />}>
<Shipments orderId={order.id} />
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
<Recommendations orderId={order.id} />
</Suspense>
</div>
);
}
// app/orders/[id]/loading.tsx
export default function Loading() {
return (
<div className="grid gap-6 animate-pulse">
<div className="h-10 bg-gray-200 rounded" />
<ShipmentsSkeleton />
<RecsSkeleton />
</div>
);
}
// app/orders/[id]/error.tsx
'use client';
import { useEffect } from 'react';
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => { console.error(error); /* ship to Sentry */ }, [error]);
return (
<div role="alert">
<p>We couldn't load this order.</p>
<button onClick={reset}>Retry</button>
</div>
);
}
Shell renders immediately. Shipments and recommendations stream in independently. A recommendations failure doesn't take down the shipments section. 404 renders the right not-found.tsx.
Rule 6: Metadata API — generateMetadata, Open Graph, Typed, Per-Route
Cursor routinely writes Next.js pages with no export const metadata and no generateMetadata, or with <head> tags rendered inside the component body (a Pages-era pattern that doesn't work in the App Router). The Metadata API is the only correct path, it supports both static and dynamic metadata, and it auto-generates Open Graph and Twitter cards when configured right.
The rule:
Every page exports either:
- `export const metadata: Metadata = { ... }` — static metadata.
- `export async function generateMetadata({ params, searchParams }):
Promise<Metadata> { ... }` — dynamic metadata that depends on the
data the page is about to render.
NEVER put `<meta>` / `<title>` / `<link rel="...">` tags directly in
the JSX. Next does not manage them that way in the App Router.
Metadata hierarchy is merged from the root down:
app/layout.tsx — site-wide defaults (siteName, default OG image,
icons, viewport).
app/(section)/layout.tsx — section defaults (title template).
app/(section)/page.tsx — page-specific overrides.
Title template at the root:
export const metadata: Metadata = {
title: { default: 'Site Name', template: '%s · Site Name' },
};
Open Graph is declared once and derived per-page:
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title, description: post.excerpt,
images: [{ url: post.cover, width: 1200, height: 630 }],
type: 'article', publishedTime: post.publishedAt.toISOString(),
},
twitter: { card: 'summary_large_image' },
};
}
Canonical URLs: `metadata.alternates.canonical`. Important for any page
accessible under multiple paths (with/without trailing slash, query
params).
Robots: page-specific `robots: { index: false, follow: true }` for
private / user-specific pages. Site default in root metadata.
`generateMetadata` is deduped against the page's data fetches via the
request-memoization cache — calling `getPost()` in both metadata and
page does NOT hit the DB twice if both use `fetch` or `cache()` /
`unstable_cache`.
`opengraph-image.tsx` / `twitter-image.tsx` (App Router file
conventions) for dynamic OG image generation with `ImageResponse`.
Preferred over pre-rendered OG images for content-heavy apps.
`robots.ts` and `sitemap.ts` at app root — typed APIs, never hand-rolled
XML. `sitemap.ts` returns `MetadataRoute.Sitemap` from the data layer.
Viewport (separated from metadata in Next 14+):
export const viewport: Viewport = {
width: 'device-width', initialScale: 1, themeColor: '#000000',
};
Icons via `icon.tsx` / `apple-icon.tsx` / `favicon.ico` conventions.
Don't declare icon paths manually.
Before — <head> tags in component, no OG, no canonical:
export default function PostPage({ post }: { post: Post }) {
return (
<>
<head><title>{post.title}</title></head>
<article>{post.body}</article>
</>
);
}
The <head> is ignored by Next in the App Router. No OG, no Twitter card, no canonical, no indexing control.
After — generateMetadata, full OG, canonical, robots:
// app/posts/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getPost } from '@/lib/data/posts';
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/posts/${post.slug}` },
openGraph: {
title: post.title,
description: post.excerpt,
url: `/posts/${post.slug}`,
images: [{ url: post.cover, width: 1200, height: 630, alt: post.title }],
type: 'article',
publishedTime: post.publishedAt.toISOString(),
authors: [post.authorName],
},
twitter: { card: 'summary_large_image', title: post.title, description: post.excerpt },
robots: { index: true, follow: true },
};
}
export default async function PostPage(
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return <article>{post.body}</article>;
}
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: { default: 'Example', template: '%s · Example' },
description: 'Default site description',
openGraph: { siteName: 'Example', type: 'website', images: '/og-default.png' },
twitter: { card: 'summary_large_image' },
};
Every page gets the right title, OG image, canonical, and robots directive. Sitemap and robots.txt are data-driven.
Rule 7: Middleware — Edge-Safe, Minimal, Matcher-Scoped, Auth Only
Middleware runs on every request matching its config. Cursor will write middleware that does session lookup with Prisma, DB writes for audit logs, and 200 KB of business logic — on the Edge runtime, which doesn't support most of Node. The rule: middleware is for cheap, edge-compatible, per-request concerns (auth redirect, locale detection, A/B bucketing, rate limiting). Anything heavier belongs in the page/action.
The rule:
Middleware is in `middleware.ts` at the repo root. There is exactly
one; composed internally if multiple concerns.
Runtime: Edge by default (`export const runtime = 'edge'`). Node.js
middleware is opt-in and reserved for cases that require it (specific
SDKs that don't run on Edge). Most middleware should be Edge.
Allowed middleware concerns (in order of frequency):
1. Auth redirect: unauthenticated user on a protected route →
redirect to /login.
2. Locale detection + redirect.
3. Feature-flag bucketing (rewrite based on cookie).
4. Rate limiting (Upstash ratelimit or similar Edge-compatible lib).
5. Tenant resolution for multi-tenant apps (subdomain → rewrite
header).
Forbidden in middleware:
- Database queries to primary DB (use session cookies + JWT
verification for auth checks, not a DB round-trip per request).
- Large third-party SDKs that aren't Edge-compatible.
- Cryptographic signing that requires Node's crypto (use Web Crypto).
- Response-body rewriting (use `NextResponse.next()` with headers,
not body transforms).
- Side effects (audit logs, analytics). Those belong in the route
handler or as `after()` callbacks.
Every middleware sets a `matcher` config so it runs only on paths that
need it:
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
A middleware that runs on every request (including static assets) is
a bug.
Session verification uses a JWT library compatible with the Edge
runtime (jose, iron-session edge). Do NOT import @auth/* server-only
modules inside middleware.
Redirects preserve the original URL as a query param:
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('from', req.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
`NextResponse.next()` passes through with optional header mutation:
const response = NextResponse.next();
response.headers.set('x-tenant-id', tenantId);
return response;
Rate limiting returns 429 with a Retry-After header:
if (limited) return new NextResponse('Too many requests', {
status: 429, headers: { 'Retry-After': '60' } });
Middleware errors are caught internally by Next and produce a 500.
Do not throw — return a redirect or a 4xx/5xx response deliberately.
`after(fn)` (Next 15) — use instead of middleware for "do something
after the response is sent" (send analytics, log to queue). That runs
on the server, not the edge.
Before — middleware with Prisma, no matcher, runs on everything:
// middleware.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function middleware(req: NextRequest) {
const token = req.cookies.get('token')?.value;
if (!token) return NextResponse.redirect(new URL('/login', req.url));
const user = await prisma.user.findUnique({ where: { sessionToken: token } });
if (!user) return NextResponse.redirect(new URL('/login', req.url));
await prisma.auditLog.create({ data: { userId: user.id, path: req.nextUrl.pathname } });
return NextResponse.next();
}
Prisma doesn't run on Edge. Every request hits the DB twice. No matcher — fires for _next/static too.
After — JWT verify, matcher, rate limit, after() for analytics:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.AUTH_SECRET!);
export async function middleware(req: NextRequest) {
const token = req.cookies.get('token')?.value;
const { pathname } = req.nextUrl;
const isProtected = pathname.startsWith('/dashboard') || pathname.startsWith('/orders');
if (!isProtected) return NextResponse.next();
if (!token) {
const url = new URL('/login', req.url);
url.searchParams.set('from', pathname);
return NextResponse.redirect(url);
}
try {
const { payload } = await jwtVerify(token, secret);
const response = NextResponse.next();
response.headers.set('x-user-id', String(payload.sub));
return response;
} catch {
const url = new URL('/login', req.url);
url.searchParams.set('from', pathname);
return NextResponse.redirect(url);
}
}
export const config = {
matcher: ['/dashboard/:path*', '/orders/:path*'],
};
// app/orders/page.tsx
import { after } from 'next/server';
export default async function Page() {
after(() => {
// runs after response is sent, audit log, analytics, etc.
logPageView({ path: '/orders' });
});
// ...
}
Edge-compatible, narrow matcher, audit-logging moved out to after(), no DB round-trip per request.
Rule 8: Testing — Playwright For E2E, Vitest For Units, MSW For API Mocking
The Next.js testing story is "choose one of each layer": unit tests with Vitest (fast, Jest-compatible, ESM-native), component tests with Vitest + Testing Library (for Client Components and hooks), API mocks with MSW (Mock Service Worker) for both test environments, and end-to-end tests with Playwright (which handles App Router's streaming, navigation, and forms better than Cypress). Cursor defaults to Jest + React Testing Library + fetch-mock — all workable, but the modern path is smoother.
The rule:
Unit / component tests: Vitest + @testing-library/react. `jsdom`
environment for component tests.
- New tests use Vitest. Jest is migrated on touch.
- `@vitejs/plugin-react` configured.
- `setupFiles: ['./vitest.setup.ts']` with MSW + RTL extends.
API mocks: MSW (Mock Service Worker) with handlers under
`src/mocks/handlers.ts`. Same handlers power Vitest (node) and Playwright
(browser).
Server Component testing:
- Server Components are testable as async functions — call them
directly, assert the returned JSX. No RTL render required for
leaf Server Components.
- For integration of Server Component + data layer, prefer the
Playwright e2e test over mocking the whole data layer.
Client Component testing:
- RTL render, user-event interactions (NOT fireEvent).
- Assertions on role / accessible name (`getByRole('button',
{ name: /submit/i })`), not class names or test IDs.
- testIDs only as a last resort (complex DOM, iconic UIs).
Server Actions testing:
- Pure logic extracted to a plain async function is unit-tested
directly.
- The action wrapper (with redirect / revalidate) is exercised via
Playwright.
Playwright:
- `playwright.config.ts` with projects for chromium / firefox /
webkit.
- `baseURL` pointed at `http://localhost:3000`; `webServer` starts
`next dev` (or `next start` for prod build).
- MSW via browser worker registered in a dev-only provider:
in test mode, the dev build uses MSW for third-party APIs.
- Every e2e test uses `page.goto(...)`, role-based selectors,
`expect(locator).toBeVisible()`, and waits on network via
`page.waitForResponse` when testing streaming.
`next/router` / `next/navigation` in tests: mock with Vitest's
`vi.mock('next/navigation', () => ({ useRouter: () => ({...}), ... }))`,
or render with an explicit Next navigation context.
Environment variables in tests: `.env.test` loaded via `next`'s env
loader when running Playwright against `next dev --port=0 -e=test`.
Coverage:
- Business-logic modules (lib/): >90% unit coverage.
- Components: >70% component + e2e combined; don't chase branch
coverage on presentational components.
- Every Server Action has at least one happy-path unit test + one
error-path test.
CI:
- Vitest on every PR.
- Playwright on every PR, parallelized by project.
- Visual regression (Playwright snapshots) on designated routes only.
Before — Jest + RTL + fetch-mock, brittle selectors, no e2e:
// __tests__/orders.test.tsx
import { render, screen } from '@testing-library/react';
import { OrdersPage } from '@/app/orders/page';
jest.mock('node-fetch');
test('renders orders', async () => {
(fetch as jest.Mock).mockResolvedValue({ json: async () => [{ id: 1, total: 10 }] });
render(<OrdersPage />);
expect(await screen.findByText('10')).toBeInTheDocument();
});
Server Component awkwardly rendered with RTL, mock-framework mess, no accessibility-first selector.
After — Vitest + MSW + Playwright, role-based, full stack:
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('https://api.example.com/orders', () =>
HttpResponse.json([{ id: 1, total: 100, status: 'pending' }])
),
];
// vitest.setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './src/mocks/handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// src/lib/data/orders.test.ts
import { describe, test, expect } from 'vitest';
import { getOrdersForUser } from './orders';
describe('getOrdersForUser', () => {
test('returns parsed orders for the user', async () => {
const orders = await getOrdersForUser('user-1');
expect(orders).toEqual([
expect.objectContaining({ id: 1, total: 100, status: 'pending' }),
]);
});
});
// e2e/orders.spec.ts
import { test, expect } from '@playwright/test';
test('create order flow', async ({ page }) => {
await page.goto('/orders/new');
await page.getByLabel('Shipping address').selectOption('1');
await page.getByLabel('Products').selectOption(['2', '3']);
await page.getByRole('button', { name: /create order/i }).click();
await expect(page).toHaveURL(/\/orders\/\d+$/);
await expect(page.getByRole('heading', { name: /order #/i })).toBeVisible();
});
Data helpers covered by fast Vitest tests. End-to-end flow exercised via Playwright against the real Next server with MSW intercepting external calls. Role-based selectors survive refactors.
The Complete .cursorrules File
Drop this in the repo root. Cursor and Claude Code both pick it up.
# Next.js 15 App Router — Production Patterns
## Components
- Server Components by default; 'use client' only for state/effects,
browser APIs, or interactive handlers.
- 'use client' at the LEAF needing it, never the layout.
- Layouts, pages, containers are Server Components.
- Server→Client props serializable only; functions must be Server
Actions.
- Client Components can render Server Components via children slot.
- import 'server-only' in modules that touch secrets or DB.
- import 'client-only' in modules that use browser APIs.
- Providers in app/providers.tsx ('use client'), wrapped by a Server
Component layout.
## Data fetching
- Every fetch explicitly declares cache/revalidate/tags/no-store.
- Data helpers in lib/data/*.ts with import 'server-only'; typed
return values (Zod/Drizzle).
- unstable_cache for DB queries with tags.
- Parallel fetches via Promise.all; sequential only when dependent.
- No useEffect for initial data — that's a Server Component's job.
- searchParams/params Zod-validated in the page.
## Server Actions & route handlers
- Mutations via Server Actions; route.ts only for webhooks, OAuth
callbacks, non-browser consumers, streaming responses.
- Actions parse FormData with Zod at the top.
- Return discriminated { success, data } | { success:false, error }
result.
- Call revalidateTag/revalidatePath after state change.
- Authorize inside the action, not middleware.
- Forms use <form action={action}>, not onSubmit+fetch.
- useActionState + useFormStatus in Client Components.
- redirect() from inside the action after success.
- Validation errors returned; infrastructure errors thrown.
## Caching
- Top-of-page CACHE comment documents strategy (static/dynamic/hybrid).
- Prefer per-fetch next: { revalidate, tags } over route-level exports.
- revalidateTag preferred over revalidatePath.
- revalidatePath('/') is the nuclear option — use sparingly.
- Actions never cached; call into cached helpers for reads.
- router.refresh() only after client-only mutations changing server
data.
## Streaming
- loading.tsx on every async segment; fast-rendering skeleton.
- Expensive sections wrapped in <Suspense fallback=...>.
- Parallel expensive fetches each get their own Suspense boundary.
- error.tsx ('use client') on every data-fetching route; logs +
provides reset().
- not-found.tsx + notFound() for missing resources.
- Skeletons mirror final layout.
## Metadata
- Every page exports metadata or generateMetadata; no <head> tags in
JSX.
- Root layout: title template, metadataBase, default OG, viewport.
- generateMetadata dedupes with page data via memoization.
- openGraph + twitter cards on every content page.
- Canonical URL via alternates.canonical.
- Robots page-level for private pages.
- opengraph-image.tsx / twitter-image.tsx via ImageResponse for dynamic.
- robots.ts and sitemap.ts typed at app root.
- viewport exported separately from metadata.
## Middleware
- One middleware.ts at root; Edge runtime by default.
- Allowed: auth redirect, locale, feature flag, rate limit, tenant.
- Forbidden: primary-DB queries, non-Edge SDKs, body rewrites, side
effects.
- Matcher scopes paths; never run on all requests.
- JWT verify via jose (Edge-compatible), not Prisma/server-only auth.
- Redirects preserve `from` query param.
- Rate limit returns 429 + Retry-After.
- Use after(fn) for post-response work (analytics, audit).
## Testing
- Vitest + @testing-library/react for units/components.
- MSW for API mocks; same handlers in node + browser.
- Playwright for e2e with projects per browser, webServer starting next.
- Role-based selectors; testIDs last resort.
- Server Components called as async functions in unit tests where
possible.
- Server Actions: pure logic unit-tested; wrapper via Playwright.
- Mock next/navigation via vi.mock when needed.
- Coverage: >90% lib/, >70% components, every action has happy + error
test.
- CI: Vitest + Playwright on every PR; visual regression selectively.
End-to-End Example: A Paginated Post List With Search, Form Creation, and Streaming
Without rules: 'use client' at the top, useEffect fetch, client-side form post, no caching, no metadata.
'use client';
import { useEffect, useState } from 'react';
export default function PostsPage() {
const [posts, setPosts] = useState([]);
useEffect(() => { fetch('/api/posts').then(r => r.json()).then(setPosts); }, []);
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
With rules: RSC page, tagged cache, streamed sections, Server Action form, metadata.
// app/posts/page.tsx
// CACHE: static for post list (tag 'posts', 60s revalidate); search is
// a searchParam that changes the cached key via the fetch URL.
import { Suspense } from 'react';
import type { Metadata } from 'next';
import { PostList } from './PostList';
import { PostListSkeleton } from './PostListSkeleton';
export const metadata: Metadata = {
title: 'Posts',
description: 'Latest posts',
alternates: { canonical: '/posts' },
};
export default async function PostsPage(
{ searchParams }: { searchParams: Promise<{ q?: string }> }
) {
const { q = '' } = await searchParams;
return (
<>
<h1>Posts</h1>
<SearchBar defaultValue={q} />
<Suspense fallback={<PostListSkeleton />}>
<PostList q={q} />
</Suspense>
</>
);
}
// app/posts/PostList.tsx (Server Component)
import { getPosts } from '@/lib/data/posts';
export async function PostList({ q }: { q: string }) {
const posts = await getPosts({ q });
return (
<ul>
{posts.map(p => (
<li key={p.id}><a href={`/posts/${p.slug}`}>{p.title}</a></li>
))}
</ul>
);
}
// app/posts/SearchBar.tsx ('use client')
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
export function SearchBar({ defaultValue }: { defaultValue: string }) {
const router = useRouter();
const pathname = usePathname();
const handle = useDebouncedCallback((value: string) => {
const params = new URLSearchParams();
if (value) params.set('q', value);
router.replace(`${pathname}?${params.toString()}`);
}, 300);
return <input defaultValue={defaultValue} onChange={e => handle(e.target.value)} />;
}
// lib/data/posts.ts
import 'server-only';
export async function getPosts({ q }: { q: string }) {
return fetch(`https://api.example.com/posts?q=${encodeURIComponent(q)}`, {
next: { revalidate: 60, tags: ['posts', `posts:q:${q}`] },
}).then(r => r.json());
}
// app/posts/actions.ts
'use server';
import { z } from 'zod';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
const Schema = z.object({ title: z.string().min(1), body: z.string().min(10) });
export async function createPost(_: unknown, formData: FormData) {
const parsed = Schema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
});
if (!parsed.success) {
return { status: 'error' as const, fieldErrors: parsed.error.flatten().fieldErrors };
}
const post = await savePost(parsed.data);
revalidateTag('posts');
redirect(`/posts/${post.slug}`);
}
Server-rendered shell, streaming post list, tag-cached data, debounced search, Server-Action-backed creation with progressive enhancement, typed metadata, canonical URL.
Get the Full Pack
These eight rules cover the Next.js 15 App Router patterns where AI assistants consistently reach for the wrong idiom. Drop them into .cursorrules and the next prompt you write will look different — RSC-default, cache-disciplined, action-driven, streaming, metadata-complete, middleware-minimal, Playwright-tested Next.js, without having to re-prompt.
If you want the expanded pack — these eight plus rules for Server Components + tRPC, Drizzle ORM with unstable_cache patterns, Auth.js v5 (NextAuth) in the App Router, Partial Prerendering, shared UI libraries (tsup/packaged), edge-API with OpenAPIHono, i18n with next-intl, analytics with Server Actions + after(), feature flags via @vercel/flags or flags-sdk, and the deploy patterns I use for Next.js on Vercel + Cloudflare Workers — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Next.js you would actually merge.
Top comments (0)