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:
plugins: [tanstackStart(), nitro(), react(), svgr(), tailwindcss()]Or with explicit vite-tsconfig-paths:
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
| Pattern | Example | URL |
|---|---|---|
| Static segment | routes/about.tsx | /about |
| Index route | routes/posts/index.tsx | /posts |
| Dynamic param | routes/posts/$postId.tsx | /posts/:postId |
| Splat | routes/docs/$.tsx | /docs/* |
| Layout only | routes/posts/route.tsx | (no URL change, renders <Outlet/>) |
| Pathless auth group | routes/(protected)/route.tsx | (no URL segment) |
| HTTP-only endpoint | routes/api/health.ts | /api/health |
| Lazy component | routes/heavy.lazy.tsx | paired 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
Monorepo layout (recommended)
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 libraryFeature 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.jsonWithin-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:
- Shared application stores — one feature publishes state, others subscribe.
- React Query cache synchronization — multiple features read the same query key.
- Complex coordination — handle in the route/page, or create a shared widget in
libs/shared/orapps/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 defaultPendingComponentLarger 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.
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.
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.
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:
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:
// 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):
// 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.
// loader:
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(postQuery(params.postId)),
// component:
const { data } = useSuspenseQuery(postQuery(postId)); // synchronousloaderDeps rule
Never read search directly in loader. Always extract the keys you need via loaderDeps:
loaderDeps: ({ search }) => ({ page: search.page, filter: search.filter }),
loader: ({ deps }) => fetchPosts(deps), // deps, not context.searchServer 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 middlewareIn 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 componentsEnvironment variable naming
| Prefix | Where 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:
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:
@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 returnsT | undefinedverbatimModuleSyntax: true—import typeis required for type-only importserasableSyntaxOnly: true— no decorators or other non-erasable syntaxnoUnusedLocals: 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)
shellComponentrenders the HTML shell server-side;componentrenders providers client-side