dotmd

.cursorrules — React + TypeScript (Vite SPA)

Cursor rules for React + TypeScript Vite SPA projects with client-side data-fetching patterns.

By dotmd TeamCC0Published Feb 19, 2026View source ↗

Install path

Use this file for each supported tool in your project.

  • Cursor: Save as .cursorrules in your project at .cursorrules.

Configuration

.cursorrules

1# .cursorrules — React + TypeScript (Vite SPA)
2
3You 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.
4
5## Quick Reference
6
7| 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`) |
22
23## Project Structure
24
25```
26├── src/
27│ ├── main.tsx # Entry point — renders <App /> into #root
28│ ├── app.tsx # Router provider + global providers
29│ ├── routes/ # Page components (one per route)
30│ ├── components/
31│ │ ├── ui/ # Reusable primitives (button, input, card)
32│ │ └── [feature]/ # Feature-scoped components
33│ ├── hooks/ # Custom React hooks
34│ ├── lib/
35│ │ ├── api-client.ts # Configured HTTP client (base URL, auth headers)
36│ │ ├── utils.ts # cn() and shared utilities
37│ │ └── validations/ # Zod schemas
38│ ├── services/ # API service modules (one per resource)
39│ ├── types/ # Shared TypeScript types
40│ └── stores/ # Zustand stores (if needed)
41├── index.html # Vite entry HTML
42├── vite.config.ts
43├── tailwind.config.ts
44└── tsconfig.json
45```
46
47## Vite-Specific Conventions
48
49### Environment Variables
50
51Vite uses `import.meta.env` with `VITE_` prefix. Never use `process.env`.
52
53```ts
54// ✅ Correct
55const apiUrl = import.meta.env.VITE_API_URL
56
57// ❌ Wrong — process.env doesn't exist in the browser
58const apiUrl = process.env.REACT_APP_API_URL
59```
60
61Type env vars in `src/vite-env.d.ts`:
62
63```ts
64/// <reference types="vite/client" />
65interface ImportMetaEnv {
66 readonly VITE_API_URL: string
67 readonly VITE_SENTRY_DSN: string
68}
69interface ImportMeta {
70 readonly env: ImportMetaEnv
71}
72```
73
74### Dev Proxy
75
76Avoid CORS issues locally by proxying API calls through Vite:
77
78```ts
79// vite.config.ts
80export default defineConfig({
81 server: {
82 proxy: {
83 "/api": { target: "http://localhost:8000", changeOrigin: true },
84 },
85 },
86})
87```
88
89## Routing
90
91Use `createBrowserRouter` — not `<BrowserRouter>` with JSX routes. The data router API enables loaders, error boundaries, and better code organization.
92
93```tsx
94// src/app.tsx
95const queryClient = new QueryClient({
96 defaultOptions: { queries: { staleTime: 60_000, retry: 1 } },
97})
98
99const 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])
116
117export function App() {
118 return (
119 <QueryClientProvider client={queryClient}>
120 <RouterProvider router={router} />
121 </QueryClientProvider>
122 )
123}
124```
125
126Use `useSearchParams` for filter/sort state that should survive page refresh:
127
128```tsx
129const [searchParams, setSearchParams] = useSearchParams()
130const status = searchParams.get("status") ?? "all"
131
132function handleStatusChange(newStatus: string) {
133 setSearchParams((prev) => { prev.set("status", newStatus); prev.set("page", "1"); return prev })
134}
135```
136
137## Data Fetching with TanStack Query
138
139### Service → Hook → Component
140
141Components never call `fetch` directly. API calls live in service modules, TanStack Query hooks consume them.
142
143```ts
144// src/services/posts.ts
145import { api } from "@/lib/api-client"
146import type { Post, CreatePostInput } from "@/types/post"
147
148export 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```
156
157```ts
158// src/hooks/use-posts.ts
159import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
160import { postsService } from "@/services/posts"
161
162export 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}
168
169export function usePostsQuery(params: { status?: string; page?: number }) {
170 return useQuery({ queryKey: postKeys.list(params), queryFn: () => postsService.list(params) })
171}
172
173export function useCreatePost() {
174 const queryClient = useQueryClient()
175 return useMutation({
176 mutationFn: postsService.create,
177 onSuccess: () => queryClient.invalidateQueries({ queryKey: postKeys.lists() }),
178 })
179}
180```
181
182```tsx
183// Usage in component
184export 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```
195
196## Component Conventions
197
198- **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.
202
203```tsx
204import { cn } from "@/lib/utils"
205import type { Post } from "@/types/post"
206
207interface PostCardProps {
208 post: Post
209 onDelete?: (id: string) => void
210 className?: string
211}
212
213export 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```
221
222### TypeScript
223
224- 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.
228
229### Tailwind CSS
230
231- 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.
234
235## Forms
236
237```tsx
238import { useForm } from "react-hook-form"
239import { zodResolver } from "@hookform/resolvers/zod"
240import { z } from "zod"
241
242const 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>
248
249export function CreatePostForm() {
250 const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
251 resolver: zodResolver(schema),
252 defaultValues: { status: "draft" },
253 })
254 const createPost = useCreatePost()
255
256 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```
267
268## API Client
269
270```ts
271// src/lib/api-client.ts
272import ky from "ky"
273
274export 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```
287
288## Testing
289
290Vitest + React Testing Library + MSW for API mocking. Files: `*.test.ts(x)`, colocated with source.
291
292```tsx
293import { 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"
298
299function wrapper({ children }: { children: React.ReactNode }) {
300 const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
301 return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
302}
303
304test("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```
311
312## Commands
313
314```bash
315pnpm dev # Vite dev server (HMR, http://localhost:5173)
316pnpm build # Type-check + production build → dist/
317pnpm preview # Preview production build locally
318pnpm lint # ESLint
319pnpm test # Vitest (run once)
320pnpm test:watch # Vitest (watch mode)
321```
322
323## When Generating Code
324
3251. **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: