From 40a882131c3a0bcd0cffc0c8ace0ef868696ebd8 Mon Sep 17 00:00:00 2001 From: Builder Labs Date: Thu, 4 Jun 2026 17:24:17 -0700 Subject: [PATCH] Add Ship with AI rules: Next.js 15 + shadcn/ui, FastAPI + SQLAlchemy 2.0, React Native + Expo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three stack-specific rule sets from Ship with AI — production-tested rules with DO/DON'T code examples for each framework version. - Next.js 15 (shadcn/ui, Tailwind v4, Drizzle): async params, RSC, server actions, cn() utility, loading/error boundaries - Python FastAPI (SQLAlchemy 2.0, Pydantic v2): Mapped columns, ConfigDict, async sessions, service-layer architecture, typed routes - React Native Expo (SDK 52, Router v4): Pressable, FlashList, expo-image, Gesture API v2, Reanimated shared values, TanStack Query Each file is ~380 lines with 8 rules targeting version-specific patterns that AI models trained on older framework versions frequently get wrong. --- README.md | 3 + ...windv4-drizzle-cursorrules-prompt-file.mdc | 380 +++++++++++++++++ ...emy2-pydantic2-cursorrules-prompt-file.mdc | 374 +++++++++++++++++ ...ive-expo-sdk52-cursorrules-prompt-file.mdc | 388 ++++++++++++++++++ 4 files changed, 1145 insertions(+) create mode 100644 rules/nextjs15-shadcn-tailwindv4-drizzle-cursorrules-prompt-file.mdc create mode 100644 rules/python-fastapi-sqlalchemy2-pydantic2-cursorrules-prompt-file.mdc create mode 100644 rules/react-native-expo-sdk52-cursorrules-prompt-file.mdc diff --git a/README.md b/README.md index 8204dac5..4b2d388a 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ By adding selected `.mdc` files to `.cursor/rules/`, you can use these rules dir - [Next.js (Vercel, TypeScript)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/nextjs-vercel-typescript-cursorrules-prompt-file.mdc) - Next.js development with Vercel and TypeScript integration. - [Next.js (App Router)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/nextjs-app-router-cursorrules-prompt-file.mdc) - Next.js development with App Router integration. - [Next.js (Material UI, Tailwind CSS)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/nextjs-material-ui-tailwind-css-cursorrules-prompt.mdc) - Next.js development with Material UI and Tailwind CSS integration. +- [Next.js 15 (shadcn/ui, Tailwind v4, Drizzle)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/nextjs15-shadcn-tailwindv4-drizzle-cursorrules-prompt-file.mdc) - 8 production rules for Next.js 15 App Router with async params, RSC-first rendering, server actions, shadcn/ui composition, and Tailwind v4 CSS-first config. DO/DON'T code examples. - [Qwik (Basic Setup with TypeScript and Vite)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/qwik-basic-cursorrules-prompt-file.mdc) - Qwik development with TypeScript and Vite integration. - [Qwik (with Tailwind CSS)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/qwik-tailwind-cursorrules-prompt-file.mdc) - Qwik development with Tailwind CSS integration. - [React Components Creation](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/react-components-creation-cursorrules-prompt-file.mdc) - React component creation and development. @@ -147,6 +148,7 @@ By adding selected `.mdc` files to `.cursor/rules/`, you can use these rules dir - [Python (FastAPI Best Practices)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/python-fastapi-best-practices-cursorrules-prompt-f.mdc) - Python FastAPI development with best practices. - [Python (FastAPI Scalable API)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/python-fastapi-scalable-api-cursorrules-prompt-fil.mdc) - Python FastAPI development with scalable API integration. - [Python (FastAPI Production Architecture)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/fastapi-production-architecture-cursorrules-prompt-file.mdc) - FastAPI services with router/service/repository boundaries, typed provider adapters, bulkhead isolation, idempotency, and domain exceptions. +- [Python FastAPI (SQLAlchemy 2.0, Pydantic v2)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/python-fastapi-sqlalchemy2-pydantic2-cursorrules-prompt-file.mdc) - 8 production rules for FastAPI with SQLAlchemy 2.0 Mapped columns, Pydantic v2 ConfigDict, async sessions, typed endpoints, and service-layer architecture. DO/DON'T code examples. - [Python (Flask JSON Guide)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/python-flask-json-guide-cursorrules-prompt-file.mdc) - Python Flask development with JSON guide. - [Python LLM & ML Workflow](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/python-llm-ml-workflow-cursorrules-prompt-file.mdc) - Python LLM & ML development with workflow integration. - [Salesforce (Apex)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/salesforce-apex-cursorrules-prompt-file.mdc) - Salesforce development with Apex integration. @@ -166,6 +168,7 @@ By adding selected `.mdc` files to `.cursor/rules/`, you can use these rules dir - [HarmonyOS ArkTS](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/harmony-arkts.mdc) - Components, state, resources, lifecycle, layout, and accessibility. - [NativeScript](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/nativescript-cursorrules-prompt-file.mdc) - Cross-platform mobile app development. - [React Native Expo](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/react-native-expo-cursorrules-prompt-file.mdc) - Expo-based mobile app development. +- [React Native Expo (SDK 52, Expo Router v4)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/react-native-expo-sdk52-cursorrules-prompt-file.mdc) - 8 production rules for Expo SDK 52 with Pressable, FlashList, expo-image, Gesture API v2, Reanimated, and Zustand + TanStack Query. DO/DON'T code examples. - [SwiftUI Guidelines](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/swiftui-guidelines-cursorrules-prompt-file.mdc) - SwiftUI development guidelines. - [TypeScript (Expo, Jest, Detox)](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/typescript-expo-jest-detox-cursorrules-prompt-file.mdc) - TypeScript development with Expo, Jest, and Detox integration. - [UIKit Guidelines](https://github.com/PatrickJS/awesome-cursorrules/blob/main/rules/uikit-guidelines-cursorrules-prompt-file.mdc) - UIKit development guidelines. diff --git a/rules/nextjs15-shadcn-tailwindv4-drizzle-cursorrules-prompt-file.mdc b/rules/nextjs15-shadcn-tailwindv4-drizzle-cursorrules-prompt-file.mdc new file mode 100644 index 00000000..198be25b --- /dev/null +++ b/rules/nextjs15-shadcn-tailwindv4-drizzle-cursorrules-prompt-file.mdc @@ -0,0 +1,380 @@ +--- +description: "Next.js 15 App Router with shadcn/ui, Tailwind v4 CSS-first config, Drizzle ORM, and server actions — 8 production rules with DO/DON'T examples" +globs: **/*.ts, **/*.tsx +alwaysApply: false +--- + +# Next.js 15 + shadcn/ui + Tailwind v4 + Drizzle + +Production rules for Next.js 15 App Router. These target the version-specific patterns that AI models trained on older Next.js frequently get wrong. + +**Stack:** Next.js 15 · React 19 · shadcn/ui · Tailwind CSS v4 · Drizzle ORM 0.38+ · TypeScript + +--- + +## Rule 1 — Async Params Are Required in Next.js 15 + +Next.js 15 changed `params` and `searchParams` to be Promises. Accessing them synchronously is a **build error**. + +```tsx +// ✅ DO — await params before use +export default async function PostPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const post = await db.query.posts.findFirst({ + where: (posts, { eq }) => eq(posts.slug, slug), + }); + return ; +} + +// ✅ DO — same for searchParams +export default async function SearchPage({ + searchParams, +}: { + searchParams: Promise<{ q?: string; page?: string }>; +}) { + const { q = "", page = "1" } = await searchParams; + // ... +} +``` + +```tsx +// ❌ DON'T — synchronous params (Next.js 14 style, breaks in v15) +export default function PostPage({ params }: { params: { slug: string } }) { + const { slug } = params; // Runtime error in Next.js 15 +} +``` + +--- + +## Rule 2 — Server Components by Default; Push "use client" to Leaves + +Every file in `app/` is a React Server Component unless marked `"use client"`. Never make a whole page client-side just to use one stateful widget. + +```tsx +// ✅ DO — RSC fetches data, passes it to a small client leaf +// app/dashboard/page.tsx (server component — no directive needed) +import { db } from "@/lib/db"; +import { RevenueChart } from "@/components/revenue-chart"; // client component + +export default async function DashboardPage() { + const revenue = await db.query.revenue.findMany({ limit: 30 }); + return ( +
+

Dashboard

+ {/* Only this leaf is client */} +
+ ); +} +``` + +```tsx +// ❌ DON'T — entire page becomes a client component for one useState +"use client"; +import { useState, useEffect } from "react"; + +export default function DashboardPage() { + const [revenue, setRevenue] = useState([]); + useEffect(() => { + fetch("/api/revenue").then((r) => r.json()).then(setRevenue); + }, []); + // No streaming, no caching, no direct DB access, extra roundtrip +} +``` + +**Rule:** If a component needs `useState`, `useEffect`, event handlers, or browser APIs — extract just that piece into a `"use client"` component. Keep the parent RSC. + +--- + +## Rule 3 — Server Actions with Zod Validation + +Mutations go through server actions, not route handlers. Every action validates input with Zod before touching the database. + +```ts +// ✅ DO — lib/actions/posts.ts +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { posts } from "@/lib/db/schema"; +import { getCurrentUser } from "@/lib/auth"; + +const createPostSchema = z.object({ + title: z.string().min(3).max(255), + content: z.string().min(10), + published: z.boolean().default(false), +}); + +export type ActionState = { errors: Record; message: string }; + +export async function createPost( + _prev: ActionState, + formData: FormData +): Promise { + const user = await getCurrentUser(); + if (!user) redirect("/sign-in"); + + const result = createPostSchema.safeParse({ + title: formData.get("title"), + content: formData.get("content"), + published: formData.get("published") === "on", + }); + + if (!result.success) { + return { errors: result.error.flatten().fieldErrors, message: "Validation failed" }; + } + + await db.insert(posts).values({ ...result.data, authorId: user.id }); + revalidatePath("/dashboard/posts"); + return { errors: {}, message: "Post created" }; +} +``` + +```ts +// ❌ DON'T — mutation in a route handler with no validation +// app/api/posts/route.ts +export async function POST(req: Request) { + const body = await req.json(); // Unvalidated user input + await db.insert(posts).values(body); // Direct DB write — dangerous + return Response.json({ ok: true }); +} +``` + +--- + +## Rule 4 — shadcn/ui: Wrap, Don't Edit + +shadcn/ui components live in `components/ui/`. The CLI can regenerate them. Wrap to extend; never edit the source files. + +```tsx +// ✅ DO — components/submit-button.tsx +"use client"; + +import { Button, type ButtonProps } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; +import { useFormStatus } from "react-dom"; + +export function SubmitButton({ children, ...props }: ButtonProps) { + const { pending } = useFormStatus(); + return ( + + ); +} +``` + +```tsx +// ✅ DO — compose Card primitives for feature cards +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +export function PlanCard({ name, price, isPopular }: PlanCardProps) { + return ( + + +
+ {name} + {isPopular && Popular} +
+
+ ${price}/month +
+ ); +} +``` + +```tsx +// ❌ DON'T — editing components/ui/button.tsx directly +// npx shadcn@latest add button will overwrite your changes +``` + +--- + +## Rule 5 — Tailwind v4: CSS-First, No tailwind.config.js + +Tailwind v4 reads configuration from CSS `@theme` blocks. There is no `tailwind.config.js`. Creating one will be silently ignored. + +```css +/* ✅ DO — app/globals.css */ +@import "tailwindcss"; + +@theme { + --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; + --color-brand-50: oklch(0.97 0.01 250); + --color-brand-500: oklch(0.55 0.2 250); + --color-brand-900: oklch(0.25 0.1 250); + --breakpoint-xs: 30rem; +} + +/* shadcn/ui semantic tokens — use @theme inline so they reference CSS vars */ +@theme inline { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-border: hsl(var(--border)); +} +``` + +```js +// ❌ DON'T — tailwind.config.js/ts is Tailwind v3 syntax; ignored in v4 +module.exports = { + content: ["./app/**/*.tsx"], + theme: { extend: { colors: { brand: "#4f46e5" } } }, +}; +``` + +Also: use `@import "tailwindcss"` — not the old v3 directives: + +```css +/* ❌ DON'T — v3 directives */ +@tailwind base; +@tailwind components; +@tailwind utilities; +``` + +--- + +## Rule 6 — Drizzle ORM: Relational Queries and Typed Schema + +Use `db.query.*` for reads and export `$inferSelect` / `$inferInsert` types everywhere. + +```ts +// ✅ DO — lib/db/schema.ts +import { pgTable, uuid, varchar, text, boolean, timestamp, index } from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; + +export const posts = pgTable( + "posts", + { + id: uuid("id").defaultRandom().primaryKey(), + title: varchar("title", { length: 255 }).notNull(), + slug: varchar("slug", { length: 255 }).notNull().unique(), + content: text("content"), + published: boolean("published").notNull().default(false), + authorId: uuid("author_id").notNull().references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (t) => [index("posts_author_id_idx").on(t.authorId)] +); + +export type Post = typeof posts.$inferSelect; +export type NewPost = typeof posts.$inferInsert; + +// ✅ DO — relational query (generates optimal JOIN) +const postsWithAuthors = await db.query.posts.findMany({ + where: (posts, { eq }) => eq(posts.published, true), + with: { author: { columns: { id: true, name: true } } }, + orderBy: (posts, { desc }) => [desc(posts.createdAt)], + limit: 20, +}); +``` + +```ts +// ❌ DON'T — creating a Pool manually (unnecessary in Drizzle 0.38+) +import { Pool } from "pg"; +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +export const db = drizzle(pool, { schema }); + +// ✅ DO — pass connection string directly +export const db = drizzle(process.env.DATABASE_URL!, { schema }); +``` + +--- + +## Rule 7 — cn() for Conditional Classes + +Always merge Tailwind classes through `cn()` (clsx + tailwind-merge) to avoid class conflicts. + +```tsx +// ✅ DO — components/ui/badge.tsx pattern +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground", + secondary: "bg-secondary text-secondary-foreground", + destructive: "bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { variant: "default" }, + } +); + +interface BadgeProps extends React.HTMLAttributes, VariantProps {} + +export function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} +``` + +```tsx +// ❌ DON'T — string concatenation causes class conflicts +
// className="bg-red-500" won't override +``` + +--- + +## Rule 8 — Loading / Error Boundaries Per Route Segment + +Every data-fetching route segment needs a `loading.tsx`. Every route group needs an `error.tsx`. + +```tsx +// ✅ DO — app/(dashboard)/loading.tsx +import { Skeleton } from "@/components/ui/skeleton"; + +export default function DashboardLoading() { + return ( +
+ +
+ + + +
+
+ ); +} + +// ✅ DO — app/(dashboard)/error.tsx +"use client"; +export default function DashboardError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+

Something went wrong: {error.message}

+ +
+ ); +} +``` + +```tsx +// ❌ DON'T — unhandled async errors crash the page with no recovery +export default async function DashboardPage() { + const data = await db.query.metrics.findMany(); // If this throws — white screen + return ; +} +``` + +--- + +**Full 8-rule set with detailed examples:** https://github.com/thebuilderlabs/ship-with-ai diff --git a/rules/python-fastapi-sqlalchemy2-pydantic2-cursorrules-prompt-file.mdc b/rules/python-fastapi-sqlalchemy2-pydantic2-cursorrules-prompt-file.mdc new file mode 100644 index 00000000..1ee79180 --- /dev/null +++ b/rules/python-fastapi-sqlalchemy2-pydantic2-cursorrules-prompt-file.mdc @@ -0,0 +1,374 @@ +--- +description: "FastAPI with SQLAlchemy 2.0 Mapped columns, Pydantic v2 ConfigDict, async sessions, and service-layer architecture — 8 production rules with DO/DON'T examples" +globs: **/*.py +alwaysApply: false +--- + +# Python FastAPI + SQLAlchemy 2.0 + Pydantic v2 + +Production rules for FastAPI. These target version-specific patterns that AI models trained on SQLAlchemy 1.x / Pydantic v1 frequently get wrong. + +**Stack:** FastAPI · SQLAlchemy 2.0 · Pydantic v2 · asyncpg · Alembic · Python 3.12+ + +--- + +## Rule 1 — SQLAlchemy 2.0: Mapped[] Columns, Not Column() + +SQLAlchemy 2.0 uses the `Mapped` annotation syntax. The old `Column()` style still works but loses type safety and IDE support. + +```python +# ✅ DO — SQLAlchemy 2.0 Mapped style +from datetime import datetime +from sqlalchemy import String, Text, ForeignKey +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + full_name: Mapped[str] = mapped_column(String(255)) + bio: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(default=True) + created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow) + + posts: Mapped[list["Post"]] = relationship(back_populates="author") +``` + +```python +# ❌ DON'T — SQLAlchemy 1.x Column style (no type safety) +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String(255), unique=True) + is_active = Column(Boolean, default=True) + # No type annotations — IDE can't infer user.email type +``` + +--- + +## Rule 2 — Pydantic v2: ConfigDict, Not class Config + +Pydantic v2 replaced the inner `class Config` with `model_config = ConfigDict(...)`. Using the old style raises a deprecation warning (and breaks in strict mode). + +```python +# ✅ DO — Pydantic v2 ConfigDict +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class AppSchema(BaseModel): + """Base schema all project schemas inherit from.""" + model_config = ConfigDict( + from_attributes=True, # Replaces orm_mode = True + str_strip_whitespace=True, + str_min_length=1, + ) + + +class UserResponse(AppSchema): + id: int + email: EmailStr + full_name: str + bio: str | None + is_active: bool + created_at: datetime +``` + +```python +# ❌ DON'T — Pydantic v1 inner Config class +class UserSchema(BaseModel): + class Config: + orm_mode = True # Deprecated — use from_attributes=True + schema_extra = {...} # Deprecated — use json_schema_extra + email: str +``` + +--- + +## Rule 3 — Separate Create / Update / Response Schemas + +Never use one schema for all three operations. Leaks internal fields and prevents partial updates. + +```python +# ✅ DO — three schemas per domain entity +from pydantic import Field, field_validator + + +class UserCreate(AppSchema): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + full_name: str = Field(max_length=255) + + @field_validator("password") + @classmethod + def password_complexity(cls, v: str) -> str: + if not any(c.isupper() for c in v): + raise ValueError("Password must contain an uppercase letter") + if not any(c.isdigit() for c in v): + raise ValueError("Password must contain a digit") + return v + + +class UserUpdate(AppSchema): + model_config = ConfigDict(from_attributes=True, str_min_length=None) + email: EmailStr | None = None + full_name: str | None = Field(default=None, max_length=255) + bio: str | None = None + + +class UserResponse(AppSchema): + id: int + email: EmailStr + full_name: str + bio: str | None + is_active: bool + created_at: datetime + # NOTE: password / hashed_password never declared here +``` + +```python +# ❌ DON'T — one schema for everything +class User(BaseModel): + id: int | None = None + email: str + password: str # Exposed in response! + hashed_password: str # Internal field leaked + full_name: str +``` + +--- + +## Rule 4 — Async Session with expire_on_commit=False + +`expire_on_commit=True` (the default) causes `MissingGreenlet` errors when you access attributes after `await session.commit()`. Always disable it. + +```python +# ✅ DO — app/database.py +from sqlalchemy.ext.asyncio import ( + AsyncSession, async_sessionmaker, create_async_engine, +) + +engine = create_async_engine( + settings.database_url, # postgresql+asyncpg://... + pool_size=10, + max_overflow=20, + pool_pre_ping=True, + pool_recycle=3600, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, # ← Critical for async — prevents MissingGreenlet +) + +# ✅ DO — dependency with commit/rollback +from collections.abc import AsyncIterator + +async def get_db() -> AsyncIterator[AsyncSession]: + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise +``` + +```python +# ❌ DON'T — synchronous driver or missing expire_on_commit=False +engine = create_engine("postgresql://user:pass@host/db") # Blocks the event loop + +# ❌ DON'T — accessing attributes after commit without expire_on_commit=False +user = User(email="a@b.com") +session.add(user) +await session.commit() +print(user.email) # MissingGreenlet crash! +``` + +--- + +## Rule 5 — Service Layer Architecture + +Routers are thin. Business logic lives in service classes injected via `Depends`. + +```python +# ✅ DO — app/domain/users/service.py +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + + +class UserService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def get_by_id(self, user_id: int) -> User | None: + return await self.db.get(User, user_id) + + async def get_by_email(self, email: str) -> User | None: + result = await self.db.execute(select(User).where(User.email == email)) + return result.scalar_one_or_none() + + async def create(self, data: UserCreate, hashed_password: str) -> User: + user = User( + email=data.email, + full_name=data.full_name, + hashed_password=hashed_password, + ) + self.db.add(user) + await self.db.flush() # Get the id without committing + return user + + +# ✅ DO — app/domain/users/router.py +from fastapi import APIRouter, Depends, HTTPException +from app.dependencies import get_db + +router = APIRouter(prefix="/users", tags=["users"]) + +async def get_user_service(db: AsyncSession = Depends(get_db)) -> UserService: + return UserService(db) + +UserServiceDep = Annotated[UserService, Depends(get_user_service)] + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user(user_id: int, service: UserServiceDep) -> UserResponse: + user = await service.get_by_id(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return UserResponse.model_validate(user) +``` + +```python +# ❌ DON'T — database queries in the router +@router.get("/{user_id}") +async def get_user(user_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + # Business logic + DB queries leaking into the transport layer +``` + +--- + +## Rule 6 — Typed Route Responses, Never dict + +Always declare a `response_model` or return type annotation. Returning `dict` skips validation and produces broken OpenAPI docs. + +```python +# ✅ DO — explicit response model, validated output +@router.get("/", response_model=list[UserResponse]) +async def list_users(service: UserServiceDep) -> list[UserResponse]: + users = await service.list_all() + return [UserResponse.model_validate(u) for u in users] + +# ✅ DO — paginated response with generic schema +@router.get("/search") +async def search_users( + q: str = Query(min_length=1), + page: int = Query(ge=1, default=1), + service: UserServiceDep, +) -> PaginatedResponse[UserResponse]: + items, total = await service.search(q, page) + return PaginatedResponse( + items=[UserResponse.model_validate(u) for u in items], + total=total, page=page, page_size=20, + pages=-(-total // 20), + ) +``` + +```python +# ❌ DON'T — dict return skips Pydantic validation and breaks docs +@router.get("/{user_id}") +async def get_user(user_id: int) -> dict: + return {"id": user_id, "maybee_name": "typo"} # No validation, typos slip through +``` + +--- + +## Rule 7 — Async Routes for I/O; Plain def for CPU + +`async def` routes run on the event loop. An `async def` that does blocking I/O freezes all concurrent requests. + +```python +# ✅ DO — async for DB / HTTP / file I/O +@router.get("/{user_id}") +async def get_user(user_id: int, service: UserServiceDep) -> UserResponse: + user = await service.get_by_id(user_id) + return UserResponse.model_validate(user) + +# ✅ DO — plain def for CPU-only work (FastAPI runs it in a thread pool) +@router.get("/hash") +def compute_hash(data: str) -> dict[str, str]: + import hashlib + return {"hash": hashlib.sha256(data.encode()).hexdigest()} + +# ✅ DO — httpx for async outbound HTTP +import httpx + +@router.get("/proxy") +async def proxy(url: str) -> dict: + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=10) + return resp.json() +``` + +```python +# ❌ DON'T — blocking calls inside async functions +@router.get("/data") +async def get_data(): + import requests + response = requests.get("https://api.example.com/data") # Blocks event loop! + return response.json() +``` + +--- + +## Rule 8 — Pydantic Settings for Configuration + +Use `pydantic-settings` for config. Never `os.getenv()` scattered across files. + +```python +# ✅ DO — app/config.py +from pydantic import PostgresDsn, computed_field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + database_url: PostgresDsn + secret_key: str + debug: bool = False + db_pool_size: int = 10 + db_max_overflow: int = 20 + + @computed_field + @property + def async_database_url(self) -> str: + return str(self.database_url).replace("postgresql://", "postgresql+asyncpg://") + + +settings = Settings() +``` + +```python +# ❌ DON'T — os.getenv() scattered everywhere +import os + +DATABASE_URL = os.getenv("DATABASE_URL") # No validation, no type safety +SECRET_KEY = os.getenv("SECRET_KEY", "changeme") # Dangerous default +``` + +--- + +**Full 8-rule set with detailed examples:** https://github.com/thebuilderlabs/ship-with-ai diff --git a/rules/react-native-expo-sdk52-cursorrules-prompt-file.mdc b/rules/react-native-expo-sdk52-cursorrules-prompt-file.mdc new file mode 100644 index 00000000..68a1c030 --- /dev/null +++ b/rules/react-native-expo-sdk52-cursorrules-prompt-file.mdc @@ -0,0 +1,388 @@ +--- +description: "React Native Expo SDK 52 with Expo Router v4, Pressable, FlashList, expo-image, Gesture API v2, Reanimated, and Zustand + TanStack Query — 8 production rules with DO/DON'T examples" +globs: **/*.ts, **/*.tsx +alwaysApply: false +--- + +# React Native Expo SDK 52 + Expo Router v4 + +Production rules for Expo SDK 52. These target version-specific patterns that AI models trained on older React Native or Expo SDKs frequently get wrong. + +**Stack:** Expo SDK 52 · React Native 0.76 (New Architecture) · React 19 · Expo Router v4 · TypeScript + +--- + +## Rule 1 — Pressable Over TouchableOpacity + +`TouchableOpacity`, `TouchableHighlight`, and `TouchableNativeFeedback` are legacy APIs. `Pressable` is the current, flexible primitive for all touchable elements. + +```tsx +// ✅ DO — Pressable with dynamic style and ripple +import { Pressable, Text, StyleSheet } from "react-native"; + +export function Button({ title, onPress }: { title: string; onPress: () => void }) { + return ( + [ + styles.button, + { opacity: pressed ? 0.75 : 1 }, + ]} + android_ripple={{ color: "rgba(255,255,255,0.2)" }} + accessibilityRole="button" + accessibilityLabel={title} + > + {({ pressed }) => ( + {title} + )} + + ); +} + +const styles = StyleSheet.create({ + button: { backgroundColor: "#0066FF", borderRadius: 8, paddingVertical: 12, paddingHorizontal: 20 }, + label: { color: "#fff", fontWeight: "600", textAlign: "center" }, + labelPressed: { color: "rgba(255,255,255,0.8)" }, +}); +``` + +```tsx +// ❌ DON'T — deprecated Touchable components +import { TouchableOpacity, TouchableHighlight } from "react-native"; + +// TouchableOpacity — no render prop, limited customization + + {title} + +``` + +--- + +## Rule 2 — FlashList Over FlatList + +`@shopify/flash-list` significantly outperforms `FlatList` for long lists. It recycles cells by component type rather than by index. + +```tsx +// ✅ DO — FlashList with estimatedItemSize (required for optimization) +import { FlashList } from "@shopify/flash-list"; +import { memo } from "react"; + +interface Post { id: string; title: string; excerpt: string } + +// Extract renderItem to a stable memoized component +const PostCard = memo(function PostCard({ post }: { post: Post }) { + return ( + + {post.title} + {post.excerpt} + + ); +}); + +function PostList({ posts }: { posts: Post[] }) { + return ( + } + estimatedItemSize={120} // ← Required; measure a typical item + keyExtractor={(item) => item.id} + contentContainerStyle={{ paddingHorizontal: 16 }} + ItemSeparatorComponent={() => } + ListEmptyComponent={No posts yet} + /> + ); +} +``` + +```tsx +// ❌ DON'T — FlatList for long lists, or FlashList without estimatedItemSize +import { FlatList } from "react-native"; + + } /> + +// ❌ DON'T — inline renderItem (creates new function every render) + {item.title}} + // Missing estimatedItemSize — FlashList warns and can't optimize +/> +``` + +--- + +## Rule 3 — expo-image Over React Native Image + +`expo-image` provides disk caching, blurhash placeholders, smooth transitions, and better performance. React Native's built-in `Image` does none of this. + +```tsx +// ✅ DO — expo-image with blurhash placeholder and cache +import { Image } from "expo-image"; + +const BLURHASH = "|rF?hV%2WCj[ayj[a|j[az_NaeWBj@ayfRayfQfQM{M|azj[azf6fQfQIpWXofj[ayj[j[fQayWCoeoeaya}j[ayfQa{oLj?j[WB"; + +function Avatar({ uri, size = 48 }: { uri: string; size?: number }) { + return ( + + ); +} +``` + +```tsx +// ❌ DON'T — React Native Image has no cache, no placeholder, no transitions +import { Image } from "react-native"; + + +// No disk cache, no blurhash, no fade-in — flash of blank content on every mount +``` + +--- + +## Rule 4 — Expo Router v4 File-Based Routing + +Expo Router v4 uses file-based routing. Keep route files as thin shells that delegate to feature screens. Use route groups `(name)` for layout segmentation and auth-gating. + +```tsx +// ✅ DO — app/(tabs)/profile.tsx (thin shell) +import { ProfileScreen } from "@/features/profile/screens/ProfileScreen"; +export default function ProfileRoute() { + return ; +} + +// ✅ DO — app/(tabs)/_layout.tsx (auth guard + tab bar) +import { Redirect, Tabs } from "expo-router"; +import { useAuth } from "@/features/auth/hooks/useAuth"; + +export default function TabsLayout() { + const { isAuthenticated } = useAuth(); + if (!isAuthenticated) return ; + + return ( + + + + + ); +} + +// ✅ DO — typed route params with useLocalSearchParams +// app/post/[id].tsx +import { useLocalSearchParams } from "expo-router"; + +export default function PostRoute() { + const { id } = useLocalSearchParams<{ id: string }>(); + return ; +} +``` + +```tsx +// ❌ DON'T — business logic in route files +// app/(tabs)/profile.tsx +export default function ProfileRoute() { + const [profile, setProfile] = useState(null); + useEffect(() => { fetchProfile().then(setProfile); }, []); + // 200 lines of UI + logic — impossible to test or reuse +} + +// ❌ DON'T — passing objects through route params (must be serializable strings) +router.push({ pathname: "/post/[id]", params: { id: "123", data: JSON.stringify(bigObj) } }); +``` + +--- + +## Rule 5 — Gesture Handler v2 API (Gesture.Pan, not PanGestureHandler) + +Expo SDK 52 ships with `react-native-gesture-handler` v2. The legacy `PanGestureHandler` component API is deprecated. Use `Gesture.*` + `GestureDetector`. + +```tsx +// ✅ DO — Gesture API v2 with Reanimated shared values +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import Animated, { + useSharedValue, useAnimatedStyle, withSpring, runOnJS, +} from "react-native-reanimated"; + +function SwipeableCard({ onDismiss }: { onDismiss: () => void }) { + const translateX = useSharedValue(0); + + const pan = Gesture.Pan() + .onUpdate((e) => { translateX.value = e.translationX; }) + .onEnd((e) => { + if (Math.abs(e.translationX) > 150) { + translateX.value = withSpring(e.translationX > 0 ? 500 : -500); + runOnJS(onDismiss)(); + } else { + translateX.value = withSpring(0); + } + }); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); + + return ( + + + Swipe to dismiss + + + ); +} +``` + +```tsx +// ❌ DON'T — legacy component-based gesture API +import { PanGestureHandler } from "react-native-gesture-handler"; + + + + +``` + +--- + +## Rule 6 — SafeAreaView from react-native-safe-area-context + +The `SafeAreaView` from `react-native` is iOS-only and has no edge control. Always use the one from `react-native-safe-area-context`. + +```tsx +// ✅ DO — safe-area-context SafeAreaView with edge control +import { SafeAreaView } from "react-native-safe-area-context"; + +export function Screen({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +// ✅ DO — useSafeAreaInsets for manual control +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +export function CustomHeader() { + const insets = useSafeAreaInsets(); + return ( + + My App + + ); +} +``` + +```tsx +// ❌ DON'T — react-native SafeAreaView (iOS-only, no edge control) +import { SafeAreaView } from "react-native"; + + + {/* Notch bleeds on Android */} + +``` + +--- + +## Rule 7 — Zustand for UI State, TanStack Query for Server State + +Don't use a global store for server/async data. TanStack Query handles caching, deduplication, and background refetch. Zustand is for local UI state only. + +```tsx +// ✅ DO — TanStack Query for server data +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + +function usePost(id: string) { + return useQuery({ + queryKey: ["post", id], + queryFn: () => api.posts.get(id), + staleTime: 1000 * 60 * 5, // 5 minutes + }); +} + +function useCreatePost() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: api.posts.create, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["posts"] }), + }); +} + +// ✅ DO — Zustand for UI-only state (modal open, selected tab, etc.) +import { create } from "zustand"; + +interface UIStore { + isFilterOpen: boolean; + openFilter: () => void; + closeFilter: () => void; +} + +export const useUIStore = create((set) => ({ + isFilterOpen: false, + openFilter: () => set({ isFilterOpen: true }), + closeFilter: () => set({ isFilterOpen: false }), +})); +``` + +```tsx +// ❌ DON'T — storing fetched data in Zustand (duplicates React Query's job) +export const usePostStore = create((set) => ({ + posts: [], + loading: false, + fetchPosts: async () => { + set({ loading: true }); + const posts = await api.posts.list(); + set({ posts, loading: false }); + }, +})); +``` + +--- + +## Rule 8 — Reanimated Shared Values for Animations + +`useSharedValue` + `useAnimatedStyle` run on the UI thread. Never use React state for animation values — it causes JS-thread jank. + +```tsx +// ✅ DO — Reanimated shared values + withSpring +import Animated, { + useSharedValue, useAnimatedStyle, withSpring, withTiming, +} from "react-native-reanimated"; + +function ExpandableCard({ isExpanded }: { isExpanded: boolean }) { + const height = useSharedValue(80); + + useEffect(() => { + height.value = withSpring(isExpanded ? 240 : 80, { damping: 15 }); + }, [isExpanded]); + + const animatedStyle = useAnimatedStyle(() => ({ height: height.value })); + + return ( + + Content + + ); +} +``` + +```tsx +// ❌ DON'T — React state for animation (JS thread, causes dropped frames) +function ExpandableCard({ isExpanded }: { isExpanded: boolean }) { + const [height, setHeight] = useState(80); + + useEffect(() => { + // Runs on JS thread — can drop frames on heavy lists + setHeight(isExpanded ? 240 : 80); + }, [isExpanded]); + + return Content; +} +``` + +--- + +**Full 8-rule set with detailed examples:** https://github.com/thebuilderlabs/ship-with-ai