DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Next.js: 6 Rules That Make AI Write Production-Ready Next.js Code

Cursor Rules for Next.js: 6 Rules That Make AI Write Production-Ready Next.js Code

Cursor can scaffold a Next.js app in minutes. The problem? Without guidance, it generates Pages Router code in App Router projects, slaps "use client" on every file, skips image optimization, leaks environment variables to the browser, and treats req.body like it's already validated.

You can fix this by adding targeted rules to your .cursorrules or .cursor/rules/*.mdc files. Here are 6 rules I use on every Next.js project, with bad vs. good examples showing exactly what changes.


Rule 1: Always Use App Router — Never Pages Router

Always use the Next.js App Router (app/ directory).
Never create files in pages/. Use file-based routing with
app/route-name/page.tsx for pages and app/route-name/layout.tsx
for layouts. Use loading.tsx, error.tsx, and not-found.tsx
for built-in UI states.
Enter fullscreen mode Exit fullscreen mode

The App Router is the default since Next.js 13.4. Pages Router is legacy. Mixing both creates routing conflicts and confusing behavior.

Without this rule, Cursor often defaults to Pages Router patterns:

// ❌ Bad: Pages Router pattern
// pages/dashboard.tsx
import { GetServerSideProps } from 'next';

export const getServerSideProps: GetServerSideProps = async (ctx) => {
  const session = await getSession(ctx);
  const stats = await fetchDashboardStats(session.user.id);

  return {
    props: { session, stats },
  };
};

export default function Dashboard({ session, stats }) {
  return (
    <div>
      <h1>Welcome, {session.user.name}</h1>
      <StatsGrid stats={stats} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

getServerSideProps, getStaticProps, getInitialProps — all legacy APIs that don't exist in App Router.

With this rule, Cursor uses App Router conventions:

// ✅ Good: App Router with server component
// app/dashboard/page.tsx
import { getSession } from '@/lib/auth';
import { fetchDashboardStats } from '@/lib/data';
import { StatsGrid } from '@/components/StatsGrid';

export default async function DashboardPage() {
  const session = await getSession();
  const stats = await fetchDashboardStats(session.user.id);

  return (
    <div>
      <h1>Welcome, {session.user.name}</h1>
      <StatsGrid stats={stats} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

No getServerSideProps. No props drilling from data fetching. The component itself is async and fetches data directly on the server.


Rule 2: Server Components by Default — Client Only When Needed

All components are React Server Components by default.
Only add "use client" when the component uses browser APIs,
React hooks (useState, useEffect, useRef, etc.), or event
handlers (onClick, onChange, etc.). Never put "use client"
at the top of page.tsx or layout.tsx unless absolutely required.
Push "use client" to the smallest leaf component possible.
Enter fullscreen mode Exit fullscreen mode

Server Components render on the server with zero client-side JavaScript. Adding "use client" unnecessarily bloats the bundle and kills performance.

Without this rule, Cursor puts "use client" everywhere:

// ❌ Bad: entire page is a client component for no reason
// app/products/page.tsx
"use client";

import { useState, useEffect } from 'react';

export default function ProductsPage() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(setProducts);
  }, []);

  return (
    <div>
      <h1>Products</h1>
      {products.map(product => (
        <div key={product.id}>
          <h3>{product.name}</h3>
          <p>${product.price}</p>
          <AddToCartButton productId={product.id} />
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The entire page is client-rendered. The product list fetches from an API route instead of querying the database directly. The user downloads all the JavaScript for a page that could have been static HTML.

With this rule, Cursor keeps the page as a Server Component and only uses "use client" for interactive parts:

// ✅ Good: Server Component page with a client leaf
// app/products/page.tsx
import { db } from '@/lib/db';
import { AddToCartButton } from '@/components/AddToCartButton';

export default async function ProductsPage() {
  const products = await db.product.findMany({
    orderBy: { createdAt: 'desc' },
  });

  return (
    <div>
      <h1>Products</h1>
      {products.map(product => (
        <div key={product.id}>
          <h3>{product.name}</h3>
          <p>${product.price}</p>
          <AddToCartButton productId={product.id} />
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
// components/AddToCartButton.tsx
"use client";

import { useState } from 'react';

export function AddToCartButton({ productId }: { productId: string }) {
  const [adding, setAdding] = useState(false);

  async function handleClick() {
    setAdding(true);
    await fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify({ productId }),
    });
    setAdding(false);
  }

  return (
    <button onClick={handleClick} disabled={adding}>
      {adding ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The page ships zero JavaScript. Only the button — the one interactive piece — is a client component.


Rule 3: TypeScript Strict Mode — Always

Always use TypeScript with strict mode enabled.
Never use `any` type. Use `unknown` for truly unknown types
and narrow with type guards. Define explicit return types
for all API route handlers and server actions. Use Zod schemas
to derive types from validation where possible.
Enter fullscreen mode Exit fullscreen mode

Without strict mode, TypeScript gives you a false sense of safety. any spreads like a virus through your codebase, silently disabling type checking.

Without this rule:

// ❌ Bad: any types everywhere, no strict mode
export async function POST(req: any) {
  const data = await req.json();
  const user = await createUser(data);
  return Response.json(user);
}

function formatPrice(price: any) {
  return '$' + price.toFixed(2); // runtime crash if price is a string
}

async function getUser(id: any) {
  const res = await fetch(`/api/users/${id}`);
  const data: any = await res.json();
  return data;
}
Enter fullscreen mode Exit fullscreen mode

Everything is any. Nothing is checked. You find bugs in production, not at build time.

With this rule:

// ✅ Good: strict types, explicit return types
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(['admin', 'user']),
});

type CreateUserInput = z.infer<typeof CreateUserSchema>;

export async function POST(req: NextRequest): Promise<NextResponse> {
  const body: unknown = await req.json();
  const data = CreateUserSchema.parse(body);
  const user = await createUser(data);
  return NextResponse.json(user);
}

function formatPrice(price: number): string {
  return '$' + price.toFixed(2);
}

async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data: unknown = await res.json();
  return UserSchema.parse(data);
}
Enter fullscreen mode Exit fullscreen mode

Every function has explicit input and output types. Zod validates at runtime. TypeScript catches the rest at build time.


Rule 4: API Routes — Always Validate With Zod, Never Trust req.body

All API route handlers (app/api/**/route.ts) must validate
incoming data using Zod schemas. Never access req.body or
req.json() fields directly without validation. Always return
proper HTTP status codes. Always handle validation errors
with a 400 response including the error details.
Enter fullscreen mode Exit fullscreen mode

Your API routes are a public attack surface. Trusting req.body means trusting the internet.

Without this rule:

// ❌ Bad: no validation, trusting req.body blindly
// app/api/orders/route.ts
export async function POST(req: Request) {
  const { productId, quantity, shippingAddress } = await req.json();

  const order = await db.order.create({
    data: {
      productId,
      quantity,
      shippingAddress,
      total: quantity * (await getProductPrice(productId)),
    },
  });

  return Response.json(order);
}
Enter fullscreen mode Exit fullscreen mode

What if quantity is -5? What if productId is an SQL injection string? What if shippingAddress is missing? No validation means anything goes.

With this rule:

// ✅ Good: Zod validation, proper error handling
// app/api/orders/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const CreateOrderSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().min(1).max(99),
  shippingAddress: z.object({
    street: z.string().min(1),
    city: z.string().min(1),
    state: z.string().length(2),
    zip: z.string().regex(/^\d{5}(-\d{4})?$/),
  }),
});

export async function POST(req: NextRequest): Promise<NextResponse> {
  const body: unknown = await req.json();
  const result = CreateOrderSchema.safeParse(body);

  if (!result.success) {
    return NextResponse.json(
      { error: 'Invalid request', details: result.error.flatten() },
      { status: 400 }
    );
  }

  const { productId, quantity, shippingAddress } = result.data;

  const order = await db.order.create({
    data: {
      productId,
      quantity,
      shippingAddress,
      total: quantity * (await getProductPrice(productId)),
    },
  });

  return NextResponse.json(order, { status: 201 });
}
Enter fullscreen mode Exit fullscreen mode

Every field is validated. Bad input gets a 400 with details. Quantity can't be negative. Product ID must be a UUID. The zip code must match the expected format.


Rule 5: Image Optimization — Always next/image, Never img

Always use the next/image component (import Image from 'next/image')
for all images. Never use the HTML <img> tag. Always provide width
and height props, or use fill with a sized parent container.
Always include a descriptive alt attribute. Use priority prop
for above-the-fold images (hero, LCP).
Enter fullscreen mode Exit fullscreen mode

<img> tags serve unoptimized images, cause layout shift (CLS), and hurt your Core Web Vitals score. next/image handles lazy loading, resizing, format conversion, and layout stability automatically.

Without this rule:

// ❌ Bad: plain img tags, no optimization
function ProductCard({ product }) {
  return (
    <div>
      <img src={product.imageUrl} />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </div>
  );
}

function HeroSection() {
  return (
    <section>
      <img
        src="/hero-banner.png"
        style={{ width: '100%', height: 'auto' }}
      />
      <h1>Welcome to our store</h1>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

No alt text (accessibility violation). No width/height (layout shift). Full-size images served to mobile devices. The hero image blocks rendering because it's not prioritized.

With this rule:

// ✅ Good: next/image with proper optimization
import Image from 'next/image';

function ProductCard({ product }: { product: Product }) {
  return (
    <div>
      <Image
        src={product.imageUrl}
        alt={product.name}
        width={400}
        height={300}
        className="rounded-lg object-cover"
      />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </div>
  );
}

function HeroSection() {
  return (
    <section className="relative h-[500px]">
      <Image
        src="/hero-banner.png"
        alt="Store hero banner with featured products"
        fill
        className="object-cover"
        priority
      />
      <h1 className="relative z-10">Welcome to our store</h1>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Images are lazy-loaded by default, served in WebP/AVIF, resized per viewport, and include proper alt text. The hero image uses priority for instant LCP.


Rule 6: Environment Variables — Server-Only Secrets, Never Exposed to Client

Environment variables without the NEXT_PUBLIC_ prefix are
server-only and must never be imported in client components.
API keys, database URLs, and secrets must never use NEXT_PUBLIC_.
For client-side config, use NEXT_PUBLIC_ prefix and never put
sensitive values in them. Always access env vars through a
centralized config file with validation, never inline process.env.
Enter fullscreen mode Exit fullscreen mode

One misplaced NEXT_PUBLIC_ prefix leaks your API key to every browser that loads your app. This is the most dangerous mistake AI can make in a Next.js project.

Without this rule:

// ❌ Bad: secrets exposed to the client
// .env
NEXT_PUBLIC_DATABASE_URL=postgresql://admin:secret@db.example.com:5432/prod
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_abc123
NEXT_PUBLIC_JWT_SECRET=super-secret-jwt-key

// components/PaymentForm.tsx
"use client";

export function PaymentForm() {
  async function handleSubmit(data: FormData) {
    // Stripe secret key is in the client bundle!
    const res = await fetch('https://api.stripe.com/v1/charges', {
      headers: {
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY}`,
      },
      body: data,
    });
  }

  return <form onSubmit={handleSubmit}>...</form>;
}
Enter fullscreen mode Exit fullscreen mode

Your database password, Stripe secret key, and JWT secret are in the client-side JavaScript bundle. Anyone can open DevTools and read them.

With this rule:

// ✅ Good: server-only secrets, validated config
// .env
DATABASE_URL=postgresql://admin:secret@db.example.com:5432/prod
STRIPE_SECRET_KEY=sk_live_abc123
JWT_SECRET=super-secret-jwt-key
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xyz789
NEXT_PUBLIC_APP_URL=https://myapp.com

// lib/env.ts
import { z } from 'zod';

const serverSchema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  JWT_SECRET: z.string().min(32),
});

const clientSchema = z.object({
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
  NEXT_PUBLIC_APP_URL: z.string().url(),
});

export const serverEnv = serverSchema.parse(process.env);
export const clientEnv = clientSchema.parse({
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
});
Enter fullscreen mode Exit fullscreen mode
// app/api/payment/route.ts — server only
import { serverEnv } from '@/lib/env';

export async function POST(req: NextRequest): Promise<NextResponse> {
  const stripe = new Stripe(serverEnv.STRIPE_SECRET_KEY);
  // Secret key never leaves the server
}
Enter fullscreen mode Exit fullscreen mode

Secrets stay on the server. Public values are explicitly prefixed and validated. If an env var is missing or malformed, the app fails at startup — not in production.


Copy-Paste Ready: All 6 Rules

Here's a single block you can drop into your .cursorrules or .cursor/rules/nextjs.mdc:

# Next.js Rules

## Routing
- Always use App Router (app/ directory), never Pages Router (pages/)
- Use file conventions: page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx
- Never use getServerSideProps, getStaticProps, or getInitialProps

## Server vs Client Components
- All components are Server Components by default
- Only add "use client" for components using hooks, event handlers, or browser APIs
- Push "use client" to the smallest leaf component possible
- Never put "use client" on page.tsx or layout.tsx unless absolutely required

## TypeScript
- Always use strict mode
- Never use `any` — use `unknown` and narrow with type guards
- Define explicit return types for API routes and server actions
- Use Zod schemas to derive types where possible

## API Routes
- Validate all incoming data with Zod schemas
- Never access req.json() fields without validation
- Return proper HTTP status codes (400 for bad input, 201 for created)
- Always use safeParse and return error details on failure

## Images
- Always use next/image, never <img>
- Always provide width + height, or use fill with a sized parent
- Always include descriptive alt text
- Use priority for above-the-fold / LCP images

## Environment Variables
- Never use NEXT_PUBLIC_ for secrets (API keys, DB URLs, tokens)
- Access env vars through a centralized validated config, not inline process.env
- Validate all env vars at startup with Zod
- Only NEXT_PUBLIC_ vars are safe for client components
Enter fullscreen mode Exit fullscreen mode

Want 50+ Production-Tested Rules?

These 6 rules are a starting point. My Cursor Rules Pack v2 includes 50+ rules covering React, TypeScript, Next.js, Prisma, and testing — organized by framework and priority so Cursor applies them consistently.

Stop fighting bad AI output. Give Cursor the rules it needs to write production-ready Next.js code.

Top comments (0)