The 2-4-2 Pattern for Cursor Rules

I’ve read a lot of .cursorrules files. The pattern is almost always the same: someone started with a few sensible rules, then added more every time Cursor did something wrong, and six months later the file is 600 lines of everything they’ve ever wanted Cursor to know. Tech stack. Folder structure. API conventions. Authentication patterns. Error handling. “Never use lodash.” “Always use Zod.” Design system notes. Database naming conventions. The entire philosophy of the codebase, crammed into one file.

And Cursor reads it. Every single message, Cursor loads all 600 lines into context. The problem isn’t that Cursor can’t process the file. It’s that you’re burning context on database naming conventions while debugging a CSS layout, and your rules about CSS layout are competing for attention with 580 lines of everything else. Signal gets diluted.

The fix is progressive disclosure. Show the model what it needs, when it needs it. For Cursor rules, that means a tiered structure where context loads based on what you’re actually working on.

If this sounds familiar, it’s the same principle behind writing a lean AGENTS.md. The pattern works across tools.

.cursorrules is deprecated

Before getting into the pattern, one thing to sort out: .cursorrules is the old format. Cursor now uses .cursor/rules/ with individual .mdc files, and the difference matters for what we’re about to set up.

Each .mdc file has frontmatter that controls when it loads. That’s the mechanism that makes the 2-4-2 pattern work. If you’re still using a single .cursorrules file, the new format gives you control that the old one doesn’t.

The directory structure looks like this:

.cursor/rules/
├── 01-project-context.mdc
├── 02-code-standards.mdc
├── 03-frontend.mdc
├── 04-backend.mdc
├── 05-database.mdc
├── 06-aiml.mdc
├── 07-security.mdc
└── 08-feature-checklist.mdc

Eight files. Two always on, four triggered by what you’re editing, two that Cursor loads when it decides it needs them. That’s the whole pattern.

The two always-on files

These files load on every message, so every line in them costs you context on every single Cursor interaction. Be ruthless about what goes here.

File 1: Project context. This file answers “what are we building?” It covers your tech stack, your top-level folder structure, and the files Cursor needs to know exist. Keep it under 200 lines.

---
alwaysApply: true
---

# Project Context

React 18 + TypeScript monorepo. Next.js frontend, Express backend, Supabase for data.

## Structure

- `client/` — Next.js app (App Router)
- `server/` — Express API
- `shared/` — Types, schemas, utilities shared between client and server
- `scripts/` — Build and deployment tooling

## Key files

- `client/src/app/layout.tsx` — Root layout
- `server/index.ts` — API entry point
- `shared/schema.ts` — Drizzle schema (source of truth for DB types)
- `client/src/lib/api.ts` — Typed API client

## Non-negotiables

- Shared types live in `shared/`. Never duplicate types between client and server.
- All DB access goes through `server/db/`. Direct Supabase calls from the client are not allowed.
- Use the logger at `server/lib/logger.ts`. No console.log in application code.

File 2: Code standards. This file answers “what does good code look like here?” Naming conventions, file size limits, TypeScript rules, patterns to avoid. Also under 200 lines.

---
alwaysApply: true
---

# Code Standards

## Naming

- DB columns: snake_case
- TypeScript: camelCase for variables/functions, PascalCase for types/components
- Files: kebab-case for utilities, PascalCase for React components

## Limits

- Max 200 lines per component. If it's bigger, split it.
- Max 3 levels of nesting in JSX before extracting a component.

## TypeScript

- All function parameters must be typed. No `any`.
- Use type assertions (`as`) only with a comment explaining why.
- Prefer `type` over `interface` for object types.

## Patterns to avoid

- Do not use `axios`. We use native `fetch` with the typed client in `client/src/lib/api.ts`.
- Do not use `moment`. Use `date-fns`.
- Do not use default exports except for Next.js pages and API routes.

## Error handling

- API errors: use the `ApiError` class in `server/lib/errors.ts`
- Client errors: use the error boundary in `client/src/components/ErrorBoundary.tsx`
- Always handle loading and error states in components that fetch data

That’s it. Two files, always loaded, under 200 lines each. Everything else is conditional.

The four auto-attached files

These files load based on what you’re editing. The .mdc frontmatter takes a globs field that specifies the file patterns that trigger it. When you’re editing a file that matches, Cursor loads the corresponding rules file automatically.

The four categories that cover most codebases:

Frontend rules triggers when you’re editing anything in client/src/:

---
globs: ["client/src/**"]
---

# Frontend Rules

## Components

Follow the pattern in `client/src/components/ui/Button.tsx` for component structure.
Props interface at the top, component below, no default exports except for pages.

## Data fetching

Use SWR for client-side data fetching. Follow the pattern in
`client/src/hooks/useUser.ts`. Never fetch directly from components.

## Forms

Use react-hook-form + Zod. See `client/src/components/forms/LoginForm.tsx`
for the pattern. The form schema always lives in the same file as the form.

## Styling

Tailwind only. No inline styles, no CSS modules. Use `cn()` from
`client/src/lib/utils.ts` for conditional classes.

Backend rules triggers on server/**:

---
globs: ["server/**"]
---

# Backend Rules

## Route structure

Follow the pattern in `server/routes/users.ts`. Routes define handlers inline
for simple cases; extract to `server/handlers/` when a handler exceeds 50 lines.

## Middleware

Auth middleware is at `server/middleware/auth.ts`. Every authenticated route
uses it. Do not replicate auth logic in handlers.

## Response format

All API responses use the wrapper in `server/lib/response.ts`:
- Success: `{ data: T, error: null }`
- Error: `{ data: null, error: { code: string, message: string } }`

Never return raw data without the wrapper.

## Input validation

All request bodies validated with Zod at the route level before handlers run.
Schemas live in `shared/schemas/` so they can be reused on the client.

Database rules triggers on schema and migration files:

---
globs: ["shared/schema.ts", "server/db/**", "**/migrations/**"]
---

# Database Rules

## Schema

`shared/schema.ts` is the source of truth. Column names are snake_case.
All tables have `created_at` and `updated_at` with `defaultNow()`.

## Queries

Use the query builder in `server/db/queries/`. Don't write raw SQL unless
the query builder can't express what you need (and document why when you do).

## RLS

Every table has RLS enabled. New tables need policies before shipping.
See `server/db/policies/` for examples. The pattern: restrict reads to the
user's own rows unless the table is explicitly public.

## Migrations

Generated with `pnpm db:generate`. Never edit generated migration files.
Always run `pnpm db:migrate` locally before committing schema changes.

AI/ML rules, if your project has model integrations, trigger on those files:

---
globs: ["server/ai/**", "server/lib/openai.ts"]
---

# AI Integration Rules

## Client

Use the singleton in `server/lib/openai.ts`. Do not instantiate OpenAI client
directly in other files.

## Prompts

System prompts live in `server/ai/prompts/`. No inline prompt strings in
handler code. Each prompt is a function that takes typed parameters.

## Error handling

OpenAI calls can fail with rate limits and content policy errors. Always wrap
in the retry handler at `server/ai/retry.ts`. Do not write custom retry logic.

Auto-attached files should be medium-length (150-300 lines), specific to their domain, and reference real files in your codebase. Not “follow established patterns.” “Follow the pattern in client/src/hooks/useUser.ts.”

The two agent-requested files

This tier is where Cursor makes the call. These files aren’t always on and they don’t have glob triggers. Instead, Cursor reads their description and decides whether to load them based on what task it’s working on.

The description field in the frontmatter matters a lot here. Vague descriptions mean Cursor won’t know when to load the file.

Security rules:

---
description: "Security patterns, authentication flows, JWT handling, session management, RLS policies, environment variables, and access control. Load when implementing or modifying auth, permissions, protected routes, or anything touching user credentials."
---

# Security Rules

## Auth flow

JWT tokens issued by the auth endpoint at `server/routes/auth.ts`.
Refresh tokens stored in httpOnly cookies. Access tokens: 15 minute expiry.
See the full flow in `docs/auth-flow.md`.

## Never do this

- Never log tokens, passwords, or session data
- Never return password hashes in API responses
- Never store secrets in code. Use environment variables.
- Never trust user-supplied IDs without verifying ownership

## Environment variables

Secrets go in `.env.local` (gitignored). Public vars are prefixed with
`NEXT_PUBLIC_`. Server-only vars have no prefix. See `.env.example` for all
required variables.

## Protected routes

Client-side: use the `withAuth` HOC in `client/src/lib/auth.ts`.
Server-side: the auth middleware at `server/middleware/auth.ts`.
Both are required for truly protected resources.

Feature checklist:

---
description: "Checklist for shipping new features. Load when implementing a new feature end-to-end, adding a new route or page, or doing anything that touches multiple layers of the stack."
---

# New Feature Checklist

When shipping something new, make sure you've covered:

## Data layer
- [ ] Schema updated and migration generated
- [ ] RLS policies added for new tables
- [ ] Query functions added to `server/db/queries/`

## API layer
- [ ] Route added to appropriate router file
- [ ] Input validation schema in `shared/schemas/`
- [ ] Auth middleware applied if route is protected
- [ ] Error cases handled with proper response codes

## Client layer
- [ ] Loading state handled
- [ ] Error state handled
- [ ] Route guard applied if page is protected
- [ ] Added to navigation if user-facing

## Before shipping
- [ ] Tests written (at minimum: happy path + one error case)
- [ ] Checked for N+1 queries
- [ ] Verified the feature works with RLS enabled

Write descriptions like function docstrings. Cover all the cases where the file is relevant. “Security patterns, authentication flows, JWT handling, session management, RLS policies, environment variables, and access control” gives Cursor enough signal. “Security stuff” does not.

Why this beats one big file

It comes down to relevance. A 600-line always-on file means Cursor spends context budget on database naming conventions when you’re debugging a CSS layout. With the 2-4-2 structure, your always-on files are tight (under 400 lines combined). The CSS rules load when you’re editing frontend files. The database rules load when you’re touching the schema. At any given moment, Cursor has the rules that matter for what you’re doing, not a general purpose encyclopedia.

The agent-requested tier is worth calling out. Cursor is genuinely decent at deciding “I’m implementing a new feature, I should load the feature checklist.” The catch is that file descriptions need to be specific enough for that to work. If Cursor can’t tell from the description when the file is relevant, it won’t load it at the right times.

Writing rules that stick

A few things that make a real difference:

Reference actual files, not abstractions. “Follow patterns in client/src/hooks/useDataCache.ts” is a rule Cursor can act on. “Follow established patterns” is a hope. Real file paths give Cursor something to look up.

Say what not to do. “Do not use axios” is more useful than “use fetch.” Negative constraints stop Cursor from reaching for whatever it saw most in training data. List the libraries you’ve rejected and why.

Write descriptions for the model reading them. Your rule file descriptions are read by Cursor to decide whether to load the file. Be precise, cover all the cases where the file is relevant.

Review every two weeks. Stale rules are worse than no rules, because Cursor trusts them. When you switch from Express to Fastify, your backend rules need to update. When you change your error handling approach, the old pattern in the rules file becomes actively harmful. Put a recurring calendar reminder, check each file, and delete anything that no longer reflects reality.

Migrate in 15 minutes

If you have an existing .cursorrules file, here’s how to move over:

Step 1: Create the directory (1 minute)

mkdir -p .cursor/rules

Step 2: Back up your existing content (1 minute)

cp .cursorrules .cursorrules.backup

Step 3: Create 01-project-context.mdc (5 minutes)

Open your old .cursorrules and pull out everything that answers “what are we building?” Tech stack, folder structure, key files, non-negotiables. Paste it into 01-project-context.mdc with alwaysApply: true in the frontmatter. Keep it under 200 lines. Cut anything that isn’t essential orientation.

Step 4: Create 02-code-standards.mdc (5 minutes)

Pull out the naming conventions, file size rules, TypeScript preferences, and patterns to avoid. Same frontmatter. Same line limit.

Step 5: Sort the rest into domain files (5 minutes)

Look at what’s left in your backup. Group it: frontend, backend, database. Create one .mdc file for each group with appropriate globs. If something doesn’t fit a domain, it either belongs in one of the always-on files or it should be an agent-requested file.

Step 6: Delete .cursorrules

You don’t need it anymore. The .cursor/rules/ directory takes over.


Start with just the two always-on files if the full migration feels like too much. Even getting your monolithic file down to 200 lines of essentials, with the rest rebuilt as triggered files over the following week, is a real improvement. The goal is rules that actually get followed, not a comprehensive specification that Cursor half-reads.

If you find Cursor violating a rule twice, that’s a signal: either the rule is in the wrong tier (should be always-on, not auto-attached) or the description isn’t specific enough for agent-requested files. Move it, sharpen the description, and check again.