rmnr
DocsTourSlicesBest PracticeAgentsInstall

Command Palette

Search for a command to run...

New
  • Best Practice
  • Audit Chain

Standards

Best Practice

Single source of truth for how rr-based projects are built. Two surfaces, one data file — the Docs tab is for humans, the AI Prompt tab is for pasting into your AI agent so it follows the same rules.

Rule tiers & conflict resolution

Every rule carries a tier. When two rules conflict, the higher tier wins. When you can't ask the user (autonomous run, mid-task): pick the option this doctrine recommends, add a `// TODO(rr): confirm — chose X over Y because …` marker, and continue. Never silently guess.

P0P0 — security & data integrity

NEVER violate, no exceptions, no TODO escape hatch. If a P0 rule blocks the task, stop and report instead of working around it.

P1P1 — architecture & structure

Violate only when genuinely necessary. Every violation needs a `// TODO(rr): <why + what the compliant version looks like>` at the site AND a note in the commit body.

P2P2 — style & modularity

Enforced by lint/audit tooling. If the tooling passes, you pass.

Stack baselineP1

The pin is intentional: rr slices must compose identically across every consumer app. "Works on my machine" is a lift blocker. `check:stack-pin` compares package.json against this section in CI.

P1Next.js 16 + React 19

Pin Next ^16 and React ^19 in package.json. No `middleware.ts` — use `proxy.ts` at the project root.

Why: Next 16 deprecates middleware.ts and ships App Router + Cache Components as the default.

// package.json
"next": "^16.0.0",
"react": "^19.0.0"

P1Tailwind v4

Use Tailwind v4 with `@tailwindcss/postcss`. Bridge a v3 config via `@config` only during migration.

P1Convex self-hosted

Use Convex self-hosted via Docker Compose on the same Dokploy node. Pin `convex` ^1.16 minimum.

Why: Self-hosted = zero per-user cost, full schema portability, deploy via `npx convex deploy --env-file …`.

P1Auth = @convex-dev/auth

Use `@convex-dev/auth` for sessions. NO Clerk. Custom auth slices are allowed only when @convex-dev/auth is documented as insufficient.

P1TypeScript strict + drift guard

TS strict everywhere. If package.json versions disagree with this baseline, FLAG it — don't silently adopt either side.

Vertical slice structureP1

Every feature is a vertical slice that owns its full stack. No deep cross-slice imports — the barrel is the contract.

P1Slice layout

Consumer projects: each feature lives at `slices/<slug>/` (+ optionally `convex/features/<slug>/`). rr internal repo only: `frontend/slices/<slug>/` (preserves Next routing). Don't mix the two conventions. Per-slice shape: `components/ lib/ utils/ hooks/ config/ api/` + `types.ts` + tests + the metadata pair.

slices/cta/
  ├── components/  ├── lib/  ├── hooks/  ├── config/
  ├── utils/  ├── api/  ├── types.ts  ├── index.ts
  └── slice.json  slice.manifest.json

P1Barrel-only cross-slice imports

Inside a slice, imports resolve ONLY via `@/components/ui/*`, `@/shared/*`, `@/features/<own-slug>/*`, `@convex/*`, or relative-within-slice. No `../../` reaching into another slice's internals.

Why: Deep imports lock you into another slice's internal layout. The barrel is the contract.

// DON'T — deep import into another slice
import { parseMention } from "@/features/comments/lib/mention-parser";

// DO — through the barrel
import { parseMention } from "@/features/comments";

P1Metadata PAIR (not trio)

Every slice ships `slice.json` (contract folded in under the `contract` block since 2026-06-21) + `slice.manifest.json`. Version SSOT: `slice.json.version === slice.manifest.json.version`, gated by `audit:slices`. `lib/content/slices.ts` scalars are GENERATED via `gen-slice-catalog.mjs` — never hand-edit them.

Why: The old third file (`slice.contract.ts`) was folded into `slice.json.contract` — one SSOT, drift impossible.

P1Props-driven portability

Portable slices NEVER hardcode consumer URLs, env names, role enums, or copy. Hardcode = lift blocker.

// BAD
const SITE = "https://rahmanef.com";

// GOOD
export function HeroView({ siteUrl }: { siteUrl: string }) { … }

P1rr backend is admin-only

Site demos run on the localStorage adapter, NOT Convex. `convex/features/*` in rr is copy-source for consumers. Never compose every feature into rr's own `convex/schema.ts` — that turns the library into a monolith.

Convex rules

P0Validators on every public function

Every `mutation()` / `query()` reachable from the client MUST declare `args:` with `v.*` validators.

Why: Without them, anything goes from a crafted client. audit-bp marks missing validators as P0.

// DO
export const setRole = mutation({
  args: { userId: v.id("users"), role: v.union(v.literal("admin"), v.literal("member")) },
  handler: async (ctx, args) => { /* … */ },
});

P0Server-side authz inside every handler

Call `requireUser` / `requireAdmin` from `convex/_shared/auth.ts` as the FIRST line of the handler. Route-layer gates do not protect Convex HTTP endpoints.

Why: Convex HTTP queries are directly reachable — Next.js layout gates don't protect them.

// DO
handler: async (ctx, args) => {
  await requireAdmin(ctx);
  await ctx.db.patch(args.id, { role: args.role });
}

P1No bare .collect(); index every filtered/ordered query

`ctx.db.query(...).collect()` scans the table. Use `.withIndex(...).take(N)` or paginate; add the index in `defineTable(…).index(…)`. Exception: tiny bounded config tables (< ~50 rows) may `.collect()` with a `// TODO(rr): bounded table` marker.

Why: Bare collects bypass query-budget guardrails and degrade as the table grows.

// BAD
await ctx.db.query("posts").collect();

// GOOD
await ctx.db.query("posts").withIndex("by_author", q => q.eq("authorId", args.authorId)).take(50);

Next.js rules

P0NEXT_PUBLIC_ only for non-sensitive values

Any value prefixed `NEXT_PUBLIC_` ships in the client bundle. Never put secrets, API keys, or admin emails there.

P0Server Actions verify the caller

Every `'use server'` export authenticates AND authorizes before mutating. Treat them as public API endpoints.

P1proxy.ts not middleware.ts

Next 16 renamed middleware to proxy. Put logic in `proxy.ts` at the project root.

P1next/link + next/image only

Never use `<a href="/internal">` or `<img src=…>`. Use `<Link>` / `<Image>` so Next can prefetch + optimise.

P1Cache Components for static reads

Marketing/SSG pages opt into `"use cache"` + `cacheLife` / `cacheTag`. Enable `experimental.cacheComponents` in next.config.mjs first.

P1runtime fs reads need outputFileTracingIncludes

Runtime `fs.readdir` / `readFile` on repo dirs requires that dir in `outputFileTracingIncludes` in next.config.mjs. Works locally, silently empty in the standalone Docker image otherwise.

Data fetchingP1

If you're reaching for `useEffect` + `fetch`/`useState`, the answer is `useQuery` or a server component.

P1Authed/dynamic pages → preloadQuery

Server component calls `preloadQuery` (convex/nextjs) for first paint, passes the ref to a client component using `usePreloadedQuery` — reactive after hydration, no loading flash.

P1Reactive client state → useQuery/useMutation

After first paint, reactive client state uses `useQuery` / `useMutation` from `convex/react`.

P1Static marketing reads → fetchQuery in "use cache"

Static reads use `fetchQuery` inside a `"use cache"` component, or build-time data.

P1Never fetch in useEffect

Client mutations go through slice-local hooks (`slices/<slug>/hooks/`), never inline in JSX handlers scattered across components.

Error handling & loggingP1

P1Convex → typed ConvexError

Throw `ConvexError({ code, message })` with a typed code from the slice's `types.ts` (e.g. `NOT_AUTHORIZED | NOT_FOUND | RATE_LIMITED`). Never throw raw strings; never leak internal details to clients.

// DON'T
throw new Error("db failed: " + JSON.stringify(user));

// DO
throw new ConvexError({ code: "NOT_AUTHORIZED", message: "Admin role required" });

P1Client → map code to copy, surface via toast

Catch in the slice's mutation hook, map code → user copy, surface via the shared toast (sonner). Never swallow silently; never `alert()`.

P1Route boundaries

Every route group ships `error.tsx` (and `not-found.tsx` where relevant). Errors render inside the shell chrome, not a white page.

P1Logging

Server-side `console.error("[<slice>:<fn>]", err)` with a context prefix. No PII in logs. No `console.log` left in shipped client code.

TestingP1

A slice's tests travel with it when copied — co-locate them.

P1Co-located per slice

Unit/component tests live inside the slice — `slices/<slug>/__tests__/` or `<file>.test.ts(x)` next to the source.

P1Mandatory per slice

(1) the barrel's exported API — the contract consumers rely on; (2) every Convex mutation/query via `convex-test`, INCLUDING the authz-denied path (unauthenticated caller must be rejected).

P1App-level e2e stays global

Playwright smoke is global (`npm run e2e` local, `e2e:staging` against staging). Slices don't own e2e. Test files are excluded from the 200-LOC cap but still obey single-responsibility.

File modularityP2

Files are read more than written. Keep them small, single-purpose, composable so consumers grok + reuse + replace pieces without reading the whole thing. Tooling-enforced.

P2≤200 lines per source file

Hard cap enforced by `audit:file-size` + eslint `max-lines`. Exclusions: pure data exports (`lib/content/*.ts`, `*/seed.ts`, theme presets), `_generated/`, test files, and `components/ui/*` (vendored shadcn — never edit, never count; customize by wrapping in `shared/` or slice components, or regenerate via the shadcn CLI).

Why: Large files hide concerns, resist diff review, force consumers to scroll instead of compose.

// BAD: 400-line PostEditor.tsx (toolbar + body + sidebar + status)
// GOOD: PostEditor.tsx (≤200) composes <Toolbar/> + <EditorBody/> + <SidebarMeta/> + <StatusPanel/>

P2Single responsibility per file

One default export OR one cohesive named-export cluster per file. Prefixed exports (`createX`, `parseX`, `serializeX`, `validateX`) = 4 files, not 4 exports.

P2Extract on the SECOND occurrence

Repeated UI pattern → `components/` or `shared/`. Util needed by two slices → `shared/<name>/utils/`. Not the third copy — the second.

P2Dynamic over hardcoded

Lookup maps over if/switch-chains; derived selectors over literal arrays; `labels` props over inline copy.

// BAD
if (kind === 'admin') return <AdminLink/>;
if (kind === 'user') return <UserLink/>;

// GOOD
const LINKS = { admin: AdminLink, user: UserLink };
const L = LINKS[kind];
return <L/>;

P2Compose, don't accumulate

Prefer a new file that composes with the existing one over editing the existing one bigger. Existing file stays small + tested; the new file is the one that changes.

NamingP2

P2File + export casing

Files: `kebab-case.ts(x)`. Component exports: `PascalCase`. Hooks: `useCamelCase`. Utils/fns: `camelCase`.

P2Types + constants placement

Per-slice types in `types.ts`; per-slice constants in `config/`. `index.ts` exists ONLY as a barrel — never put implementation in it.

P2Convex naming

Table names plural camel (`posts`, `auditLogs`); indexes `by_<field>`.

UI rulesP1

P1shadcn primitives only

All UI builds on shadcn primitives. Never raw `<button>`, `<dialog>`, `<input type=date|file>` — use `Button`, `ResponsiveDialog`, `DateField`, `FileUpload`.

P1Theme tokens, not hex

Use `bg-background` / `text-foreground` / `border-border`. Semantic status colors resolve through the tones SSOT (`_shared/.../ui/tones.ts`) — never invent a local green-means-success.

P1Mobile-first responsive

Single column base, layer `md:` / `lg:` upward.

P1No marketing chrome on workspace surfaces

Workspace templates render full-bleed (`h-dvh`) — the workspace IS the product.

P1Shell hierarchy: exactly one outer chrome

dashboard-shell owns admin/workspace chrome; admin-panel / admin / platform-admin mount INSIDE it. Never nest two chromes.

Delivery rulesP1

P1Solo-dev = push direct to main

Tests + typecheck + validate green → push direct to main. No PRs. Dokploy auto-deploys. Risky changes go to staging first: `git push origin main:staging` → verify `e2e:staging` → then main.

Why: PRs add ceremony without review benefit when the solo dev is also the reviewer.

P1Conventional commits

`feat(scope): subject` / `fix` / `chore`. Body explains WHY and lists any P1 deviations (`TODO(rr)` markers added this commit).

P1Co-author the AI

End AI-assisted commits with `Co-Authored-By: Claude <noreply@anthropic.com>` so authorship is honest.

P0Publish guardrail

NEVER run `npm publish` yourself. When publish conditions hold (packages/ modified + version bumped above `npm view` + tsc green + pushed to main), end your response with the publish suggestion and let the user run the OTP step.

P1No GitHub Actions cloud minutes

Local CI via the pre-push hook or `/sc-git ci`; Dokploy builds on push.

Copy-first & Source MapP1

P1Never greenfield what a proven source solved

Check the Source Map in CLAUDE.md first; `cp -r` → adjust import aliases → strip business-specific bits.

P1Missing source path → STOP and ask

Source Map paths are machine-local (`~/projects/...`). If a source path doesn't exist on this machine, STOP and ask — don't reconstruct from memory; that defeats copy-first.

rr distribution kinds (TEMPLATE vs SLICE)P1

rr publishes TWO installable kinds. They install to different paths and answer different needs — confusing them is the #1 support issue.

P1TEMPLATE = full-app scaffold

A TEMPLATE (catalog: `lib/content/layouts.ts`) is a whole-app starter. Install `npx rr add <template-slug>` — defaults to `--at root` (routes promoted to `app/(public)/` + `app/admin/`, `/preview/<slug>` constants auto-rewritten). `--at preview` only for sandbox demos.

Why: Templates do NOT ship slice metadata — they're monolithic scaffolds you fork.

P1SLICE = drop-in vertical feature

A SLICE (catalog: `lib/content/slices.ts`) is one self-contained feature. `npx rr add <slice-slug>` copies into `slices/<slug>/` (+ optional `convex/features/<slug>/`) with the metadata pair. Variants: `add <slug> <variant>` flattens one; `add <slug>` copies all + switcher prop.

Why: The metadata pair is what makes a slice composable + auditable.

P1Trust the CLI banner

CLI prints `[TEMPLATE]` or `[SLICE]`. Wrong banner = wrong slug.

P1Lift = sanitize first (slice path only)

Strip consumer URLs, env names, role enums, table coupling → replace with props / env-configured allowlists. `npx rr lift` is operator-manual.

P1MCP connectors via create-your-mcp

Add ChatGPT / Claude / Cursor connector support via `npx rr add create-your-mcp` — never hand-roll OAuth/PKCE.

Enforcement map

What tooling guards each rule. If a rule has no tooling row, the prompt is its only guard — treat it as P1.

≤200 LOC

`audit:file-size` + eslint `max-lines`

Barrel-only imports

eslint `no-restricted-imports` / boundaries + `audit:slices`

No raw `<a>` / `<img>` / `<button>`

eslint `no-restricted-syntax`

Validators + authz on Convex fns

`audit-bp` (P0 gate)

Metadata pair version match

`audit:slices`

Catalog scalars = generated

`gen:catalog:check` (pre-commit)

Stack pin vs package.json

`check:stack-pin`

Skills JSON sync

`sync-skills.mjs --check` (prepublishOnly)

Types

`npx tsc --noEmit`