.cursorrules — React + TypeScript (Vite SPA)
Cursor rules for React + TypeScript Vite SPA projects with client-side data-fetching patterns.
Install path
Use this file for each supported tool in your project.
- Cursor: Save as
.cursorrulesin your project at.cursorrules.
Configuration
.cursorrules
1# .cursorrules — React + TypeScript (Vite SPA)23You are a senior React developer working in a client-side single-page application built with Vite, TypeScript, and React Router. This is NOT a Next.js project — there is no SSR, no server components, no API routes. All data fetching happens client-side via TanStack Query. You write clean, type-safe code with clear separation between UI, data fetching, and business logic.45## Quick Reference67| Area | Convention |8| ----------------- | -------------------------------------------------------------- |9| Package manager | pnpm |10| Build tool | Vite 5+ |11| Language | TypeScript (strict mode) |12| Framework | React 18+ |13| Routing | React Router v6 (`createBrowserRouter`) |14| Data fetching | TanStack Query v5 |15| Forms | React Hook Form + Zod resolver |16| Styling | Tailwind CSS + `cn()` utility |17| State management | React hooks + URL state; Zustand for complex cross-cutting state |18| HTTP client | `ky` (or `fetch` wrapper in `lib/api-client.ts`) |19| Testing | Vitest + React Testing Library + MSW |20| Linting | ESLint + Prettier (follow existing config) |21| Imports | Use `@/` path alias (configured in `vite.config.ts` + `tsconfig.json`) |2223## Project Structure2425```26├── src/27│ ├── main.tsx # Entry point — renders <App /> into #root28│ ├── app.tsx # Router provider + global providers29│ ├── routes/ # Page components (one per route)30│ ├── components/31│ │ ├── ui/ # Reusable primitives (button, input, card)32│ │ └── [feature]/ # Feature-scoped components33│ ├── hooks/ # Custom React hooks34│ ├── lib/35│ │ ├── api-client.ts # Configured HTTP client (base URL, auth headers)36│ │ ├── utils.ts # cn() and shared utilities37│ │ └── validations/ # Zod schemas38│ ├── services/ # API service modules (one per resource)39│ ├── types/ # Shared TypeScript types40│ └── stores/ # Zustand stores (if needed)41├── index.html # Vite entry HTML42├── vite.config.ts43├── tailwind.config.ts44└── tsconfig.json45```4647## Vite-Specific Conventions4849### Environment Variables5051Vite uses `import.meta.env` with `VITE_` prefix. Never use `process.env`.5253```ts54// ✅ Correct55const apiUrl = import.meta.env.VITE_API_URL5657// ❌ Wrong — process.env doesn't exist in the browser58const apiUrl = process.env.REACT_APP_API_URL59```6061Type env vars in `src/vite-env.d.ts`:6263```ts64/// <reference types="vite/client" />65interface ImportMetaEnv {66 readonly VITE_API_URL: string67 readonly VITE_SENTRY_DSN: string68}69interface ImportMeta {70 readonly env: ImportMetaEnv71}72```7374### Dev Proxy7576Avoid CORS issues locally by proxying API calls through Vite:7778```ts79// vite.config.ts80export default defineConfig({81 server: {82 proxy: {83 "/api": { target: "http://localhost:8000", changeOrigin: true },84 },85 },86})87```8889## Routing9091Use `createBrowserRouter` — not `<BrowserRouter>` with JSX routes. The data router API enables loaders, error boundaries, and better code organization.9293```tsx94// src/app.tsx95const queryClient = new QueryClient({96 defaultOptions: { queries: { staleTime: 60_000, retry: 1 } },97})9899const router = createBrowserRouter([100 {101 element: <RootLayout />,102 errorElement: <RootErrorBoundary />,103 children: [104 { index: true, element: <HomePage /> },105 { path: "dashboard", element: <DashboardPage /> },106 {107 path: "settings",108 children: [109 { index: true, element: <SettingsPage /> },110 { path: "profile", element: <ProfilePage /> },111 ],112 },113 ],114 },115])116117export function App() {118 return (119 <QueryClientProvider client={queryClient}>120 <RouterProvider router={router} />121 </QueryClientProvider>122 )123}124```125126Use `useSearchParams` for filter/sort state that should survive page refresh:127128```tsx129const [searchParams, setSearchParams] = useSearchParams()130const status = searchParams.get("status") ?? "all"131132function handleStatusChange(newStatus: string) {133 setSearchParams((prev) => { prev.set("status", newStatus); prev.set("page", "1"); return prev })134}135```136137## Data Fetching with TanStack Query138139### Service → Hook → Component140141Components never call `fetch` directly. API calls live in service modules, TanStack Query hooks consume them.142143```ts144// src/services/posts.ts145import { api } from "@/lib/api-client"146import type { Post, CreatePostInput } from "@/types/post"147148export const postsService = {149 list: (params: { status?: string; page?: number }) =>150 api.get("posts", { searchParams: params }).json<{ data: Post[]; total: number }>(),151 get: (id: string) => api.get(`posts/${id}`).json<Post>(),152 create: (input: CreatePostInput) => api.post("posts", { json: input }).json<Post>(),153 delete: (id: string) => api.delete(`posts/${id}`),154}155```156157```ts158// src/hooks/use-posts.ts159import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"160import { postsService } from "@/services/posts"161162export const postKeys = {163 all: ["posts"] as const,164 lists: () => [...postKeys.all, "list"] as const,165 list: (params: { status?: string; page?: number }) => [...postKeys.lists(), params] as const,166 detail: (id: string) => [...postKeys.all, "detail", id] as const,167}168169export function usePostsQuery(params: { status?: string; page?: number }) {170 return useQuery({ queryKey: postKeys.list(params), queryFn: () => postsService.list(params) })171}172173export function useCreatePost() {174 const queryClient = useQueryClient()175 return useMutation({176 mutationFn: postsService.create,177 onSuccess: () => queryClient.invalidateQueries({ queryKey: postKeys.lists() }),178 })179}180```181182```tsx183// Usage in component184export function Dashboard() {185 const { data, isLoading, error } = usePostsQuery({ status: "published" })186 if (isLoading) return <DashboardSkeleton />187 if (error) return <ErrorDisplay error={error} />188 return (189 <div className="space-y-4">190 {data.data.map((post) => <PostCard key={post.id} post={post} />)}191 </div>192 )193}194```195196## Component Conventions197198- **Files:** kebab-case (`post-card.tsx`). **Exports:** PascalCase (`export function PostCard()`).199- **Named exports only** — no default exports.200- **No barrel exports.** Import from source files directly.201- **One component per file** for anything non-trivial.202203```tsx204import { cn } from "@/lib/utils"205import type { Post } from "@/types/post"206207interface PostCardProps {208 post: Post209 onDelete?: (id: string) => void210 className?: string211}212213export function PostCard({ post, onDelete, className }: PostCardProps) {214 return (215 <article className={cn("rounded-lg border border-zinc-200 p-4", className)}>216 <h2 className="text-lg font-semibold">{post.title}</h2>217 </article>218 )219}220```221222### TypeScript223224- No `any`. Use `unknown` and narrow.225- Prefer `interface` for props/object shapes. `type` for unions and intersections.226- Never use `as` to silence errors — fix the type or write a type guard.227- Annotate return types on exported functions. Let inference handle internal ones.228229### Tailwind CSS230231- Mobile-first responsive. Always provide `dark:` variants.232- Use `cn()` for conditional classes. Extend the theme instead of arbitrary values.233- Avoid `@apply` except for third-party elements.234235## Forms236237```tsx238import { useForm } from "react-hook-form"239import { zodResolver } from "@hookform/resolvers/zod"240import { z } from "zod"241242const schema = z.object({243 title: z.string().min(1, "Required").max(200),244 content: z.string().min(1, "Required"),245 status: z.enum(["draft", "published"]),246})247type FormData = z.infer<typeof schema>248249export function CreatePostForm() {250 const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({251 resolver: zodResolver(schema),252 defaultValues: { status: "draft" },253 })254 const createPost = useCreatePost()255256 return (257 <form onSubmit={handleSubmit((data) => createPost.mutateAsync(data))} className="space-y-4">258 <input {...register("title")} className="w-full rounded-md border px-3 py-2" />259 {errors.title && <p className="text-sm text-red-600">{errors.title.message}</p>}260 <button type="submit" disabled={isSubmitting}>261 {isSubmitting ? "Creating..." : "Create"}262 </button>263 </form>264 )265}266```267268## API Client269270```ts271// src/lib/api-client.ts272import ky from "ky"273274export const api = ky.create({275 prefixUrl: import.meta.env.VITE_API_URL,276 hooks: {277 beforeRequest: [(req) => {278 const token = localStorage.getItem("auth_token")279 if (token) req.headers.set("Authorization", `Bearer ${token}`)280 }],281 afterResponse: [async (_req, _opts, res) => {282 if (res.status === 401) { localStorage.removeItem("auth_token"); window.location.href = "/login" }283 }],284 },285})286```287288## Testing289290Vitest + React Testing Library + MSW for API mocking. Files: `*.test.ts(x)`, colocated with source.291292```tsx293import { renderHook, waitFor } from "@testing-library/react"294import { QueryClient, QueryClientProvider } from "@tanstack/react-query"295import { http, HttpResponse } from "msw"296import { server } from "@/test/msw-server"297import { usePostsQuery } from "./use-posts"298299function wrapper({ children }: { children: React.ReactNode }) {300 const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })301 return <QueryClientProvider client={qc}>{children}</QueryClientProvider>302}303304test("fetches posts by status", async () => {305 server.use(http.get("*/posts", () => HttpResponse.json({ data: [{ id: "1", title: "Test" }], total: 1 })))306 const { result } = renderHook(() => usePostsQuery({ status: "published" }), { wrapper })307 await waitFor(() => expect(result.current.isSuccess).toBe(true))308 expect(result.current.data?.data).toHaveLength(1)309})310```311312## Commands313314```bash315pnpm dev # Vite dev server (HMR, http://localhost:5173)316pnpm build # Type-check + production build → dist/317pnpm preview # Preview production build locally318pnpm lint # ESLint319pnpm test # Vitest (run once)320pnpm test:watch # Vitest (watch mode)321```322323## When Generating Code3243251. **Search first.** Check if a component or hook already exists before creating one.3262. **Follow existing patterns.** Consistency beats preference.3273. **No new dependencies** without asking.3284. **SPA mindset.** No server. Data comes from API calls. Don't suggest Next.js patterns.3295. **Loading states are mandatory.** Every data-fetching component needs loading and error states.330
Community feedback
0 found this helpful
Works with: