Skip to content
OTFotf
All posts

Master Your Next.js Conventions with a Bulletproof .cursorrules File

D
DaveAuthor
8 min read
Master Your Next.js Conventions with a Bulletproof .cursorrules File

Your agent forgets your conventions the moment the context window rolls. Every Cursor session starts blank. You spend an hour telling it where things live, what's banned, which patterns you hate. Then you compact, switch branches, or open a new chat. The next session reinvents app/components/Button.tsx from scratch because it doesn't know you put components in components/ui/, that you use cn() from lib/utils, and that you never want a raw <img> tag.

.cursorrules fixes this. It's a file Cursor reads on every interaction — a persistent constitution for the model. Most files I've seen are vague ("write clean code"), a wall of prose no model parses well, or a flat list of bans without context. They fail for the same reason: the model needs structure, examples, and explicit allowed/denied pairs.

Here are the six sections a .cursorrules actually needs for a Next.js project, drawn from the configs we ship with our kits. The whole thing fits in one file. Copy the bits that match your stack, throw out the rest.

1. Project map — answer "where does X go" before the model guesses

The single most useful block is the directory map. A model that knows your layout won't invent app/_components/ or stash a hook inside app/api/.

## Project layout

src/
  app/                    # Next.js App Router (routes only, no components)
    (marketing)/          # Route group: public pages
    (app)/                # Route group: authenticated app
      dashboard/
        @analytics/       # parallel route slot
        @feed/
        page.tsx
        layout.tsx
    api/                  # Route handlers (route.ts)
  components/
    ui/                   # primitives — Button, Card, Dialog (no business logic)
    forms/                # composable forms, one per feature
  lib/
    db/                   # schema + queries
    auth/                 # session helpers
    utils.ts              # cn(), formatters, no React imports
  server/                 # server-only modules — never import from a client component
    actions/              # 'use server' actions
  styles/                 # global styles + tokens
public/
tests/                    # mirrors src/ structure

This block does three things: it tells the model the shape (App Router, src/ root, route groups), the rules (components/ui/ is for primitives only), and the forbidden zones (no components in app/). A vague "this is a Next.js app" line gives the model nothing. A directory tree gives it a map.

a tree of src/app, src/components, src/lib, src/server with one-line labels describing wha

2. Import rules — paths, aliases, and what counts as a barrel

The model loves barrel files because they look tidy. They also break tree-shaking, hide cycles, and turn a refactor into a 30-file edit. Say so, explicitly.

## Imports

- Path alias: `@/*``src/*`. Always `@/components/ui/button`, never long relatives.
- Named exports only. No default exports. (Easier refactor, no `React.lazy` surprises.)
- No barrel files (`index.ts`) inside `components/` or `lib/`. Import from the leaf: `@/components/ui/button`.
- Group imports in this order, blank line between: (1) React/Next, (2) third-party, (3) `@/` aliased, (4) relative. No exceptions.
- Server-only modules: `import 'server-only'` at the top of every file under `src/server/`.
- Do not import from `src/server/**` inside any file under `components/` or any `'use client'` file.

The server-only rule alone saves you a 90-minute debug. The model will happily import a DB query into a form component if you let it.

One codebase. iOS, Android, and web.

The Fitness Kit ships with auth, a database, and a backend already connected — no setup. Live demo at fitness-preview.otf-kit.dev.

See the live demo

3. The "never do" list — explicit bans with the reason

A "never" line without a reason gets ignored the first time the model thinks it has a good reason. Pair every ban with the line of code it replaces or the bug it prevents.

## Never

- Never `<img>` — use `next/image` with `width`/`height` or `fill`. (LCP, CLS, AVIF.)
- Never `useEffect` to fetch on mount — fetch in a Server Component, then pass data down or use a Server Action.
- Never `useState` for derived values — compute inline, wrap in `useMemo` only if the computation is genuinely expensive.
- Never `'use client'` at the top of a layout or a page — it forces every child to be a client component and ships the whole tree.
- Never `any`. Use `unknown` and narrow. If you reach for `any`, write a one-line comment explaining why and pick a more specific type.
- Never `dangerouslySetInnerHTML` without a sanitiser. If you must, wrap the input through one before inserting.
- Never edit generated files under `.next/` or `next-env.d.ts`.
- Never add a dependency without saying so in the response and asking first.

The "ask before adding a dep" rule is the difference between a .cursorrules that produces a 400MB node_modules and one that doesn't.

4. App Router conventions — the file-naming rules

The App Router is a convention machine. The model knows the conventions. What it doesn't know are your conventions: where parallel routes live, how you name a loading state, whether you colocate page-specific components.

## App Router

- Routes only in `src/app/**/page.tsx`. Co-located helpers (a small chart for one page) go in `_components/` with a leading underscore so the router ignores the folder.
- `loading.tsx` for any route segment that does real work. Skeleton, not spinner.
- `error.tsx` must be `'use client'` and accept `{ error, reset }`. Wrap the tree above it in a Suspense boundary if you can.
- `not-found.tsx` at every meaningful segment. Don't rely on the root one.
- Route handlers in `src/app/api/**/route.ts`. Export named HTTP methods (`export async function POST`, never `export default`).
- Metadata: export a `metadata` const or `generateMetadata`. Never `<Head>` from `next/head`.
- Parallel routes (`@slot`) declared in `layout.tsx` must have a matching `default.tsx` per slot, or the build warns.

The _components/ underscore trick is the most-copy-pasted line in our shipped configs. It stops the model from inventing components/ next to a page and accidentally breaking routing.

5. Server/client component boundaries — the rule the model gets wrong most

The default in the App Router is a server component. 'use client' is an escape hatch. The model inverts this. It sprinkles 'use client' on every file that uses useState, which is fine, but it also sprinkles it on layouts and pages — which is how a 2KB page becomes a 200KB client bundle.

## Server vs client components

Default: server. Add `'use client'` only when the file uses any of:
  - hooks (`useState`, `useEffect`, `useRef`, custom hooks)
  - event handlers attached to a DOM element
  - browser-only APIs (`window`, `document`, `localStorage`)

Layouts and pages stay server. If a child needs to be interactive, mark the leaf component — not the layout.

Server Actions: any function passed to a `<form action={...}>` or imported into a client component must live in `src/server/actions/` and start with `'use server'`. Never define an inline action inside a client component.

Data fetching lives in the component. No client-query lib for a list you could render on the server. Reach for a client query lib only when the data is genuinely client-mutating or paginated by scroll.

server-first default vs client-spray default — bundle size shipped to the browser, hydrati

This is the section that pays for the file. Without it, every Cursor session turns your homepage into a client component.

6. Worked examples — show, don't just tell

The model parses examples better than rules. Two worked pairs: a UI primitive and a server page that hands data to a small client island.

## Examples

### UI primitive pattern
File: `src/components/ui/button.tsx`
- named export `Button`
- variants via your variant helper — `default`, `secondary`, `ghost`, `destructive`
- forwards refs, sets `displayName`
- props extend the underlying element
- no business logic, no `fetch`, no env reads

### Server page that hands data to a small client island
File: `src/app/(app)/dashboard/page.tsx` — server component, no `'use client'`.
Imports the data fetcher and a client component, passes serialised props.

Then a separate code block with the actual file content:

import { getRecentOrders } from '@/lib/db/orders'
import { OrdersTable } from './_components/orders-table'

export default async function Page() {
  const orders = await getRecentOrders()
  return <OrdersTable initial={orders} />
}

The page does the fetch. The client island receives serialised data and owns the interactivity. No prop-drilling auth, no 'use client' on the page.

How to actually ship this

Drop the file at the repo root, named exactly .cursorrules. Cursor reads it automatically — no settings UI, no plugin. The whole file can be ~250 lines and that's fine; Cursor doesn't choke on it.

Two practical notes from running this across our kits:

  • One .cursorrules, not many. In a monorepo, put a per-package .cursorrules next to that package's package.json and a root one for cross-cutting rules. Cursor walks up the tree.
  • Re-read on a new chat. Cursor caches the rules per chat. If you edit .cursorrules mid-session, open a new chat. Don't assume a hot-reload.

What this gets you

The first time a new contributor opens Cursor on a project with a .cursorrules like this, the first ten generations land in the right directory, use the right import style, and don't sprinkle 'use client' on your server components. The model doesn't guess. It follows.

That's not a magic property of the model. It's what happens when you give it the same context you would give a new hire on day one — the layout, the rules, the examples. A senior engineer doesn't onboard by reading a README.md full of aspirations. They onboard by reading code, asking questions, and being corrected. The .cursorrules is the corrected-part, written down.

The model underneath will churn. New Cursor versions, new agent modes — all of it rotates under you. The conventions don't. Encode them once, in a file the tool can't ignore, and the next time the engine swaps, you keep the part that mattered.

react-nativetemplatesarchitecture
OTF Fitness Kit

Stop wiring. Start shipping.

  • Login, database, and backend already connected — nothing to set up
  • iOS + Android + web from one codebase
  • AI configs pre-tuned + 40+ tested prompts included