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.
All generic type parameters must be prefixed with T.
This convention makes it immediately clear that a name refers to a type parameter rather than a concrete type or value.
❌ Bad:
function withCapability<Args extends unknown[], R>(
handler: (user: AuthUser, ...args: Args) => R,
) { ... }✅ Good:
function withCapability<TArgs extends unknown[], TReturn>(
handler: (user: AuthUser, ...args: TArgs) => TReturn,
) { ... }Common examples:
Tfor a single generic typeTArgsfor argument typesTReturnfor return typesTDatafor data typesTErrorfor error typesTKeyfor key typesTValuefor value types
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(), 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 functions created via createServerFn() can be called directly
const data = await serverFn({ data: { id: '123' } })
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
Do not run builds after every change, especially for visual changes.
This is a visual website, not a library. Assume changes work unless the user reports otherwise. Running builds after every change wastes time and context.
After making code changes, always run pnpm test to verify the code passes basic tests.
Do NOT run tests after every tiny small change, just at the end of your known tasks. Tests are run automatically by the pre-commit hook and CI. Linting is fast and catches most issues immediately.
When the user reports something doesn't work or look right:
- Use the Playwright MCP to view the page and debug visually
- Use builds (
pnpm build) only when investigating build/bundler issues - Use linting (
pnpm lint) to check for code issues
The dev command does not end, it runs indefinitely in watch mode.
Only use build when:
- Investigating bundler or build-time errors
- Verifying production output
- The user specifically asks to verify the build
Use the Playwright MCP for visual debugging and verification.
When debugging issues or verifying visual changes work correctly:
- Navigate to the relevant page using Playwright
- Take snapshots or screenshots to verify the UI
- Interact with elements to test functionality
This is the preferred method for verifying visual changes since this is a visual site.
- Prioritize clarity, hierarchy, and calm
- Use depth to communicate structure, not decoration
- Favor warmth and approachability over stark minimalism
- Prefer fewer, well defined containers over many small sections
- Use generous spacing to create separation before adding visual effects
- Cards are acceptable when they express grouping or hierarchy
- Rounded corners are standard
- Use subtle radius values that feel intentional, not playful
- Avoid sharp 90 degree corners unless intentionally industrial
- Shadows should be soft, low contrast, and diffused
- Use shadows to imply separation, not elevation theatrics
- Avoid heavy drop shadows or strong directional lighting
- One to two shadow layers max
- Cards should feel grounded, not floating
- Prefer light elevation, border plus shadow, or surface contrast
- Avoid overusing cards as a default layout primitive
- Favor soft neutrals, off whites, and warm grays
- Use surface contrast or translucency instead of strong outlines
- Glass or frosted effects are acceptable when subtle and accessible
- Use micro transitions to reinforce spatial relationships
- Hover and focus states should feel responsive, not animated
- Avoid excessive motion or springy effects
- Let type hierarchy do most of the work
- Strong headings, calm body text
- Avoid visual noise around content
- Chunky shadows
- Overly flat, sterile layouts
- Neumorphism as a primary style
- Over designed card grids
If depth does not improve comprehension, remove it.
Never use emdashes (—) in writing. They make content smell like AI-generated text.
Use these alternatives instead:
- Commas for parenthetical phrases
- Colons to introduce lists or explanations
- Periods to break into separate sentences
- Parentheses when appropriate
❌ Bad:
RSCs aren't about replacing client interactivity — they're about choosing where work happens.
✅ Good:
RSCs aren't about replacing client interactivity. They're about choosing where work happens.
❌ Bad:
Heavy dependencies — markdown parsers, syntax highlighters — stay on the server.
✅ Good:
Heavy dependencies (markdown parsers, syntax highlighters) stay on the server.