Topic · A3
Cursor Rules for React + Vite + TanStack (Non-Next.js, 2026)
An HN comment from March 2025 surfaced a real gap: every Cursor rule for React assumes Next.js. Here is the rule for the React + Vite + TanStack Router/Query stack that doesn't.
# Cursor Rules for React + Vite + TanStack
In March 2025, drewbitt posted on Hacker News: I cannot identify a single cursor rule for client side React that is not Nextjs. The thread was about cursor.directory; the directory's React rules at the time were all framework-coupled. A year later, that's still mostly true. Search "Cursor rules React" and the top results are Next.js + React, Next.js App Router + React, or generic React tips so vague they apply to any framework.
This page is the rule for the non-Next.js React stack — Vite as the build tool, TanStack Router for navigation, TanStack Query for server state, Zod for schema, and either TanStack Form or React Hook Form for input. The full rule body is at the bottom. Every directive is anchored to a real friction point the agent produces without it.
Why the Next.js-biased rules fail on a Vite + TanStack stack
Cursor's training data is heavily weighted to Next.js patterns. Without an explicit rule, you'll get:'use client'directives in files that don't need them. Vite has no Server Components. The directive is dead code. Cursor adds it anyway because the training data does.
next/link,next/image,next/font,next/navigationimports. All non-existent in a Vite project. The agent fails on the first compile.
getServerSideProps-shaped data fetching. Wrong mental model. In a TanStack stack, data fetching is a route loader (TanStack Router) or aqueryOptionsfactory (TanStack Query).
useRouterfromnext/navigation. Should beuseNavigateanduseRouterStatefrom@tanstack/react-router.
useFormStatecalls with no Server Action. No Server Actions in a Vite SPA. The agent invents a function signature that doesn't exist.
The five directives that change the most output
1. No Next.js imports. Period.
The rule has a banned-imports list. The agent gets one paragraph at the top: This is a Vite project, not Next.js. There is nonext/ package. 'use client' is not a directive. There is no App Router and no Pages Router. Reject any suggestion that imports from next/... or uses 'use client'.
That paragraph alone removes ~70% of the wrong code Cursor produces on a new file in a Vite project.
2. Route-aware navigation with full type inference.
Before (Cursor without rule): ``tsx
import { useNavigate } from '@tanstack/react-router';
function ProductLink({ id }: { id: string }) {
const navigate = useNavigate();
return /products/${id} })}>View;
}
`
After:
`tsx
import { Link } from '@tanstack/react-router';
function ProductLink({ id }: { id: string }) {
return (
View
);
}
`
The rule body says: Prefer over useNavigate(). Use route-string templates (/products/$id) with a params object, never string-concatenated URLs. This gives you full type inference from the route tree, link prefetching, and active-link state for free.
3. TanStack Query with
queryOptions factories.
Before:
`tsx
function ProductPage() {
const { data } = useQuery({
queryKey: ['products', id],
queryFn: () => fetch(/api/products/${id}).then(r => r.json()),
});
// ...
}
`
After:
`ts
// queries/products.ts
import { queryOptions } from '@tanstack/react-query';
import { z } from 'zod';
const productSchema = z.object({ id: z.string(), name: z.string(), price: z.number() });
type Product = z.infer;
export const productQuery = (id: string) =>
queryOptions({
queryKey: ['products', id] as const,
queryFn: async ({ signal }) => {
const res = await fetch( /api/products/${id}, { signal });
if (!res.ok) throw new Error(HTTP ${res.status});
return productSchema.parse(await res.json());
},
staleTime: 30_000,
});
`
`tsx
// routes/products.$id.tsx
import { useSuspenseQuery } from '@tanstack/react-query';
import { productQuery } from '@/queries/products';
function ProductPage() {
const { data } = useSuspenseQuery(productQuery(id));
// data is typed Product, signal-canceled, runtime-validated by Zod
}
`
The rule body adds: Every server-state fetch MUST be a queryOptions({ queryKey, queryFn }) factory in src/queries/. Components import the factory and pass it to useQuery / useSuspenseQuery. Never write inline queryKey: [...] in a component. The factory pattern enables prefetching from route loaders, invalidation by key prefix, and shared types across read/write paths.
4. Route loaders for first-paint data.
Before:
`tsx
function ProductPage() {
const { data, isPending } = useQuery(productQuery(id));
if (isPending) return ;
// ...
}
`
After:
`tsx
// routes/products.$id.tsx
import { createFileRoute } from '@tanstack/react-router';
import { productQuery } from '@/queries/products';
export const Route = createFileRoute('/products/$id')({
loader: ({ context: { queryClient }, params: { id } }) =>
queryClient.ensureQueryData(productQuery(id)),
component: ProductPage,
});
function ProductPage() {
const { id } = Route.useParams();
const { data } = useSuspenseQuery(productQuery(id));
// data is loaded before the component renders — no loading state needed
}
`
The rule body says: Pages that need data on first paint MUST ensureQueryData in the route loader. The component then uses useSuspenseQuery (not useQuery) and assumes data is present. This eliminates the loading-spinner-on-back-button regression that every SPA ships at some point.
5. Forms with Zod schemas at the boundary.
Before:
`tsx
function CreateProductForm() {
const [name, setName] = useState('');
const [price, setPrice] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
fetch('/api/products', { method: 'POST', body: JSON.stringify({ name, price: Number(price) }) });
};
return (
);
}
`
After (React Hook Form + Zod):
`tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(1).max(100),
price: z.coerce.number().positive(),
});
type FormValues = z.infer;
function CreateProductForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(values),
});
if (!res.ok) throw new Error('Submit failed');
};
return (
);
}
`
The rule body says: Forms use React Hook Form with a Zod resolver, or TanStack Form with a Zod validator. No raw useState for form values. Submit state lives in formState.isSubmitting. Errors render from formState.errors[fieldName].message — never invented inline.
The full rule (paste into
.cursor/rules/react-vite.mdc)
`mdc
description: React + Vite + TanStack stack. Non-Next.js. SPA or TanStack Start SSR.
globs:
- "src//.ts"
- "src/*/.tsx"
- "vite.config.{ts,js}"
alwaysApply: false
# React + Vite + TanStack Rules
Project contract
This is a Vite + React project. There is no Next.js. There is no next/ package. 'use client' is not a directive. The router is TanStack Router. Server state is TanStack Query. Forms are React Hook Form or TanStack Form. Schema validation is Zod.
Banned constructs
- Any
next/ import.
'use client', 'use server', 'use cache' directives.
getServerSideProps, getStaticProps, useRouter from anywhere other than @tanstack/react-router.
- String-concatenated route URLs. Use
or useNavigate({ to: '...', params: {...} }).
- Inline
useQuery({ queryKey: [...], queryFn: ... }) in a component. Use a queryOptions factory.
useState for form values in feature code (test files exempt).
fetch without an AbortSignal inside a queryFn or mutationFn.
Required patterns
- File-based routing under
src/routes/. Each route file exports Route = createFileRoute('/path')({ ... }).
- Server state:
queryOptions factory in src/queries/. Components use useQuery or useSuspenseQuery with the factory.
- First-paint data:
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(...).
- Search params:
validateSearch on the route using a Zod schema.
- Forms: React Hook Form +
zodResolver(schema) OR TanStack Form + Zod validator. Never raw useState.
- Mutations:
useMutation({ mutationFn, onSuccess: () => queryClient.invalidateQueries(...) }).
- Errors: TanStack Router
errorComponent per route + global from react-error-boundary.
TanStack Start (SSR) addendum
If app.config.ts exists at the project root and exports defineConfig from @tanstack/start/config, this is a TanStack Start project (SSR). The above rules still apply with two additions:
- Server functions use
createServerFn(). Validate input with Zod inside the server fn.
- Route
loader runs on the server first; data is serialized in the HTML and rehydrated on the client.
- Cookies and headers are read via
getWebRequest() inside server functions. Never assume document or window in code that may run on the server.
When the user asks for "the Next.js way"
Reply: this project is Vite + TanStack, not Next.js. Then map the request: file-based routing → TanStack Router; data fetching → TanStack Query loader + query factory; mutations → server fn + mutation; route protection → beforeLoad guard.
`
Where this rule fails
1. The agent reverts to Next.js patterns on long sessions. The same flakiness pattern the Cursor agent knowingly ignored global rules thread documents. After ~30k tokens, 'use client' reappears. Re-pin the rule with @react-vite.mdc in the chat when you notice the drift.
2. The TanStack ecosystem changes fast. TanStack Router shipped v1 in late 2024; TanStack Start went stable in 2025. The queryOptions API in TanStack Query landed in v5. If your project is pinned to older versions, the rule will produce code that doesn't compile. Pin the rule by version: in the .mdc body, name the major versions you target.
3. Generated routes aren't always picked up. TanStack Router generates a routeTree.gen.ts file from the src/routes/ directory. If the dev server isn't running, the file is stale. Cursor will produce route files that look right but reference routes that aren't in the tree. Run npx @tanstack/router-cli generate before agent-heavy sessions.
4. The rule cannot fix bad route loaders. If a loader does too much work, the route blocks first paint. The rule mandates ensureQueryData but doesn't enforce a budget on it. In code review, look for loaders that wait on multiple sequential queries — they should Promise.all or split into parallel routes.
What to read next
- /topic/cursor-rules-typescript-5-6 — the TypeScript rule that pairs with this (especially
satisfies for route configs)
- /topic/cursor-rules-nextjs-15 — the Next.js rule for projects that do want App Router
- /topic/claude-md — the same directives translated to Claude Code's format
- /topic/agents-md — and the cross-tool AGENTS.md version
- /for/awesome-cursorrules — the 39.5k-star collection (skip the React entries unless they're explicitly non-Next.js)
- /for/awesome-cursor-rules-mdc — the 879-file .mdc variant
Sources
- drewbitt. HN comment, March 2025. The original
I cannot identify a single cursor rule for client side React that is not Nextjs quote that motivated this page.
- TanStack team. TanStack Router documentation. File-based routing,
createFileRoute, search-param schemas, route loaders.
- TanStack team. TanStack Query documentation — Query Options. The factory pattern this rule mandates.
- TanStack team. TanStack Start. SSR companion covered in the rule addendum.
- Vite team. Vite guide. Confirms no Server Components, no
'use client'.
- React Hook Form team. Documentation with Zod resolver. The form pattern the rule prefers.
- Forum thread. "Cursor agent knowingly ignored global rules". The flakiness mode this page documents.
awesome-cursorrules` repository. The reference collection — note how heavily its React entries skew Next.js.Related GitHub projects
Frequently asked
- Why does every Cursor rule for React assume Next.js?
- Because cursor.directory's React submissions skew Next.js — the framework has the loudest ecosystem and the most prolific rule authors. drewbitt called it out on HN in March 2025: `I cannot identify a single cursor rule for client side React that is not Nextjs`. The cursor.directory homepage is bot-walled by Vercel, so LLMs can't even crawl it to surface what little non-Next React content exists. The rule below targets the React + Vite + TanStack Router + TanStack Query stack specifically, with no Next.js assumptions.
- Is this rule for a SPA or for a server-rendered app?
- Either. TanStack Start (the SSR companion to TanStack Router) is covered in a separate addendum at the bottom; the core rule works for both a Vite SPA and a TanStack-Start SSR app. The split exists because the SPA case is more common in 2026 (admin dashboards, internal tools, customer-portal frontends fronting a separate API) and deserves the primary treatment.
- Should I use TanStack Router or React Router for new projects?
- TanStack Router for type safety, full code-splitting, and search-param schemas. React Router v7 for compatibility with an existing v6 codebase or for the Remix-style data router patterns. The rule below assumes TanStack Router because its file-based routing has the strongest type-inference story — Cursor produces much better navigation code when the route tree is statically typed.
- How does this rule handle data fetching?
- TanStack Query with query options factories. Every fetch lives in a `queries/<feature>.ts` file as a `queryOptions({ queryKey, queryFn })` export — the rule body forbids inline `useQuery` calls with literal keys. The reasoning: a query options factory is the only pattern that gives you type-safe shared keys across `useQuery`, `useSuspenseQuery`, `prefetchQuery`, and invalidation, without drift. Cursor reaches for inline keys constantly; the rule kills that pattern.
- Does the rule cover form state?
- Yes — TanStack Form or React Hook Form, both validated with Zod. The rule has a copy-paste template for each. The agent will pick one based on which is already imported in your codebase; if neither, it asks. No `useState` for form values, no uncontrolled inputs in feature code.