← All posts
·14 min read

Claude Code with TanStack: Rules for the Full Stack

Claude CodeTanStackReactTypeScript
Claude Code with TanStack: rules for the full stack

Why TanStack without CLAUDE.md ships code from the wrong version

TanStack is a family of headless, type-safe libraries for React and other frameworks: Query for server state, Router for type-safe routing, Table for data grids, Form for forms, and Start for the full-stack framework that ties them together. The appeal is that each library is small, owns one job, and infers its types from your code rather than asking you to annotate everything. The risk is that each library has had at least one major version with a different API, and an assistant generating "TanStack code" tends to blend the generations into something that does not compile.

A Claude Code session that wires up TanStack without project rules produces code that looks like the suite but targets a version you do not run. It writes a useQuery call with the v4 positional signature when you are on v5's single-object form. It reaches for Router's old useParams import path that moved between releases. It casts a query result to any the moment inference gets hard, which throws away the type safety that is the whole point. It mixes Query's cache with Router's loader data as if they were the same store, so a mutation invalidates nothing and the screen shows stale rows. Each of these is the difference between code that compiles against your installed versions and code that fights them, and none of it shows up until the type-checker or the runtime complains.

This guide covers the CLAUDE.md configuration that locks Claude Code into the TanStack suite you actually run: the pinned version of each library, the boundary between Query's server cache and Router's loader cache, the type-inference contract that forbids casting away inferred types, the v5 API conventions for Query and Router, and permission hooks that keep an automated session from running destructive package operations. For the routing layer on its own, Claude Code with TanStack Router covers the type-safe route tree in depth. For server state on its own, Claude Code with TanStack Query covers the caching and mutation patterns.

The TanStack CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a TanStack project it needs to declare: which libraries are in use and at what major version, the data-layer boundaries, the type-inference rules, the v5 API conventions, and the hard rules that block the version confusion Claude introduces most often.

# TanStack rules

## Versions (MANDATORY, Claude must match these)
- @tanstack/react-query: v5 (single-object signature ONLY)
- @tanstack/react-router: v1 (file-based routes, generated route tree)
- @tanstack/react-table: v8 (column helper API)
- @tanstack/react-start: v1 (server functions via createServerFn)
- NEVER generate v4 Query syntax, NEVER use pre-v8 Table APIs

## Data layer boundaries (MANDATORY)
- Query owns server state: lists, details, anything fetched from an API
- Router loaders own route-entry data, NOT a second copy of server state
- A Router loader may call a Query client, it does NOT replace Query
- Mutations invalidate Query keys, NEVER mutate the cache by hand

## Type inference (MANDATORY)
- NEVER cast a query result to `any` or `as` a different type
- Let useQuery infer from the queryFn return type
- Route params come from the generated tree, NEVER typed by hand
- If a type is wrong, fix the source, NEVER paper over it with a cast

## Query conventions
- queryKey is a typed array, factory function per resource
- queryFn returns parsed, validated data (zod at the boundary)
- staleTime and gcTime set per query, NEVER global defaults blindly

## Router conventions
- Routes are file-based under src/routes/
- Route tree is generated, NEVER hand-edited (routeTree.gen.ts)
- Navigation uses the typed Link and useNavigate, NOT string hrefs

## Hard rules
- NEVER mix v4 and v5 Query APIs in the same file
- NEVER cast away an inferred type to silence the checker
- NEVER store the same server data in both Query and a loader as the source of truth
- NEVER hand-edit the generated route tree
- NEVER use a string path where a typed route is available
- ALWAYS invalidate the relevant query keys after a mutation
- ALWAYS validate API responses at the queryFn boundary
- ALWAYS read the version from package.json before generating API calls

Four rules here prevent the majority of the breakage Claude generates without them.

The version-pinning rule is the one that matters most. TanStack Query v4 and v5 have different call signatures, Router v0 and v1 have different import paths, and Table v7 and v8 have different column APIs. Claude's training data contains all of them, so without a pin it picks whichever it saw most, which may not be yours. The rule names the exact major version of each library and tells Claude to read package.json before generating any API call, so the code targets the suite you installed.

The data-layer boundary rule prevents the most expensive class of bug. Query maintains a cache of server state keyed by query key. Router maintains loader data keyed by route. They look similar, and Claude tends to treat a loader as a place to stash a second copy of server state, which then goes stale because mutations only invalidate Query keys. The rule makes Query the single source of truth for server state and limits loaders to route-entry data, with the loader calling the Query client rather than duplicating it.

The type-inference rule protects the property that makes TanStack worth using. Every library in the suite infers types from your code, so a useQuery whose queryFn returns Promise<User> gives you a User with no annotation. The moment Claude casts that to any to silence a checker error, the inference chain breaks and every downstream type is wrong. The rule forbids the cast and requires fixing the source instead.

Install and project structure

Install the libraries the project actually uses. A full-stack TanStack app commonly has Query, Router, and Start; a dashboard adds Table.

# Core data and routing
npm install @tanstack/react-query @tanstack/react-router

# Full-stack framework (includes router integration)
npm install @tanstack/react-start

# Data grids
npm install @tanstack/react-table

# Devtools (dev dependency)
npm install -D @tanstack/react-query-devtools @tanstack/router-devtools

Keep the data layer organized so Claude finds the query factories and never reinvents them.

src/
  routes/              , file-based routes, tree generated
  lib/
    query-client.ts    , the single QueryClient instance
    queries/
      users.ts         , user query keys and functions
      posts.ts         , post query keys and functions
    mutations/
      users.ts         , user mutations with invalidation
  components/

The structure gives every resource one place for its query keys and functions. Claude reads lib/queries/users.ts, finds the existing userKeys factory, and reuses it instead of inventing a new key shape that breaks invalidation. The single QueryClient in query-client.ts is the one both the app and any Router loader use, which keeps the cache unified.

Query the v5 way

The single most version-sensitive piece of a TanStack project is the Query call signature. v5 takes one options object; v4 took positional arguments. The CLAUDE.md pins v5, so Claude generates the object form.

// src/lib/queries/users.ts
import { queryOptions } from "@tanstack/react-query";
import { z } from "zod";

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

export const userKeys = {
  all: ["users"] as const,
  detail: (id: string) => ["users", id] as const,
};

export const userQuery = (id: string) =>
  queryOptions({
    queryKey: userKeys.detail(id),
    queryFn: async () => {
      const res = await fetch(`/api/users/${id}`);
      return UserSchema.parse(await res.json());
    },
    staleTime: 60_000,
  });

Three things make this correct. The queryOptions helper is a v5 addition that produces a typed, reusable options object so the same query can be used in a component, a prefetch, and a loader without repeating the key. The queryKey comes from a factory so every consumer uses the identical key and invalidation hits all of them. The queryFn runs the response through a zod schema, so the inferred return type is the validated shape and not whatever the API happened to send. For the validation patterns at the boundary, Claude Code with Zod covers schema design.

Consuming the query in a component is a single-object call.

import { useQuery } from "@tanstack/react-query";
import { userQuery } from "../lib/queries/users";

export function UserCard({ id }: { id: string }) {
  const { data, isPending, error } = useQuery(userQuery(id));
  if (isPending) return <Spinner />;
  if (error) return <ErrorState error={error} />;
  return <div>{data.name}</div>;
}

The useQuery(userQuery(id)) form passes the prebuilt options object, which is the v5 idiom. The destructured isPending is the v5 name for the initial loading state, replacing v4's isLoading semantics. Because the options object carries the inferred type from the zod-validated queryFn, data.name is typed without any annotation. This is the inference chain the CLAUDE.md protects.

Mutations that invalidate, not mutate

The most common stale-data bug comes from a mutation that changes the server but does not tell Query which keys to refetch. The rule is to invalidate keys after a mutation, never to hand-edit the cache.

// src/lib/mutations/users.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { userKeys } from "../queries/users";

export function useUpdateUser(id: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (patch: { name?: string }) =>
      fetch(`/api/users/${id}`, {
        method: "PATCH",
        body: JSON.stringify(patch),
      }).then((r) => r.json()),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: userKeys.detail(id) });
      qc.invalidateQueries({ queryKey: userKeys.all });
    },
  });
}

The onSuccess handler invalidates both the specific user detail and the user list, so every component reading either key refetches and shows the change. There is no manual setQueryData that could drift from the server. Because the keys come from the same factory the queries use, the invalidation is guaranteed to match. This is the property that keeps the UI consistent after a write without the developer tracking which screens need updating.

Router with a generated, typed tree

TanStack Router's headline feature is that route params, search params, and links are fully typed from a generated route tree. The CLAUDE.md rule is that the tree is generated and never hand-edited, and navigation always uses the typed primitives.

// src/routes/users.$userId.tsx
import { createFileRoute } from "@tanstack/react-router";
import { userQuery } from "../lib/queries/users";

export const Route = createFileRoute("/users/$userId")({
  loader: ({ params, context }) =>
    context.queryClient.ensureQueryData(userQuery(params.userId)),
  component: UserPage,
});

function UserPage() {
  const { userId } = Route.useParams();
  // userId is typed as string from the route definition
  return <UserCard id={userId} />;
}

The file path users.$userId.tsx defines the route and its $userId param, and the generator writes the typed tree from it. The loader calls context.queryClient.ensureQueryData(userQuery(...)), which is the correct boundary: the loader does not duplicate the user data, it warms the Query cache so the component's useQuery resolves instantly. Route.useParams() returns userId typed as a string from the route definition, with no hand annotation that could drift. This is the data-layer boundary the CLAUDE.md enforces, Query owning the state and the loader prefetching it.

Navigation uses the typed Link, which rejects a path that does not exist.

import { Link } from "@tanstack/react-router";

export function UserLink({ id }: { id: string }) {
  return (
    <Link to="/users/$userId" params={{ userId: id }}>
      View profile
    </Link>
  );
}

The to="/users/$userId" is checked against the generated tree, and the params object is typed to require exactly userId. A typo in the path or a missing param is a type error at build time rather than a broken link at runtime. This is the safety a string href throws away, and the reason the CLAUDE.md forbids string paths where a typed route exists.

Tables with the column helper

TanStack Table v8 replaced the v7 column definition shape with a column helper that infers cell types from the row type. The CLAUDE.md pins v8 so Claude uses the helper rather than the old object form.

import {
  createColumnHelper,
  getCoreRowModel,
  useReactTable,
  flexRender,
} from "@tanstack/react-table";

type User = { id: string; name: string; email: string };
const columnHelper = createColumnHelper<User>();

const columns = [
  columnHelper.accessor("name", { header: "Name" }),
  columnHelper.accessor("email", { header: "Email" }),
];

export function UserTable({ data }: { data: User[] }) {
  const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });
  return (
    <table>
      <tbody>
        {table.getRowModel().rows.map((row) => (
          <tr key={row.id}>
            {row.getVisibleCells().map((cell) => (
              <td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

The createColumnHelper<User>() types every column against the User row, so accessor("name", ...) only accepts a real key and the cell renderer knows the value type. The getCoreRowModel is passed explicitly, which is the v8 requirement that the old API handled implicitly. Because the helper carries the row type through, a column referencing a field that does not exist is a type error. This is the inference TanStack Table provides and the reason the version pin matters: the v7 syntax Claude might reach for would not give you any of it.

Server functions with Start

TanStack Start adds server functions through createServerFn, which run on the server and are callable from the client with full type safety. The pattern keeps the data fetch on the server while the call site stays typed.

// src/lib/server/users.ts
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";

export const getUser = createServerFn({ method: "GET" })
  .validator(z.object({ id: z.string() }))
  .handler(async ({ data }) => {
    const user = await db.users.findById(data.id);
    return user; // typed return, serialized to the client
  });

The createServerFn declares a server-only function, the .validator runs the input through zod so a malformed call is rejected before the handler runs, and the .handler returns a typed value that Start serializes to the client. The call site gets the handler's return type with no manual annotation, and the database access never reaches the browser bundle. This is the full-stack boundary Start provides, and it composes with Query by calling the server function inside a queryFn.

Permission hooks for TanStack operations

A TanStack project's risky automated operations are around package management and the code generator. Permission hooks in .claude/settings.local.json keep the destructive ones out of an automated session.

{
  "permissions": {
    "allow": [
      "Bash(npm run dev*)",
      "Bash(npm run build*)",
      "Bash(npx tsr generate*)",
      "Bash(npx tsc --noEmit*)"
    ],
    "deny": [
      "Bash(rm -rf node_modules*)",
      "Bash(npm install * --force*)",
      "Bash(*routeTree.gen.ts*)",
      "Bash(npm uninstall @tanstack/*)"
    ]
  }
}

The allow list permits the operations a TanStack session needs: running the dev server, building, regenerating the route tree, and type-checking. The deny list blocks wiping node_modules, forced installs that could pull a mismatched version, any shell command that writes the generated route tree directly, and uninstalling a TanStack package out from under the project. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can build and type-check freely without breaking the dependency tree or the generated routes.

Common Claude Code mistakes with TanStack

Six patterns Claude generates incorrectly without CLAUDE.md constraints.

1. v4 Query signature on a v5 project

Claude generates: useQuery(key, fn, options) positional form.

Correct pattern: useQuery({ queryKey, queryFn, ...options }) single object, or pass a queryOptions result.

2. Casting a query result to silence the checker

Claude generates: const data = result.data as User.

Correct pattern: let the queryFn return type infer through; fix the source if the type is wrong.

3. Loader data treated as the source of truth

Claude generates: a loader that fetches and returns user data the component reads directly.

Correct pattern: the loader calls ensureQueryData, the component reads from Query, so mutations invalidate correctly.

4. Hand-editing the generated route tree

Claude generates: an edit to routeTree.gen.ts.

Correct pattern: edit the route file under src/routes/ and regenerate the tree.

5. String path in navigation

Claude generates: <Link to={/users/${id}}>.

Correct pattern: <Link to="/users/$userId" params={{ userId: id }}> checked against the tree.

6. Pre-v8 Table column definitions

Claude generates: a plain column object array without the column helper.

Correct pattern: createColumnHelper<Row>() so cell types infer from the row.

Add each pattern to CLAUDE.md with the corrected form. Combined with CLAUDE.md examples for the general structure, the result is TanStack code Claude generates correctly the first time.

Testing the TanStack integration

The tests that matter for TanStack are the ones that prove the data layer behaves: a query returns parsed data, a mutation invalidates the right keys, and a loader warms the cache. The type-checker covers the routing and table types, so the runtime tests focus on cache behaviour.

import { describe, it, expect } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { userQuery } from "../src/lib/queries/users";

describe("user query", () => {
  it("parses and caches a user", async () => {
    const qc = new QueryClient();
    const data = await qc.ensureQueryData(userQuery("u_1"));
    expect(data.email).toContain("@");
  });

  it("invalidates on update", async () => {
    const qc = new QueryClient();
    await qc.ensureQueryData(userQuery("u_1"));
    await qc.invalidateQueries({ queryKey: ["users", "u_1"] });
    const state = qc.getQueryState(["users", "u_1"]);
    expect(state?.isInvalidated).toBe(true);
  });
});

The first test confirms the query parses the response and the inferred type holds at runtime. The second confirms invalidation marks the cached entry stale, which is the mechanism every mutation relies on. Running these against a real QueryClient catches the regression where a key factory changes shape and invalidation silently stops matching. For the broader testing setup, Claude Code with Vitest covers the configuration patterns.

Building TanStack that matches the version you run

The TanStack CLAUDE.md in this guide produces a project where Query uses the v5 single-object signature, Router params come from the generated tree, Table columns infer from the row type through the v8 helper, Start server functions stay on the server with typed calls, and Query is the single source of truth that mutations invalidate. The result is code that type-checks against the exact suite you installed, not a blend of versions Claude saw in training.

The principle is the same as any multi-library integration with Claude Code. TanStack without a CLAUDE.md produces code that mixes generations and casts away the inference that makes the suite worth using. The CLAUDE.md is what pins the version and protects the types so Claude generates code that compiles and stays consistent after every write.

For the routing layer in depth, Claude Code with TanStack Router covers the typed route tree and search params. For server state in depth, Claude Code with TanStack Query covers caching, prefetching, and optimistic updates. For the underlying data-fetching patterns, Claude Code with React Query covers the foundations the suite builds on.

Get Claudify. Ship TanStack code that type-checks against the version you run, not the one Claude guessed.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame