dotmd
// 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
    .cursor/rules/
// File Content
.cursor/rules (MDC)
1---
2description: React + TypeScript Vite SPA conventions with React Router and TanStack Query patterns.
3globs:
4alwaysApply: true
5---
6
7# .cursorrules — React + TypeScript (Vite SPA)
8
9You 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.
10
11## Quick Reference
12
13| 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`) |
28
29## Project Structure
30
31```
32├── src/
33│ ├── main.tsx # Entry point — renders <App /> into #root
34│ ├── app.tsx # Router provider + global providers
35│ ├── routes/ # Page components (one per route)
36│ ├── components/
37│ │ ├── ui/ # Reusable primitives (button, input, card)
38│ │ └── [feature]/ # Feature-scoped components
39│ ├── hooks/ # Custom React hooks
40│ ├── lib/
41│ │ ├── api-client.ts # Configured HTTP client (base URL, auth headers)
42│ │ ├── utils.ts # cn() and shared utilities
43│ │ └── validations/ # Zod schemas
44│ ├── services/ # API service modules (one per resource)
45│ ├── types/ # Shared TypeScript types
46│ └── stores/ # Zustand stores (if needed)
47├── index.html # Vite entry HTML
48├── vite.config.ts
49├── tailwind.config.ts
50└── tsconfig.json
51```
52
53## Vite-Specific Conventions
54
55### Environment Variables
56
57Vite uses `import.meta.env` with `VITE_` prefix. Never use `process.env`.
58
59```ts
60// ✅ Correct
61const apiUrl = import.meta.env.VITE_API_URL
62
63// ❌ Wrong — process.env doesn't exist in the browser
64const apiUrl = process.env.REACT_APP_API_URL
65```
66
67Type env vars in `src/vite-env.d.ts`:
68
69```ts
70/// <reference types="vite/client" />
71interface ImportMetaEnv {
72 readonly VITE_API_URL: string
73 readonly VITE_SENTRY_DSN: string
74}
75interface ImportMeta {
76 readonly env: ImportMetaEnv
77}
78```
79
80### Dev Proxy
81
82Avoid CORS issues locally by proxying API calls through Vite:
83
84```ts
85// vite.config.ts
86export default defineConfig({
87 server: {
88 proxy: {
89 "/api": { target: "http://localhost:8000", changeOrigin: true },
90 },
91 },
92})
93```
94
95## Routing
96
97Use `createBrowserRouter` — not `<BrowserRouter>` with JSX routes. The data router API enables loaders, error boundaries, and better code organization.
98
99```tsx
100// src/app.tsx
101const queryClient = new QueryClient({
102 defaultOptions: { queries: { staleTime: 60_000, retry: 1 } },
103})
104
105const 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])
122
123export function App() {
124 return (
125 <QueryClientProvider client={queryClient}>
126 <RouterProvider router={router} />
127 </QueryClientProvider>
128 )
129}
130```
131
132Use `useSearchParams` for filter/sort state that should survive page refresh:
133
134```tsx
135const [searchParams, setSearchParams] = useSearchParams()
136const status = searchParams.get("status") ?? "all"
137
138function handleStatusChange(newStatus: string) {
139 setSearchParams((prev) => { prev.set("status", newStatus); prev.set("page", "1"); return prev })
140}
141```
142
143## Data Fetching with TanStack Query
144
145### Service → Hook → Component
146
147Components never call `fetch` directly. API calls live in service modules, TanStack Query hooks consume them.
148
149```ts
150// src/services/posts.ts
151import { api } from "@/lib/api-client"
152import type { Post, CreatePostInput } from "@/types/post"
153
154export 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```
162
163```ts
164// src/hooks/use-posts.ts
165import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
166import { postsService } from "@/services/posts"
167
168export 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}
174
175export function usePostsQuery(params: { status?: string; page?: number }) {
176 return useQuery({ queryKey: postKeys.list(params), queryFn: () => postsService.list(params) })
177}
178
179export function useCreatePost() {
180 const queryClient = useQueryClient()
181 return useMutation({
182 mutationFn: postsService.create,
183 onSuccess: () => queryClient.invalidateQueries({ queryKey: postKeys.lists() }),
184 })
185}
186```
187
188```tsx
189// Usage in component
190export 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```
201
202## Component Conventions
203
204- **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.
208
209```tsx
210import { cn } from "@/lib/utils"
211import type { Post } from "@/types/post"
212
213interface PostCardProps {
214 post: Post
215 onDelete?: (id: string) => void
216 className?: string
217}
218
219export 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```
227
228### TypeScript
229
230- 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.
234
235### Tailwind CSS
236
237- 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.
240
241## Forms
242
243```tsx
244import { useForm } from "react-hook-form"
245import { zodResolver } from "@hookform/resolvers/zod"
246import { z } from "zod"
247
248const 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>
254
255export function CreatePostForm() {
256 const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
257 resolver: zodResolver(schema),
258 defaultValues: { status: "draft" },
259 })
260 const createPost = useCreatePost()
261
262 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```
273
274## API Client
275
276```ts
277// src/lib/api-client.ts
278import ky from "ky"
279
280export 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```
293
294## Testing
295
296Vitest + React Testing Library + MSW for API mocking. Files: `*.test.ts(x)`, colocated with source.
297
298```tsx
299import { 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"
304
305function wrapper({ children }: { children: React.ReactNode }) {
306 const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
307 return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
308}
309
310test("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```
317
318## Commands
319
320```bash
321pnpm dev # Vite dev server (HMR, http://localhost:5173)
322pnpm build # Type-check + production build → dist/
323pnpm preview # Preview production build locally
324pnpm lint # ESLint
325pnpm test # Vitest (run once)
326pnpm test:watch # Vitest (watch mode)
327```
328
329## When Generating Code
330
3311. **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