tanstack-fullstack-pattern
This file is generated from
skills/src/*.skill.yaml. Do not edit manually.
TanStack Fullstack Pattern
An interface-first fullstack architecture built on TanStack Start. The pattern defines clear interface boundaries between layers -- interfaces are rigid, implementations are swappable.
Pattern Overview
- Zero-config development with seed implementations and swappable service layers
- AI promptability by exposing every repository method (reads and writes) as tools
- End-to-end type safety from schema-first definitions with explicit layer boundaries
Rigid Rules (Must Follow)
- Interface-first services: every external service (database, AI, observability) is accessed through an interface.
- End-to-end type safety via schemas: every boundary uses a Zod/ArkType schema as the type source (
z.infer<>). Never hand-writetypefor wire data. Service interfaces (ReadRepository,AIAdapterService, etc.) are hand-written contracts -- they define behaviour, not wire shapes. - Three schema layers: repository (DB-shaped), server-function / AI-tool (API-shaped, shared), router search-param (URL-shaped). Mappers translate between layers.
- Repository contracts use repository-layer schemas only.
- Server functions and AI tools share the same tools-layer schemas (
.inputValidator(Schema)/toolDefinition({ inputSchema })). Both parse withSchema.parse(args). - AI and UI interact only with tools-layer schemas; they must not depend directly on repository schemas.
- Loaders-first data fetching: fetch route data in loaders through server functions.
- URL-as-state: filters, tabs, selections live in URL search params via
validateSearch(Zod/ArkType). UseloaderDepsto feed validated search into loaders. - Middleware chain: auth is global middleware, invalidation runs on mutation server functions.
- Mutation pattern: POST server functions chain
.middleware([requireAuthMiddleware, invalidateMiddleware])and return normalized{ data, error }. - Query pattern: GET server functions throw on failure for centralized error handling.
- Maximize AI tool coverage: expose every repository method (reads and writes) via
createSafeServerTool()so failures return{ error, code }. If a server function exists, it gets a tool. - Router capabilities as AI client tools: expose
router.navigate()androuter.invalidate()as client tools viatoolDefinition(). - AI chat context is URL-aware: pass current location to
/api/chat; keep the navigation manifest aligned with routes. - JSDoc on exports: every exported function, interface, type, and constant gets a JSDoc comment stating what and why.
- Chat persists across navigation: render
ChatDrawerat the root layout level so messages survive route changes. - AI renders rich markdown: use
react-markdown+remark-gfmfor tables, code blocks, links. Internal paths render as RouterLinkcomponents; the AI uses markdown links (e.g.[Pending tasks](/tasks?status=pending)) for in-app navigation.
Schema Boundaries
Route search schema -> loader -> tools schema -> server fn -> mapping -> repo schema -> repo impl
repo output -> mapping -> tools schema -> AI or UI
Layer 1 — Repository schemas (DB-shaped): internal to the repository, no .describe() needed. Define in src/services/schemas/repository.ts (target pattern -- currently all schemas live in schemas.ts). Infer types with z.infer<>.
Layer 2 — Server-function / AI-tool schemas (API-shaped): shared by createServerFn and toolDefinition. Add .describe() on every field -- descriptions flow into JSON Schema for the AI.
// src/services/schemas/schemas.ts
const TaskInputSchema = z.object({
title: z.string().min(1).describe('Short title'),
status: TaskStatusSchema.default('pending').describe('Current status'),
assignee: z.string().optional().describe('Assignee email'),
})
Layer 3 — Router search-param schemas (URL-shaped): defined locally in route files via validateSearch. Fields are always optional.
// src/routes/tasks/index.tsx
const TasksSearchSchema = z.object({
status: z.enum(TASK_STATUSES).optional(),
priority: z.enum(TASK_PRIORITIES).optional(),
search: z.string().optional(),
})
export const Route = createFileRoute('/tasks/')({
validateSearch: TasksSearchSchema,
loaderDeps: ({ search }) => search,
loader: ({ deps }) => getTasks({ data: deps }),
})
Type-safe mapping: use Schema.parse() between layers so mismatches are caught at runtime (e.g. TaskRepoInputSchema.parse(toolInput)).
Interface Contracts
Repository interfaces use repository-layer types only (never tools-layer schemas). AIAdapterService and ObservabilityService follow the same interface-first pattern -- see their types.ts files.
interface ReadRepository {
getTasks(filter?: TaskRepoFilter): Promise<TaskRepoOutput[]>
getTask(id: string): Promise<TaskRepoOutput | null>
getDistinctValues(field: string): Promise<string[]>
getUserProfile(email: string): Promise<UserProfile | null>
}
interface WritableRepository {
createTask(input: TaskRepoInput, createdBy?: string): Promise<TaskRepoOutput>
updateTask(id: string, input: Partial<TaskRepoInput>): Promise<TaskRepoOutput | null>
deleteTask(id: string): Promise<boolean>
}
Styling and UI (Mantine)
- Use Mantine components and styling props (
c,fw,size,variant, responsive object syntax) before custom CSS. - Design tokens (colors, fonts, spacing, radius) go in
createTheme()at__root.tsx. - CSS Modules only when Mantine props cannot express the style; use
--mantine-color-*variables. All components must support light and dark schemes.
Auth and Middleware
authMiddleware(global) extracts JWT from configurable header (AUTH_HEADER_NAME) and provides typedcontext.user/context.userProfile.requireAuthMiddlewarechains auth; POST mutations require it, GET queries stay unauthenticated. Guards:requireAuth(context)throws 401,requireGroup(context, group)throws 403.- Custom authorization: composable middleware chaining
authMiddleware+invalidateMiddlewareon POST server functions.
Migration / Build Workflow
- Schemas: repo-layer schemas in
repository.ts, tools-layer inschemas.ts(with.describe()), mappers viaSchema.parse(). - Repository: interfaces in
types.ts(repo-layer types only), seed + production implementations. - Server functions: GET queries + POST mutations in
serverFns.tswith.inputValidator(ToolSchema). - AI tools: every server function gets a
toolDefinition+createSafeServerTool(); client tools (navigate,invalidateRouter) wired inChatDrawer.tsx. - Middleware + manifest: register auth + invalidation in
start.ts; keepnavigationManifest.tsaligned with routes. - Routes:
validateSearch(Zod/ArkType),loaderDeps, loaders calling server functions. - Chat + tests: pass
browserContextlocation to/api/chat; E2E specs ine2e/against seed repository.
Looking Up TanStack Documentation
Use npx @tanstack/cli to fetch up-to-date docs instead of relying on training data. Run --help for all commands.
AI Chat Pipeline
- Server tools use
toolDefinitionfrom@tanstack/aiand call the same exported server functions as route loaders. Args typed viaSchema.parse(args)since.server()types args asunknown. POST /api/chat(src/routes/api/chat.ts) uses SSE streaming and the auth middleware context.- Keep the system prompt updated when the data model or routes change.
Client Tools (Router Capabilities for AI)
Client tools are definition-only in tools.ts (no .server()) and wired in the browser via .client() and clientTools() from @tanstack/ai-client:
// tools.ts — definition only
export const navigateToolDef = toolDefinition({
name: 'navigate',
inputSchema: NavigateInputSchema, // mirrors route search schemas
outputSchema: z.object({ success: z.boolean() }),
})
// ChatDrawer.tsx — browser wiring
const navigateClient = navigateToolDef.client((args) => {
const input = NavigateInputSchema.parse(args)
router.navigate({ to: input.to, search: input.search ?? undefined })
return { success: true }
})
const tools = clientTools(navigateClient, invalidateClient)
Observability
ObservabilityServiceinterface insrc/services/observability/types.ts; Sentry and no-op implementations with factory.- Server init:
instrument.server.mjs; client init:src/router.tsx. Usage:getObservability().startSpan('name', fn). - To swap providers: implement
ObservabilityService, update factory, replaceinstrument.server.mjs.
Post-Setup Verification
Run in order: pnpm install && pnpm update (latest compatible versions), pnpm format && pnpm lint (Biome, zero errors), pnpm test (at least one passing), pnpm build (production build succeeds).
Testing
- Unit: Vitest + jsdom + Testing Library;
renderWithProviders()wraps MantineProvider. Co-located as*.test.ts(x). - E2E: Playwright (Chromium),
REPOSITORY_TYPE=seed, specs ine2e/*.spec.ts. Auth fixture providesauthedPage/authedContextvia unsigned JWTs.