Skip to content

TanStack Start Project Conventions

Why this matters

Conventions exist so that every developer on the team can navigate any part of the codebase without having to ask where things live. When conventions are inconsistent — some queries in src/api/, some in src/hooks/, some inlined in route files — the cognitive overhead compounds with every new developer and every new feature. These conventions are the ones enforced across all TanStack Start projects built with this skill.

This file is the single source of truth for naming, file structure, and configuration decisions. The other recipes show how to use each feature; this file shows where things go and what to call them.

Vite plugin order

Always in this exact sequence — no exceptions:

ts
plugins: [tanstackStart(), nitro(), react(), svgr(), tailwindcss()]

Or with explicit vite-tsconfig-paths:

ts
plugins: [tsconfigPaths(), tanstackStart(), nitro(), svgr(), react(), tailwindcss()]

Never add @tanstack/router-plugin as a separate entry in plugins. It is included inside tanstackStart().

Path alias

~/* maps to src/*. Defined in tsconfig.json as "paths": { "~/*": ["./src/*"] }. Use it everywhere — never use relative ../../ imports across feature boundaries.

Route file naming

PatternExampleURL
Static segmentroutes/about.tsx/about
Index routeroutes/posts/index.tsx/posts
Dynamic paramroutes/posts/$postId.tsx/posts/:postId
Splatroutes/docs/$.tsx/docs/*
Layout onlyroutes/posts/route.tsx(no URL change, renders <Outlet/>)
Pathless auth grouproutes/(protected)/route.tsx(no URL segment)
HTTP-only endpointroutes/api/health.ts/api/health
Lazy componentroutes/heavy.lazy.tsxpaired with routes/heavy.tsx

Flat dotted names for nesting without directories: (protected)/app.index.tsx, (protected)/app.route.tsx.

We do not use the _auth.tsx underscore prefix pattern for auth layouts. All auth gating goes in named pathless groups ((protected)/).

Never create or edit src/routeTree.gen.ts — it is regenerated by tanstackStart() on every dev and build.

Source directory layout

apps/
  web/                     # TanStack Start app
    src/
      router.tsx           # getRouter() + Register module augmentation
      routes/              # thin coordinators only — no business logic inline
      shared/              # app-level: api client, auth helpers, components, dev-tools
      generated/           # codegen output (routeTree.gen.ts lives here OR src/ root, never hand-edited)
      styles.css
      server.ts
libs/
  feature-auth/            # pervasive feature — allowed import node
  feature-user-profile/    # pervasive feature
  feature-widgets/         # domain feature
  feature-billing/         # domain feature
  shared/                  # cross-cutting libs — UI, formatters, types
  ui/                      # generic UI component library

Feature lib internals

libs/feature-widgets/
  src/
    components/            # React components
    hooks/                 # feature hooks — the public API
    view-models/           # Zod schemas + TS types
    validators/            # input schemas
    types/                 # TypeScript types
    server/                # server fns — only if fullstack and NOT shared across features
  index.ts                 # deliberate public API
  package.json

Within-app notes

  • Routes are thin coordinators: call feature hooks, no business logic inline.
  • Co-location in routes is ok only for private types/mappers used by that route alone.
  • generated/ isolates codegen output (routeTree.gen.ts, OpenAPI types, etc.) — never hand-edit these files.
  • Cross-cutting concerns (logger, telemetry) are their own lib or a third-party dependency.

Feature import rules (DAG)

Most features have no cross-feature imports. The dep graph must always be a directed acyclic graph (DAG).

Pervasive features (feature-auth, feature-user-profile) can be imported from other features — expose only components and public hooks via index.ts; never import internal implementation.

When features need to coordinate, prefer in this order:

  1. Shared application stores — one feature publishes state, others subscribe.
  2. React Query cache synchronization — multiple features read the same query key.
  3. Complex coordination — handle in the route/page, or create a shared widget in libs/shared/ or apps/web/src/.

Single-app layout (simple projects)

src/
  router.tsx                  getRouter() + Register module augmentation
  routes/
    __root.tsx                root route (shell + providers)
    index.tsx                 "/"
    (protected)/
      route.tsx               auth layout — loader redirect guard
      app.route.tsx           app shell layout
      app.index.tsx           main app page
    api/
      health.ts               HTTP-only endpoint
  routeTree.gen.ts            GENERATED — never edit
  styles.css                  single CSS file; @import "tailwindcss"
  server.ts                   optional custom Nitro entry
  client.tsx                  optional explicit hydration entry
  shared/
    api/
      query-client.ts         QueryClient singleton + getTanStackQueryContext()
      providers/
        tanstack-query.tsx    QueryClientProvider wrapper
    auth/                     session helpers, auth server fns
    components/
      router/
        error.tsx             defaultErrorComponent
        not-found.tsx         defaultNotFoundComponent
        pending.tsx           defaultPendingComponent

Larger single-app projects add these directories inside src/:

src/
  features/
    <domain>/                 vertical slices (co-locate route, query, form, components)
  shared/
    <concern>/                horizontal utilities (api, auth, components, dev-tools, theme, telemetry)

QueryClient defaults

Always use these exact defaults. Do not override them per-query without a documented reason.

ts
new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 20,          // 20 seconds
      refetchOnWindowFocus: false,
      retry: 1,
    },
  },
})

Router defaults

Always set defaultPreloadStaleTime: 0. This is the most important router option — it delegates staleness management entirely to React Query and prevents the router from double-caching data.

ts
createRouter({
  defaultPreloadStaleTime: 0,
  // ...
})

Query key factories

All query keys come from typed as const factories. Never inline key arrays in useQuery calls — always reference the factory.

ts
export const postsKeys = {
  all:    ["posts"] as const,
  list:   (filter: string) => [...postsKeys.all, "list", filter] as const,
  detail: (id: string)     => [...postsKeys.all, "detail", id] as const,
};

For multi-tenant apps where the same key space is shared across tenants or base URLs, include both in every key:

ts
export const itemKeys = {
  root:   (baseUrl: string) => ["items", { baseUrl }] as const,
  all:    (baseUrl: string, shopId: string) => [...itemKeys.root(baseUrl), { shopId }] as const,
  detail: (baseUrl: string, shopId: string, itemId: string) =>
    [...itemKeys.all(baseUrl, shopId), { itemId }] as const,
};

queryOptions factory location

Never define queryOptions factories inline in route files.

In a monorepo, queryOptions factories for a feature's data live inside the feature lib alongside the hooks that use them:

ts
// libs/feature-widgets/src/queries.ts
export const widgetQuery = (id: string) => queryOptions({
  queryKey: widgetKeys.detail(id),
  queryFn:  () => api.widgets.get(id),
});

In a single-app project, use src/queries/<domain>.ts (simple) or src/features/<domain>/queries.ts (larger):

ts
// src/queries/posts.ts
export const postQuery = (id: string) => queryOptions({
  queryKey: postsKeys.detail(id),
  queryFn:  () => api.posts.get(id),
});

Loader + component pairing rule

When a loader calls ensureQueryData, the component must call useSuspenseQuery (not useQuery) with the same queryOptions. This is non-negotiable — mixing the two for the same key causes a brief pending state that the loader was specifically designed to avoid.

tsx
// loader:
loader: ({ context, params }) =>
  context.queryClient.ensureQueryData(postQuery(params.postId)),

// component:
const { data } = useSuspenseQuery(postQuery(postId));  // synchronous

loaderDeps rule

Never read search directly in loader. Always extract the keys you need via loaderDeps:

ts
loaderDeps: ({ search }) => ({ page: search.page, filter: search.filter }),
loader: ({ deps }) => fetchPosts(deps),   // deps, not context.search

Server function file layout

In a monorepo, server functions for a feature live inside the feature lib:

libs/feature-<domain>/src/server/
  index.ts      createServerFn — the RPC entry point
  schema.ts     Zod schemas (importable client-side, no server deps)

Server functions that are shared across features and don't belong to any single feature live in services/:

services/
  server.ts       createServerFn — app-owned shared server fns
  middleware.ts   shared middleware

In a single-app project, use per-domain directories under services/ (or src/features/<domain>/):

services/<domain>/
  server.ts     createServerFn — the RPC entry point
  schema.ts     Zod schemas (importable client-side, no server deps)
  key.ts        typed query-key factory
  client.ts     useServerFn + useQuery/useMutation hooks for components

Environment variable naming

PrefixWhere it can be read
PUBLIC_*Only via a createServerFn that returns it from the root loader — never direct process.env.PUBLIC_* in client code
PRIVATE_*Only inside createServerFn handlers or createServerOnlyFn

Token passing convention

Pass bearer tokens as per-call headers, not in data. The auth middleware reads Authorization:

ts
createItem({
  data: { itemId, name },
  headers: { Authorization: `Bearer ${await getAccessToken()}` },
})

Never put tokens in query params, body, or ctx.data.

CSS convention

One CSS file: src/styles.css. Content:

css
@import "tailwindcss";

All theme tokens go in this file via @theme {}. No other CSS files. No CSS modules. No styled-components.

TypeScript strictness

All projects use the tsconfig.json from project-setup.md without relaxing any flags. Notably:

  • noUncheckedIndexedAccess: true — array index access returns T | undefined
  • verbatimModuleSyntax: trueimport type is required for type-only imports
  • erasableSyntaxOnly: true — no decorators or other non-erasable syntax
  • noUnusedLocals: true + noUnusedParameters: true — dead code is a type error

SSR mode

Always ssr: "data-only" on the root route. This means:

  • Loaders run on the server (fast initial data, no flicker)
  • The component tree renders on the client (no hydration mismatch complexity)
  • shellComponent renders the HTML shell server-side; component renders providers client-side

Released under the MIT License.