Typesafety is of utmost importance.
We never ever cast types unless it's absolutely necessary. This includes:
- Manual generic type parameters (e.g.,
<Type>) - Type assertions using
as - Type assertions using
satisfies - Any other form of type casting
Always infer types and go up the logical chain as far as we can control to determine types. The preferred approach is:
- Schema validation - Use schema definitions (e.g., Convex schema, Zod, etc.) as the source of truth
- Type inference from concrete sources - Let TypeScript infer types from function return types, API responses, etc.
- Go up the chain - Trace types back to their source rather than casting at the point of use
❌ Bad:
const result = api.getData() as MyType
const value = getValue<MyType>()✅ Good:
// Infer from schema or API definition
const result = api.getData() // Type inferred from api.getData return type
const value = getValue() // Type inferred from function implementationIf types need to be fixed, fix them at the source (schema, API definition, function signature) rather than casting at the point of use.
loaderDeps must always be specific to what's actually used in the loader.
Only include the properties from search (or other sources) that are actually used in the loader function. This ensures proper cache invalidation and prevents unnecessary re-runs when unrelated search params change.
❌ Bad:
loaderDeps: ({ search }) => search, // Includes everything, even unused params
loader: async ({ deps }) => {
// Only uses deps.page and deps.pageSize
await fetchData({ page: deps.page, pageSize: deps.pageSize })
}✅ Good:
loaderDeps: ({ search }) => ({
page: search.page,
pageSize: search.pageSize,
// Only include what's actually used in the loader
}),
loader: async ({ deps }) => {
await fetchData({ page: deps.page, pageSize: deps.pageSize })
}This ensures the loader only re-runs when the specific dependencies change, not when unrelated search params (like expanded, viewMode, etc.) change.
Loaders in TanStack Start/Router are isomorphic and cannot call server logic unless via a call to a server function.
Loaders run on both the server and client, so they cannot directly access server-only APIs (like file system, database connections, etc.). To perform server-side operations, loaders must call server functions (e.g., TanStack server functions created via createServerFn(), Convex queries/mutations, API routes, or other server functions).
❌ Bad:
loader: async () => {
// This won't work - direct server API access
const data = await fs.readFile('data.json')
return { data }
}✅ Good:
loader: async () => {
// Call a server function instead (TanStack server function or Convex)
// TanStack server functions created via createServerFn() can be called directly
const data = await serverFn({ data: { id: '123' } })
// or
const data = await convex.query(api.data.getData)
return { data }
}TanStack Start performs environment shaking - any code not referenced by a createServerFn handler is stripped from the client build.
This means:
- Server-only code (database, file system, etc.) is automatically excluded from client bundles
- Only code inside
createServerFnhandlers is included in server bundles - Code outside handlers is included in both server and client bundles
Server functions wrapped in createServerFn can be safely imported statically in route files.
❌ Bad - Dynamic imports in component code:
// In a route component
const rolesQuery = useQuery({
queryFn: async () => {
const { listRoles } = await import('~/utils/roles.server')
return listRoles({ data: {} })
},
})This causes bundler issues because dynamic imports can't be properly tree-shaken, potentially pulling server-only code (like Buffer, drizzle, postgres) into the client bundle.
✅ Good - Static imports:
// At the top of the route file
import { listRoles } from '~/utils/roles.server'
// In component code
const rolesQuery = useQuery({
queryFn: async () => {
return listRoles({ data: {} })
},
})Since listRoles is wrapped in createServerFn, TanStack Start will properly handle environment shaking and exclude server-only dependencies from the client bundle.
- Server functions (
createServerFnwrappers) can be imported statically anywhere - Direct server-only code (database clients, file system, etc.) must ONLY be imported:
- Inside
createServerFnhandlers - In separate server-only files (e.g.,
*.server.ts) - Never use dynamic imports (
await import()) for server-only code in component code
- Inside
The dev command does not end - it runs indefinitely in watch mode.
When agents need to test build output or verify that the project builds successfully, use the build command instead of dev. The build command will complete and exit, making it suitable for automated testing and verification.