Skip to content

Latest commit

 

History

History
165 lines (115 loc) · 5.42 KB

File metadata and controls

165 lines (115 loc) · 5.42 KB

Agent Guidelines

Typesafety

Typesafety is of utmost importance.

Avoid Type Casting

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

Prefer Type Inference

Always infer types and go up the logical chain as far as we can control to determine types. The preferred approach is:

  1. Schema validation - Use schema definitions (e.g., Convex schema, Zod, etc.) as the source of truth
  2. Type inference from concrete sources - Let TypeScript infer types from function return types, API responses, etc.
  3. Go up the chain - Trace types back to their source rather than casting at the point of use

Example

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 implementation

If types need to be fixed, fix them at the source (schema, API definition, function signature) rather than casting at the point of use.

Route Loaders

loaderDeps Must Be Specific

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 Are Isomorphic

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 }
}

Server-Only Code and Environment Shaking

TanStack Start Environment Shaking

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 createServerFn handlers is included in server bundles
  • Code outside handlers is included in both server and client bundles

Importing Server Functions

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.

Rules for Server-Only Imports

  1. Server functions (createServerFn wrappers) can be imported statically anywhere
  2. Direct server-only code (database clients, file system, etc.) must ONLY be imported:
    • Inside createServerFn handlers
    • In separate server-only files (e.g., *.server.ts)
    • Never use dynamic imports (await import()) for server-only code in component code

Development & Build Commands

Use build for Testing Build Output

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.