DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Next.js: The Complete Guide to AI-Assisted Next.js Development

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
Enter fullscreen mode Exit fullscreen mode

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).
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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)} />;
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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('/');
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode
// 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' },
};
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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' });
  });
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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)