// Config Record
>.cursor/rules — React + TypeScript (Vite SPA)
Cursor project rules (MDC) for React + TypeScript Vite SPA projects with client-side data-fetching patterns.
author:
dotmd Team
license:CC0
published:Feb 19, 2026
// Installation
>Add this file to your project repository:
- Cursor--path=
.cursor/rules/
// File Content
.cursor/rules (MDC)
1---2description: React + TypeScript Vite SPA conventions with React Router and TanStack Query patterns.3globs:4alwaysApply: true5---67# .cursorrules — React + TypeScript (Vite SPA)89You 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.1011## Quick Reference1213| Area | Convention |14| ----------------- | -------------------------------------------------------------- |15| Package manager | pnpm |16| Build tool | Vite 5+ |17| Language | TypeScript (strict mode) |18| Framework | React 18+ |19| Routing | React Router v6 (`createBrowserRouter`) |20| Data fetching | TanStack Query v5 |21| Forms | React Hook Form + Zod resolver |22| Styling | Tailwind CSS + `cn()` utility |23| State management | React hooks + URL state; Zustand for complex cross-cutting state |24| HTTP client | `ky` (or `fetch` wrapper in `lib/api-client.ts`) |25| Testing | Vitest + React Testing Library + MSW |26| Linting | ESLint + Prettier (follow existing config) |27| Imports | Use `@/` path alias (configured in `vite.config.ts` + `tsconfig.json`) |2829## Project Structure3031```32├── src/33│ ├── main.tsx # Entry point — renders <App /> into #root34│ ├── app.tsx # Router provider + global providers35│ ├── routes/ # Page components (one per route)36│ ├── components/37│ │ ├── ui/ # Reusable primitives (button, input, card)38│ │ └── [feature]/ # Feature-scoped components39│ ├── hooks/ # Custom React hooks40│ ├── lib/41│ │ ├── api-client.ts # Configured HTTP client (base URL, auth headers)42│ │ ├── utils.ts # cn() and shared utilities43│ │ └── validations/ # Zod schemas44│ ├── services/ # API service modules (one per resource)45│ ├── types/ # Shared TypeScript types46│ └── stores/ # Zustand stores (if needed)47├── index.html # Vite entry HTML48├── vite.config.ts49├── tailwind.config.ts50└── tsconfig.json51```5253## Vite-Specific Conventions5455### Environment Variables5657Vite uses `import.meta.env` with `VITE_` prefix. Never use `process.env`.5859```ts60// ✅ Correct61const apiUrl = import.meta.env.VITE_API_URL6263// ❌ Wrong — process.env doesn't exist in the browser64const apiUrl = process.env.REACT_APP_API_URL65```6667Type env vars in `src/vite-env.d.ts`:6869```ts70/// <reference types="vite/client" />71interface ImportMetaEnv {72 readonly VITE_API_URL: string73 readonly VITE_SENTRY_DSN: string74}75interface ImportMeta {76 readonly env: ImportMetaEnv77}78```7980### Dev Proxy8182Avoid CORS issues locally by proxying API calls through Vite:8384```ts85// vite.config.ts86export default defineConfig({87 server: {88 proxy: {89 "/api": { target: "http://localhost:8000", changeOrigin: true },90 },91 },92})93```9495## Routing9697Use `createBrowserRouter` — not `<BrowserRouter>` with JSX routes. The data router API enables loaders, error boundaries, and better code organization.9899```tsx100// src/app.tsx101const queryClient = new QueryClient({102 defaultOptions: { queries: { staleTime: 60_000, retry: 1 } },103})104105const router = createBrowserRouter([106 {107 element: <RootLayout />,108 errorElement: <RootErrorBoundary />,109 children: [110 { index: true, element: <HomePage /> },111 { path: "dashboard", element: <DashboardPage /> },112 {113 path: "settings",114 children: [115 { index: true, element: <SettingsPage /> },116 { path: "profile", element: <ProfilePage /> },117 ],118 },119 ],120 },121])122123export function App() {124 return (125 <QueryClientProvider client={queryClient}>126 <RouterProvider router={router} />127 </QueryClientProvider>128 )129}130```131132Use `useSearchParams` for filter/sort state that should survive page refresh:133134```tsx135const [searchParams, setSearchParams] = useSearchParams()136const status = searchParams.get("status") ?? "all"137138function handleStatusChange(newStatus: string) {139 setSearchParams((prev) => { prev.set("status", newStatus); prev.set("page", "1"); return prev })140}141```142143## Data Fetching with TanStack Query144145### Service → Hook → Component146147Components never call `fetch` directly. API calls live in service modules, TanStack Query hooks consume them.148149```ts150// src/services/posts.ts151import { api } from "@/lib/api-client"152import type { Post, CreatePostInput } from "@/types/post"153154export const postsService = {155 list: (params: { status?: string; page?: number }) =>156 api.get("posts", { searchParams: params }).json<{ data: Post[]; total: number }>(),157 get: (id: string) => api.get(`posts/${id}`).json<Post>(),158 create: (input: CreatePostInput) => api.post("posts", { json: input }).json<Post>(),159 delete: (id: string) => api.delete(`posts/${id}`),160}161```162163```ts164// src/hooks/use-posts.ts165import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"166import { postsService } from "@/services/posts"167168export const postKeys = {169 all: ["posts"] as const,170 lists: () => [...postKeys.all, "list"] as const,171 list: (params: { status?: string; page?: number }) => [...postKeys.lists(), params] as const,172 detail: (id: string) => [...postKeys.all, "detail", id] as const,173}174175export function usePostsQuery(params: { status?: string; page?: number }) {176 return useQuery({ queryKey: postKeys.list(params), queryFn: () => postsService.list(params) })177}178179export function useCreatePost() {180 const queryClient = useQueryClient()181 return useMutation({182 mutationFn: postsService.create,183 onSuccess: () => queryClient.invalidateQueries({ queryKey: postKeys.lists() }),184 })185}186```187188```tsx189// Usage in component190export function Dashboard() {191 const { data, isLoading, error } = usePostsQuery({ status: "published" })192 if (isLoading) return <DashboardSkeleton />193 if (error) return <ErrorDisplay error={error} />194 return (195 <div className="space-y-4">196 {data.data.map((post) => <PostCard key={post.id} post={post} />)}197 </div>198 )199}200```201202## Component Conventions203204- **Files:** kebab-case (`post-card.tsx`). **Exports:** PascalCase (`export function PostCard()`).205- **Named exports only** — no default exports.206- **No barrel exports.** Import from source files directly.207- **One component per file** for anything non-trivial.208209```tsx210import { cn } from "@/lib/utils"211import type { Post } from "@/types/post"212213interface PostCardProps {214 post: Post215 onDelete?: (id: string) => void216 className?: string217}218219export function PostCard({ post, onDelete, className }: PostCardProps) {220 return (221 <article className={cn("rounded-lg border border-zinc-200 p-4", className)}>222 <h2 className="text-lg font-semibold">{post.title}</h2>223 </article>224 )225}226```227228### TypeScript229230- No `any`. Use `unknown` and narrow.231- Prefer `interface` for props/object shapes. `type` for unions and intersections.232- Never use `as` to silence errors — fix the type or write a type guard.233- Annotate return types on exported functions. Let inference handle internal ones.234235### Tailwind CSS236237- Mobile-first responsive. Always provide `dark:` variants.238- Use `cn()` for conditional classes. Extend the theme instead of arbitrary values.239- Avoid `@apply` except for third-party elements.240241## Forms242243```tsx244import { useForm } from "react-hook-form"245import { zodResolver } from "@hookform/resolvers/zod"246import { z } from "zod"247248const schema = z.object({249 title: z.string().min(1, "Required").max(200),250 content: z.string().min(1, "Required"),251 status: z.enum(["draft", "published"]),252})253type FormData = z.infer<typeof schema>254255export function CreatePostForm() {256 const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({257 resolver: zodResolver(schema),258 defaultValues: { status: "draft" },259 })260 const createPost = useCreatePost()261262 return (263 <form onSubmit={handleSubmit((data) => createPost.mutateAsync(data))} className="space-y-4">264 <input {...register("title")} className="w-full rounded-md border px-3 py-2" />265 {errors.title && <p className="text-sm text-red-600">{errors.title.message}</p>}266 <button type="submit" disabled={isSubmitting}>267 {isSubmitting ? "Creating..." : "Create"}268 </button>269 </form>270 )271}272```273274## API Client275276```ts277// src/lib/api-client.ts278import ky from "ky"279280export const api = ky.create({281 prefixUrl: import.meta.env.VITE_API_URL,282 hooks: {283 beforeRequest: [(req) => {284 const token = localStorage.getItem("auth_token")285 if (token) req.headers.set("Authorization", `Bearer ${token}`)286 }],287 afterResponse: [async (_req, _opts, res) => {288 if (res.status === 401) { localStorage.removeItem("auth_token"); window.location.href = "/login" }289 }],290 },291})292```293294## Testing295296Vitest + React Testing Library + MSW for API mocking. Files: `*.test.ts(x)`, colocated with source.297298```tsx299import { renderHook, waitFor } from "@testing-library/react"300import { QueryClient, QueryClientProvider } from "@tanstack/react-query"301import { http, HttpResponse } from "msw"302import { server } from "@/test/msw-server"303import { usePostsQuery } from "./use-posts"304305function wrapper({ children }: { children: React.ReactNode }) {306 const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })307 return <QueryClientProvider client={qc}>{children}</QueryClientProvider>308}309310test("fetches posts by status", async () => {311 server.use(http.get("*/posts", () => HttpResponse.json({ data: [{ id: "1", title: "Test" }], total: 1 })))312 const { result } = renderHook(() => usePostsQuery({ status: "published" }), { wrapper })313 await waitFor(() => expect(result.current.isSuccess).toBe(true))314 expect(result.current.data?.data).toHaveLength(1)315})316```317318## Commands319320```bash321pnpm dev # Vite dev server (HMR, http://localhost:5173)322pnpm build # Type-check + production build → dist/323pnpm preview # Preview production build locally324pnpm lint # ESLint325pnpm test # Vitest (run once)326pnpm test:watch # Vitest (watch mode)327```328329## When Generating Code3303311. **Search first.** Check if a component or hook already exists before creating one.3322. **Follow existing patterns.** Consistency beats preference.3333. **No new dependencies** without asking.3344. **SPA mindset.** No server. Data comes from API calls. Don't suggest Next.js patterns.3355. **Loading states are mandatory.** Every data-fetching component needs loading and error states.336