From 9cbaec12b526b3e708fa925a6e67731895237eff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Oct 2025 18:11:13 +0000 Subject: [PATCH 01/25] docs: add comprehensive LinearLite to TanStack port plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detailed planning documentation for porting the LinearLite example from Electric SQL + PGlite to TanStack Start + TanStack DB. Key features of the plan: - Dual-mode architecture (Query polling vs Electric real-time sync) - User isolation with Better Auth authentication - Full feature parity with original LinearLite - 10-phase implementation roadmap over 4 weeks - Complete code examples for all major components - Database schema, tRPC API, and collection configurations Also includes comprehensive TanStack Start guide for reference. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- LINEARLITE_TANSTACK_PORT_PLAN.md | 1814 +++++++++++++++++ TANSTACK_START_COMPREHENSIVE_GUIDE.md | 2570 +++++++++++++++++++++++++ 2 files changed, 4384 insertions(+) create mode 100644 LINEARLITE_TANSTACK_PORT_PLAN.md create mode 100644 TANSTACK_START_COMPREHENSIVE_GUIDE.md diff --git a/LINEARLITE_TANSTACK_PORT_PLAN.md b/LINEARLITE_TANSTACK_PORT_PLAN.md new file mode 100644 index 000000000..b0e8da8df --- /dev/null +++ b/LINEARLITE_TANSTACK_PORT_PLAN.md @@ -0,0 +1,1814 @@ +# LinearLite Port to TanStack Start + TanStack DB - Detailed Plan + +## Executive Summary + +This document outlines a comprehensive plan to port the [LinearLite example](https://github.com/electric-sql/electric/tree/main/examples/linearlite) from Electric SQL + PGlite to **TanStack Start + TanStack DB**. The ported application will support **dual modes** (Query mode and Electric mode) to demonstrate both traditional polling and real-time sync approaches. + +### Key Objectives + +1. **Full feature parity** with original LinearLite (issue management, kanban board, comments, filtering, etc.) +2. **Dual-mode architecture** allowing users to switch between Query and Electric modes +3. **User isolation** - users can only see pre-created data + their own created data +4. **Modern stack** - TanStack Start for full-stack framework, TanStack DB for client-side data store +5. **Type-safe** end-to-end with schemas and validation + +--- + +## Architecture Overview + +### Technology Stack + +#### **Frontend** +- **Framework:** TanStack React Start (v1.x) +- **Data Store:** TanStack DB (v0.4.x) +- **Styling:** Tailwind CSS +- **Rich Text Editor:** TipTap with markdown support +- **Drag & Drop:** @dnd-kit (modern alternative to react-beautiful-dnd) +- **Virtualization:** @tanstack/react-virtual (modern alternative to react-window) + +#### **Backend** +- **Server Framework:** TanStack Start server functions + tRPC +- **Database:** PostgreSQL +- **ORM:** Drizzle ORM +- **Schema Validation:** Zod +- **Authentication:** Better Auth +- **Real-time Sync:** ElectricSQL v0.13+ + +#### **Build Tools** +- **Bundler:** Vite +- **Package Manager:** pnpm +- **Language:** TypeScript 5.x + +### Dual-Mode Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ TanStack Start App β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ React Components (useLiveQuery) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ TanStack DB Collections Layer β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ Query Collection β”‚ β”‚Electric Collectionβ”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ (Poll mode) β”‚ β”‚ (Real-time mode)β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ tRPC API Routes β”‚ β”‚ Electric Proxy β”‚ β”‚ +β”‚ β”‚ (Server Fns) β”‚ β”‚ Route β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” + β”‚ PostgreSQL Database β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ β”‚ issues, comments, users β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–² + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ ElectricSQL Sync β”‚ + β”‚ Service β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Phase 1: Project Setup & Infrastructure + +### 1.1 Initialize TanStack Start Project + +```bash +# Create new TanStack Start project +npx create-start@latest linearlite-tanstack --template react + +# Navigate to project +cd linearlite-tanstack + +# Initialize pnpm (if not already) +pnpm install +``` + +### 1.2 Install Dependencies + +```bash +# Core dependencies +pnpm add @tanstack/react-db @tanstack/db +pnpm add @tanstack/query-db-collection +pnpm add @tanstack/electric-db-collection +pnpm add @tanstack/react-router +pnpm add @tanstack/react-query +pnpm add @trpc/server @trpc/client @trpc/react-query + +# Database & ORM +pnpm add drizzle-orm postgres +pnpm add -D drizzle-kit + +# Schema validation +pnpm add zod drizzle-zod + +# Authentication +pnpm add better-auth + +# ElectricSQL +pnpm add @electric-sql/client + +# UI libraries +pnpm add @tiptap/react @tiptap/starter-kit @tiptap/extension-table tiptap-markdown +pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities +pnpm add fractional-indexing +pnpm add clsx tailwind-merge +pnpm add lucide-react + +# Virtualization +pnpm add @tanstack/react-virtual + +# Dev dependencies +pnpm add -D tailwindcss postcss autoprefixer +pnpm add -D @types/node +``` + +### 1.3 Configure Tailwind CSS + +**tailwind.config.ts:** +```typescript +import type { Config } from 'tailwindcss' + +export default { + content: ['./src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + background: 'var(--background)', + foreground: 'var(--foreground)', + border: 'var(--border)', + primary: 'var(--primary)', + }, + }, + }, + plugins: [], +} satisfies Config +``` + +### 1.4 Configure Environment Variables + +**.env:** +```bash +# Database +DATABASE_URL=postgresql://user:password@localhost:5432/linearlite + +# ElectricSQL +ELECTRIC_URL=http://localhost:3000/v1/shape +ELECTRIC_API_URL=http://localhost:3000 + +# Auth +BETTER_AUTH_SECRET=your-secret-key-here +BETTER_AUTH_URL=http://localhost:3001 + +# Mode +NODE_ENV=development +``` + +--- + +## Phase 2: Database Schema & Migrations + +### 2.1 Define Drizzle Schema + +**src/db/schema.ts:** +```typescript +import { pgTable, text, timestamp, uuid, boolean, pgEnum } from 'drizzle-orm/pg-core' +import { createInsertSchema, createSelectSchema } from 'drizzle-zod' + +// Enums +export const priorityEnum = pgEnum('priority', ['none', 'urgent', 'high', 'medium', 'low']) +export const statusEnum = pgEnum('status', ['backlog', 'todo', 'in_progress', 'done', 'canceled']) + +// Users table +export const usersTable = pgTable('users', { + id: uuid().primaryKey().defaultRandom(), + username: text().notNull().unique(), + email: text().notNull().unique(), + name: text(), + avatar_url: text(), + created_at: timestamp({ withTimezone: true }).notNull().defaultNow(), + updated_at: timestamp({ withTimezone: true }).notNull().defaultNow(), +}) + +// Issues table +export const issuesTable = pgTable('issues', { + id: uuid().primaryKey().defaultRandom(), + title: text().notNull(), + description: text().default(''), + priority: priorityEnum().notNull().default('none'), + status: statusEnum().notNull().default('backlog'), + kanbanorder: text().notNull(), + user_id: uuid().notNull().references(() => usersTable.id, { onDelete: 'cascade' }), + created_at: timestamp({ withTimezone: true }).notNull().defaultNow(), + modified: timestamp({ withTimezone: true }).notNull().defaultNow(), +}) + +// Comments table +export const commentsTable = pgTable('comments', { + id: uuid().primaryKey().defaultRandom(), + body: text().notNull(), + user_id: uuid().notNull().references(() => usersTable.id, { onDelete: 'cascade' }), + issue_id: uuid().notNull().references(() => issuesTable.id, { onDelete: 'cascade' }), + created_at: timestamp({ withTimezone: true }).notNull().defaultNow(), + modified: timestamp({ withTimezone: true }).notNull().defaultNow(), +}) + +// Zod schemas for validation +export const selectUserSchema = createSelectSchema(usersTable) +export const insertUserSchema = createInsertSchema(usersTable).omit({ + id: true, + created_at: true, + updated_at: true, +}) + +export const selectIssueSchema = createSelectSchema(issuesTable) +export const insertIssueSchema = createInsertSchema(issuesTable).omit({ + id: true, + created_at: true, + modified: true, +}) + +export const selectCommentSchema = createSelectSchema(commentsTable) +export const insertCommentSchema = createInsertSchema(commentsTable).omit({ + id: true, + created_at: true, + modified: true, +}) + +// TypeScript types +export type User = typeof usersTable.$inferSelect +export type InsertUser = typeof usersTable.$inferInsert +export type Issue = typeof issuesTable.$inferSelect +export type InsertIssue = typeof issuesTable.$inferInsert +export type Comment = typeof commentsTable.$inferSelect +export type InsertComment = typeof commentsTable.$inferInsert + +export type Priority = 'none' | 'urgent' | 'high' | 'medium' | 'low' +export type Status = 'backlog' | 'todo' | 'in_progress' | 'done' | 'canceled' +``` + +### 2.2 Database Connection + +**src/db/connection.ts:** +```typescript +import { drizzle } from 'drizzle-orm/node-postgres' +import * as schema from './schema' + +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL environment variable is not set') +} + +export const db = drizzle(process.env.DATABASE_URL, { + schema, + casing: 'snake_case', +}) +``` + +### 2.3 Seed Data Script + +**src/db/seed.ts:** +```typescript +import { db } from './connection' +import { usersTable, issuesTable, commentsTable } from './schema' +import { generateKeyBetween } from 'fractional-indexing' + +async function seed() { + console.log('Seeding database...') + + // Create demo users + const [demoUser] = await db.insert(usersTable).values({ + username: 'demo', + email: 'demo@example.com', + name: 'Demo User', + }).returning() + + const [testUser] = await db.insert(usersTable).values({ + username: 'testuser', + email: 'test@example.com', + name: 'Test User', + }).returning() + + console.log('Created users:', demoUser.username, testUser.username) + + // Create demo issues + let kanbanorder = generateKeyBetween(null, null) + + const issues = [ + { + title: 'Set up project infrastructure', + description: 'Initialize TanStack Start project with all dependencies', + priority: 'high' as const, + status: 'done' as const, + user_id: demoUser.id, + kanbanorder, + }, + { + title: 'Implement issue list view', + description: 'Create the main issue list with filtering and sorting', + priority: 'high' as const, + status: 'in_progress' as const, + user_id: demoUser.id, + kanbanorder: (kanbanorder = generateKeyBetween(kanbanorder, null)), + }, + { + title: 'Add kanban board', + description: 'Implement drag-and-drop kanban board for issues', + priority: 'medium' as const, + status: 'todo' as const, + user_id: demoUser.id, + kanbanorder: (kanbanorder = generateKeyBetween(kanbanorder, null)), + }, + { + title: 'Build comment system', + description: 'Allow users to comment on issues', + priority: 'medium' as const, + status: 'backlog' as const, + user_id: testUser.id, + kanbanorder: (kanbanorder = generateKeyBetween(kanbanorder, null)), + }, + ] + + const createdIssues = await db.insert(issuesTable).values(issues).returning() + console.log(`Created ${createdIssues.length} issues`) + + // Create demo comments + const comments = [ + { + body: 'Great progress on this!', + user_id: testUser.id, + issue_id: createdIssues[0].id, + }, + { + body: 'Let me know if you need any help.', + user_id: demoUser.id, + issue_id: createdIssues[1].id, + }, + ] + + await db.insert(commentsTable).values(comments) + console.log(`Created ${comments.length} comments`) + + console.log('Seeding complete!') +} + +seed().catch(console.error) +``` + +### 2.4 Drizzle Configuration + +**drizzle.config.ts:** +```typescript +import type { Config } from 'drizzle-kit' + +export default { + schema: './src/db/schema.ts', + out: './src/db/migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +} satisfies Config +``` + +--- + +## Phase 3: Authentication Setup + +### 3.1 Better Auth Configuration + +**src/lib/auth.ts:** +```typescript +import { betterAuth } from 'better-auth' +import { db } from '@/db/connection' +import { drizzleAdapter } from 'better-auth/adapters/drizzle' + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: 'pg', + }), + emailAndPassword: { + enabled: true, + }, + socialProviders: { + github: { + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + }, + }, +}) + +export type Session = typeof auth.$Infer.Session +``` + +**src/lib/auth-client.ts:** +```typescript +import { createAuthClient } from 'better-auth/react' + +export const authClient = createAuthClient({ + baseURL: process.env.BETTER_AUTH_URL, +}) + +export const { useSession, signIn, signOut, signUp } = authClient +``` + +### 3.2 Auth Middleware + +**src/middleware/auth.ts:** +```typescript +import { auth } from '@/lib/auth' +import type { NextFunction, Request, Response } from 'express' + +export async function requireAuth( + req: Request, + res: Response, + next: NextFunction +) { + const session = await auth.api.getSession({ headers: req.headers }) + + if (!session) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + req.session = session + next() +} +``` + +--- + +## Phase 4: tRPC API Setup + +### 4.1 tRPC Router Configuration + +**src/server/trpc/index.ts:** +```typescript +import { initTRPC } from '@trpc/server' +import { db } from '@/db/connection' +import type { Session } from '@/lib/auth' + +interface Context { + db: typeof db + session: Session | null +} + +const t = initTRPC.context().create() + +export const router = t.router +export const publicProcedure = t.procedure +export const protectedProcedure = t.procedure.use((opts) => { + if (!opts.ctx.session) { + throw new Error('Unauthorized') + } + return opts.next({ + ctx: { + ...opts.ctx, + session: opts.ctx.session, + }, + }) +}) +``` + +### 4.2 Issues Router + +**src/server/trpc/routers/issues.ts:** +```typescript +import { z } from 'zod' +import { router, protectedProcedure } from '../index' +import { issuesTable } from '@/db/schema' +import { eq, and } from 'drizzle-orm' + +export const issuesRouter = router({ + getAll: protectedProcedure.query(async ({ ctx }) => { + // Return only issues created by the user or pre-seeded demo data + const issues = await ctx.db.query.issuesTable.findMany({ + where: (issues, { eq, or }) => + or( + eq(issues.user_id, ctx.session.user.id), + // Allow access to demo user's issues for all users + eq(issues.user_id, 'demo-user-id-here') + ), + orderBy: (issues, { asc }) => [asc(issues.created_at)], + }) + + return issues + }), + + create: protectedProcedure + .input( + z.object({ + title: z.string().min(1), + description: z.string().default(''), + priority: z.enum(['none', 'urgent', 'high', 'medium', 'low']), + status: z.enum(['backlog', 'todo', 'in_progress', 'done', 'canceled']), + kanbanorder: z.string(), + }) + ) + .mutation(async ({ ctx, input }) => { + const [issue] = await ctx.db + .insert(issuesTable) + .values({ + ...input, + user_id: ctx.session.user.id, + }) + .returning() + + return issue + }), + + update: protectedProcedure + .input( + z.object({ + id: z.string().uuid(), + title: z.string().optional(), + description: z.string().optional(), + priority: z.enum(['none', 'urgent', 'high', 'medium', 'low']).optional(), + status: z.enum(['backlog', 'todo', 'in_progress', 'done', 'canceled']).optional(), + kanbanorder: z.string().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { id, ...updates } = input + + // Verify ownership + const existing = await ctx.db.query.issuesTable.findFirst({ + where: eq(issuesTable.id, id), + }) + + if (!existing || existing.user_id !== ctx.session.user.id) { + throw new Error('Unauthorized') + } + + const [updated] = await ctx.db + .update(issuesTable) + .set({ + ...updates, + modified: new Date(), + }) + .where(eq(issuesTable.id, id)) + .returning() + + return updated + }), + + delete: protectedProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + // Verify ownership + const existing = await ctx.db.query.issuesTable.findFirst({ + where: eq(issuesTable.id, input.id), + }) + + if (!existing || existing.user_id !== ctx.session.user.id) { + throw new Error('Unauthorized') + } + + await ctx.db.delete(issuesTable).where(eq(issuesTable.id, input.id)) + + return { success: true } + }), +}) +``` + +### 4.3 Comments Router + +**src/server/trpc/routers/comments.ts:** +```typescript +import { z } from 'zod' +import { router, protectedProcedure } from '../index' +import { commentsTable, issuesTable } from '@/db/schema' +import { eq } from 'drizzle-orm' + +export const commentsRouter = router({ + getByIssueId: protectedProcedure + .input(z.object({ issueId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + // Verify user has access to this issue + const issue = await ctx.db.query.issuesTable.findFirst({ + where: eq(issuesTable.id, input.issueId), + }) + + if (!issue) { + throw new Error('Issue not found') + } + + // User can see comments if they can see the issue + if (issue.user_id !== ctx.session.user.id) { + // Check if it's a demo issue + const isDemoIssue = issue.user_id === 'demo-user-id' + if (!isDemoIssue) { + throw new Error('Unauthorized') + } + } + + return ctx.db.query.commentsTable.findMany({ + where: eq(commentsTable.issue_id, input.issueId), + orderBy: (comments, { asc }) => [asc(comments.created_at)], + }) + }), + + create: protectedProcedure + .input( + z.object({ + body: z.string().min(1), + issue_id: z.string().uuid(), + }) + ) + .mutation(async ({ ctx, input }) => { + // Verify user has access to this issue + const issue = await ctx.db.query.issuesTable.findFirst({ + where: eq(issuesTable.id, input.issue_id), + }) + + if (!issue || issue.user_id !== ctx.session.user.id) { + throw new Error('Unauthorized') + } + + const [comment] = await ctx.db + .insert(commentsTable) + .values({ + ...input, + user_id: ctx.session.user.id, + }) + .returning() + + return comment + }), + + delete: protectedProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + // Verify ownership + const existing = await ctx.db.query.commentsTable.findFirst({ + where: eq(commentsTable.id, input.id), + }) + + if (!existing || existing.user_id !== ctx.session.user.id) { + throw new Error('Unauthorized') + } + + await ctx.db.delete(commentsTable).where(eq(commentsTable.id, input.id)) + + return { success: true } + }), +}) +``` + +### 4.4 App Router + +**src/server/trpc/router.ts:** +```typescript +import { router } from './index' +import { issuesRouter } from './routers/issues' +import { commentsRouter } from './routers/comments' + +export const appRouter = router({ + issues: issuesRouter, + comments: commentsRouter, +}) + +export type AppRouter = typeof appRouter +``` + +### 4.5 tRPC API Route Handler + +**src/routes/api/trpc/$.ts:** +```typescript +import { createFileRoute } from '@tanstack/react-router' +import { fetchRequestHandler } from '@trpc/server/adapters/fetch' +import { appRouter } from '@/server/trpc/router' +import { db } from '@/db/connection' +import { auth } from '@/lib/auth' + +const serve = async ({ request }: { request: Request }) => { + return fetchRequestHandler({ + endpoint: '/api/trpc', + req: request, + router: appRouter, + createContext: async () => ({ + db, + session: await auth.api.getSession({ headers: request.headers }), + }), + }) +} + +export const ServerRoute = createFileRoute('/api/trpc/$').methods({ + GET: serve, + POST: serve, +}) +``` + +--- + +## Phase 5: TanStack DB Collections Setup + +### 5.1 Create tRPC Client + +**src/lib/trpc.ts:** +```typescript +import { createTRPCClient, httpBatchLink } from '@trpc/client' +import type { AppRouter } from '@/server/trpc/router' + +export const trpc = createTRPCClient({ + links: [ + httpBatchLink({ + url: '/api/trpc', + headers: () => { + return { + 'content-type': 'application/json', + } + }, + }), + ], +}) +``` + +### 5.2 Query Collection Configuration + +**src/lib/collections/query-mode.ts:** +```typescript +import { createCollection } from '@tanstack/react-db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' +import { QueryClient } from '@tanstack/query-core' +import { selectIssueSchema, selectCommentSchema } from '@/db/schema' +import { trpc } from '@/lib/trpc' + +export const queryClient = new QueryClient() + +export const issuesQueryCollection = createCollection( + queryCollectionOptions({ + id: 'issues-query', + queryKey: ['issues'], + refetchInterval: 3000, // Poll every 3 seconds + queryClient, + + queryFn: async () => { + const issues = await trpc.issues.getAll.query() + return issues.map((issue) => ({ + ...issue, + created_at: new Date(issue.created_at), + modified: new Date(issue.modified), + })) + }, + + getKey: (item) => item.id, + schema: selectIssueSchema, + + onInsert: async ({ transaction }) => { + const newIssue = transaction.mutations[0].modified + await trpc.issues.create.mutate({ + title: newIssue.title, + description: newIssue.description, + priority: newIssue.priority, + status: newIssue.status, + kanbanorder: newIssue.kanbanorder, + }) + }, + + onUpdate: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map((mutation) => + trpc.issues.update.mutate({ + id: mutation.original.id, + ...mutation.changes, + }) + ) + ) + }, + + onDelete: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map((mutation) => + trpc.issues.delete.mutate({ id: mutation.original.id }) + ) + ) + }, + }) +) + +export const commentsQueryCollection = createCollection( + queryCollectionOptions({ + id: 'comments-query', + queryKey: ['comments'], + refetchInterval: 3000, + queryClient, + + queryFn: async () => { + // This will be filtered per-issue in components + // For now, return empty array - comments loaded per issue + return [] + }, + + getKey: (item) => item.id, + schema: selectCommentSchema, + + onInsert: async ({ transaction }) => { + const newComment = transaction.mutations[0].modified + await trpc.comments.create.mutate({ + body: newComment.body, + issue_id: newComment.issue_id, + }) + }, + + onDelete: async ({ transaction }) => { + await Promise.all( + transaction.mutations.map((mutation) => + trpc.comments.delete.mutate({ id: mutation.original.id }) + ) + ) + }, + }) +) +``` + +### 5.3 Electric Collection Configuration + +**src/lib/collections/electric-mode.ts:** +```typescript +import { createCollection } from '@tanstack/react-db' +import { electricCollectionOptions } from '@tanstack/electric-db-collection' +import { selectIssueSchema, selectCommentSchema } from '@/db/schema' +import { trpc } from '@/lib/trpc' + +const ELECTRIC_URL = import.meta.env.VITE_ELECTRIC_URL || 'http://localhost:3000' + +export const issuesElectricCollection = createCollection( + electricCollectionOptions({ + id: 'issues-electric', + + shapeOptions: { + url: `${ELECTRIC_URL}/v1/shape`, + params: { + table: 'issues', + // Filter in Electric shape to only include user's issues + demo issues + // This requires setting up user context in Electric proxy + }, + parser: { + timestamptz: (date: string) => new Date(date), + }, + }, + + getKey: (item) => item.id, + schema: selectIssueSchema, + + onInsert: async ({ transaction }) => { + const newIssue = transaction.mutations[0].modified + const response = await trpc.issues.create.mutate({ + title: newIssue.title, + description: newIssue.description, + priority: newIssue.priority, + status: newIssue.status, + kanbanorder: newIssue.kanbanorder, + }) + + // Return transaction ID for Electric to wait for sync + return { txid: response.txid } + }, + + onUpdate: async ({ transaction }) => { + const txids = await Promise.all( + transaction.mutations.map(async (mutation) => { + const response = await trpc.issues.update.mutate({ + id: mutation.original.id, + ...mutation.changes, + }) + return response.txid + }) + ) + return { txid: txids } + }, + + onDelete: async ({ transaction }) => { + const txids = await Promise.all( + transaction.mutations.map(async (mutation) => { + const response = await trpc.issues.delete.mutate({ + id: mutation.original.id, + }) + return response.txid + }) + ) + return { txid: txids } + }, + }) +) + +export const commentsElectricCollection = createCollection( + electricCollectionOptions({ + id: 'comments-electric', + + shapeOptions: { + url: `${ELECTRIC_URL}/v1/shape`, + params: { + table: 'comments', + }, + parser: { + timestamptz: (date: string) => new Date(date), + }, + }, + + getKey: (item) => item.id, + schema: selectCommentSchema, + + onInsert: async ({ transaction }) => { + const newComment = transaction.mutations[0].modified + const response = await trpc.comments.create.mutate({ + body: newComment.body, + issue_id: newComment.issue_id, + }) + return { txid: response.txid } + }, + + onDelete: async ({ transaction }) => { + const txids = await Promise.all( + transaction.mutations.map(async (mutation) => { + const response = await trpc.comments.delete.mutate({ + id: mutation.original.id, + }) + return response.txid + }) + ) + return { txid: txids } + }, + }) +) +``` + +### 5.4 Mode Switcher Context + +**src/lib/mode-context.tsx:** +```typescript +import { createContext, useContext, useState, type ReactNode } from 'react' +import type { Collection } from '@tanstack/db' +import type { Issue, Comment } from '@/db/schema' +import { + issuesQueryCollection, + commentsQueryCollection, +} from './collections/query-mode' +import { + issuesElectricCollection, + commentsElectricCollection, +} from './collections/electric-mode' + +export type SyncMode = 'query' | 'electric' + +interface ModeContextValue { + mode: SyncMode + setMode: (mode: SyncMode) => void + issuesCollection: Collection + commentsCollection: Collection +} + +const ModeContext = createContext(null) + +export function ModeProvider({ children }: { children: ReactNode }) { + const [mode, setMode] = useState('query') + + const issuesCollection = + mode === 'query' ? issuesQueryCollection : issuesElectricCollection + const commentsCollection = + mode === 'query' ? commentsQueryCollection : commentsElectricCollection + + return ( + + {children} + + ) +} + +export function useMode() { + const context = useContext(ModeContext) + if (!context) { + throw new Error('useMode must be used within ModeProvider') + } + return context +} +``` + +--- + +## Phase 6: Route Structure + +### 6.1 Root Route with Layout + +**src/routes/__root.tsx:** +```typescript +import { createRootRoute, Outlet } from '@tanstack/react-router' +import { ModeProvider } from '@/lib/mode-context' +import '@/styles/globals.css' + +export const Route = createRootRoute({ + component: RootLayout, +}) + +function RootLayout() { + return ( + +
+ +
+
+ ) +} +``` + +### 6.2 Authenticated Layout + +**src/routes/_authenticated.tsx:** +```typescript +import { createFileRoute, Outlet, redirect } from '@tanstack/react-router' +import { LeftMenu } from '@/components/LeftMenu' +import { authClient } from '@/lib/auth-client' + +export const Route = createFileRoute('/_authenticated')({ + beforeLoad: async ({ location }) => { + const { data: session } = await authClient.session() + + if (!session) { + throw redirect({ + to: '/login', + search: { + redirect: location.href, + }, + }) + } + }, + component: AuthenticatedLayout, +}) + +function AuthenticatedLayout() { + return ( +
+ +
+ +
+
+ ) +} +``` + +### 6.3 Issue List Route + +**src/routes/_authenticated/index.tsx:** +```typescript +import { createFileRoute } from '@tanstack/react-router' +import { IssueList } from '@/components/IssueList' +import { TopFilter } from '@/components/TopFilter' +import { useMode } from '@/lib/mode-context' + +export const Route = createFileRoute('/_authenticated/')({ + component: IssueListPage, + loader: async () => { + // Preload collections + const { issuesCollection } = useMode() + await issuesCollection.preload() + return null + }, +}) + +function IssueListPage() { + return ( +
+ + +
+ ) +} +``` + +### 6.4 Kanban Board Route + +**src/routes/_authenticated/board.tsx:** +```typescript +import { createFileRoute } from '@tanstack/react-router' +import { IssueBoard } from '@/components/IssueBoard' +import { TopFilter } from '@/components/TopFilter' +import { useMode } from '@/lib/mode-context' + +export const Route = createFileRoute('/_authenticated/board')({ + component: BoardPage, + loader: async () => { + const { issuesCollection } = useMode() + await issuesCollection.preload() + return null + }, +}) + +function BoardPage() { + return ( +
+ + +
+ ) +} +``` + +### 6.5 Issue Detail Route + +**src/routes/_authenticated/issue/$issueId.tsx:** +```typescript +import { createFileRoute } from '@tanstack/react-router' +import { IssueDetail } from '@/components/IssueDetail' +import { useMode } from '@/lib/mode-context' + +export const Route = createFileRoute('/_authenticated/issue/$issueId')({ + component: IssueDetailPage, + loader: async () => { + const { issuesCollection, commentsCollection } = useMode() + await Promise.all([ + issuesCollection.preload(), + commentsCollection.preload(), + ]) + return null + }, +}) + +function IssueDetailPage() { + const { issueId } = Route.useParams() + return +} +``` + +### 6.6 Login Route + +**src/routes/login.tsx:** +```typescript +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useState } from 'react' +import { signIn } from '@/lib/auth-client' + +export const Route = createFileRoute('/login')({ + component: LoginPage, +}) + +function LoginPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const navigate = useNavigate() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + await signIn.email({ + email, + password, + }) + + navigate({ to: '/' }) + } + + return ( +
+
+

Login

+ setEmail(e.target.value)} + className="w-full px-4 py-2 border rounded" + /> + setPassword(e.target.value)} + className="w-full px-4 py-2 border rounded" + /> + +
+
+ ) +} +``` + +--- + +## Phase 7: Core Components Implementation + +### 7.1 LeftMenu Component + +**src/components/LeftMenu.tsx:** +```typescript +import { Link } from '@tanstack/react-router' +import { useMode } from '@/lib/mode-context' +import { Home, List, LayoutGrid, Search, Settings } from 'lucide-react' + +export function LeftMenu() { + const { mode, setMode } = useMode() + + return ( + + ) +} +``` + +### 7.2 TopFilter Component + +**src/components/TopFilter.tsx:** +```typescript +import { useState } from 'react' +import { Filter, ChevronDown } from 'lucide-react' +import type { Priority, Status } from '@/db/schema' + +interface TopFilterProps { + hideSort?: boolean +} + +export function TopFilter({ hideSort }: TopFilterProps) { + const [selectedStatuses, setSelectedStatuses] = useState([]) + const [selectedPriorities, setSelectedPriorities] = useState([]) + + return ( +
+
+ + + {selectedStatuses.map((status) => ( +
+ {status} +
+ ))} + + {selectedPriorities.map((priority) => ( +
+ {priority} +
+ ))} +
+ + {!hideSort && ( + + )} +
+ ) +} +``` + +### 7.3 IssueList Component + +**src/components/IssueList.tsx:** +```typescript +import { useLiveQuery, eq } from '@tanstack/react-db' +import { useVirtualizer } from '@tanstack/react-virtual' +import { useRef } from 'react' +import { useMode } from '@/lib/mode-context' +import { IssueRow } from './IssueRow' + +export function IssueList() { + const { issuesCollection } = useMode() + const parentRef = useRef(null) + + const { data: issues } = useLiveQuery((q) => + q + .from({ issue: issuesCollection }) + .orderBy(({ issue }) => issue.created_at, 'desc') + ) + + const virtualizer = useVirtualizer({ + count: issues?.length ?? 0, + getScrollElement: () => parentRef.current, + estimateSize: () => 60, + }) + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const issue = issues[virtualItem.index] + return ( +
+ +
+ ) + })} +
+
+ ) +} +``` + +### 7.4 IssueBoard Component + +**src/components/IssueBoard.tsx:** +```typescript +import { useLiveQuery, eq } from '@tanstack/react-db' +import { useMode } from '@/lib/mode-context' +import { + DndContext, + DragEndEvent, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { generateKeyBetween } from 'fractional-indexing' +import { BoardColumn } from './BoardColumn' +import type { Status } from '@/db/schema' + +const STATUSES: Status[] = ['backlog', 'todo', 'in_progress', 'done', 'canceled'] + +export function IssueBoard() { + const { issuesCollection } = useMode() + + const sensors = useSensors( + useSensor(MouseSensor), + useSensor(TouchSensor) + ) + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over) return + + const issueId = active.id as string + const newStatus = over.id as Status + + // Update issue status and kanbanorder + issuesCollection.update(issueId, (draft) => { + draft.status = newStatus + // Calculate new kanbanorder based on position + draft.kanbanorder = generateKeyBetween(null, null) + }) + } + + return ( + +
+ {STATUSES.map((status) => ( + + ))} +
+
+ ) +} +``` + +### 7.5 IssueDetail Component + +**src/components/IssueDetail.tsx:** +```typescript +import { useLiveQuery, eq } from '@tanstack/react-db' +import { useMode } from '@/lib/mode-context' +import { useState, useEffect } from 'react' +import { Editor } from './editor/Editor' +import { Comments } from './Comments' +import { useDebounce } from '@/hooks/useDebounce' + +interface IssueDetailProps { + issueId: string +} + +export function IssueDetail({ issueId }: IssueDetailProps) { + const { issuesCollection } = useMode() + + const { data: issues } = useLiveQuery((q) => + q + .from({ issue: issuesCollection }) + .where(({ issue }) => eq(issue.id, issueId)) + ) + + const issue = issues?.[0] + + const [title, setTitle] = useState(issue?.title ?? '') + const [description, setDescription] = useState(issue?.description ?? '') + + const debouncedTitle = useDebounce(title, 500) + const debouncedDescription = useDebounce(description, 500) + + useEffect(() => { + if (!issue) return + + if (debouncedTitle !== issue.title) { + issuesCollection.update(issue.id, (draft) => { + draft.title = debouncedTitle + }) + } + }, [debouncedTitle]) + + useEffect(() => { + if (!issue) return + + if (debouncedDescription !== issue.description) { + issuesCollection.update(issue.id, (draft) => { + draft.description = debouncedDescription + }) + } + }, [debouncedDescription]) + + if (!issue) { + return
Issue not found
+ } + + return ( +
+ setTitle(e.target.value)} + className="text-3xl font-bold w-full mb-4 border-none outline-none" + placeholder="Issue title" + /> + +
+ + + +
+ +
+

Description

+ +
+ + +
+ ) +} +``` + +--- + +## Phase 8: Additional Features + +### 8.1 Full-Text Search + +**Implementation approach:** +- Create a PostgreSQL GIN index on `tsvector` for issues +- Add tRPC endpoint for search +- Create search route with live query + +### 8.2 Optimistic Updates Indicator + +**Visual feedback for sync status:** +- Show "syncing" icon when mutations are pending +- Show "synced" icon when data is confirmed +- Handle errors with retry/rollback UI + +### 8.3 Electric Proxy for User Filtering + +**src/routes/api/electric/$.ts:** +```typescript +import { createFileRoute } from '@tanstack/react-router' +import { auth } from '@/lib/auth' + +const ELECTRIC_URL = process.env.ELECTRIC_URL || 'http://localhost:3000' + +const serve = async ({ request }: { request: Request }) => { + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session) { + return new Response('Unauthorized', { status: 401 }) + } + + const url = new URL(request.url) + const electricUrl = new URL(`${ELECTRIC_URL}/v1/shape`) + + // Pass through Electric protocol parameters + url.searchParams.forEach((value, key) => { + electricUrl.searchParams.set(key, value) + }) + + // Add user filtering to WHERE clause + const table = electricUrl.searchParams.get('table') + const existingWhere = electricUrl.searchParams.get('where') || '' + + let userFilter = '' + if (table === 'issues' || table === 'comments') { + userFilter = `user_id = '${session.user.id}' OR user_id = 'demo-user-id'` + } + + const newWhere = existingWhere + ? `(${existingWhere}) AND (${userFilter})` + : userFilter + + if (newWhere) { + electricUrl.searchParams.set('where', newWhere) + } + + const response = await fetch(electricUrl, { + method: request.method, + headers: request.headers, + }) + + return new Response(response.body, { + status: response.status, + headers: response.headers, + }) +} + +export const ServerRoute = createFileRoute('/api/electric/$').methods({ + GET: serve, +}) +``` + +--- + +## Phase 9: Testing Strategy + +### 9.1 Unit Tests + +- Test collection mutation handlers +- Test live query transformations +- Test utility functions (fractional indexing, etc.) + +### 9.2 Integration Tests + +- Test tRPC endpoints with mock database +- Test auth middleware +- Test Electric proxy filtering + +### 9.3 E2E Tests (Playwright) + +- Test issue creation flow +- Test mode switching +- Test real-time sync in Electric mode +- Test user isolation + +--- + +## Phase 10: Deployment + +### 10.1 Environment Setup + +**Production environment variables:** +```bash +DATABASE_URL=postgresql://... +ELECTRIC_URL=https://electric.example.com +BETTER_AUTH_SECRET=... +BETTER_AUTH_URL=https://app.example.com +``` + +### 10.2 Build Configuration + +**vite.config.ts:** +```typescript +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [ + tanstackStart({ + srcDirectory: 'src', + start: { entry: './start.tsx' }, + }), + react(), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}) +``` + +### 10.3 Deployment Targets + +**Options:** +- Vercel (recommended for TanStack Start) +- Cloudflare Pages +- AWS Amplify +- Self-hosted Node.js + +--- + +## Implementation Timeline + +### Week 1: Foundation +- [ ] Project setup and dependencies +- [ ] Database schema and migrations +- [ ] Authentication setup +- [ ] Basic routing structure + +### Week 2: Core Features +- [ ] tRPC API implementation +- [ ] Collection setup (Query + Electric modes) +- [ ] Issue list view +- [ ] Basic CRUD operations + +### Week 3: Advanced Features +- [ ] Kanban board with drag-and-drop +- [ ] Issue detail page with rich editor +- [ ] Comments system +- [ ] Mode switcher + +### Week 4: Polish & Testing +- [ ] Full-text search +- [ ] Filtering and sorting +- [ ] Optimistic update indicators +- [ ] E2E tests +- [ ] Deployment + +--- + +## Key Differences from Original + +### What's the Same +βœ… Full issue management (CRUD) +βœ… Kanban board with drag-and-drop +βœ… Comments system +βœ… Filtering and search +βœ… Real-time updates (in Electric mode) +βœ… Optimistic updates +βœ… User-specific data + +### What's Different +πŸ”„ **Framework**: TanStack Start instead of vanilla React + React Router +πŸ”„ **Data Layer**: TanStack DB instead of PGlite +πŸ”„ **Backend**: tRPC + Drizzle instead of Hono write server +πŸ”„ **Sync**: Dual-mode (Query polling + Electric real-time) +πŸ”„ **Auth**: Better Auth instead of hardcoded username +πŸ”„ **Virtualization**: @tanstack/react-virtual instead of react-window +πŸ”„ **DnD**: @dnd-kit instead of react-beautiful-dnd + +### What's New +✨ Mode switcher (Query vs Electric) +✨ Multi-user support with authentication +✨ User isolation (see only your data + demo data) +✨ Full-stack type safety with tRPC +✨ Modern TanStack ecosystem integration + +--- + +## Success Criteria + +1. βœ… **Feature Parity**: All LinearLite features work identically +2. βœ… **Dual Mode**: Users can switch between Query and Electric modes seamlessly +3. βœ… **User Isolation**: Users only see their data + pre-seeded demo data +4. βœ… **Performance**: Sub-100ms UI updates with optimistic mutations +5. βœ… **Type Safety**: End-to-end TypeScript with no `any` types +6. βœ… **Offline Support**: Works offline in Electric mode with sync on reconnect +7. βœ… **Production Ready**: Deployed and accessible with authentication + +--- + +## Resources & References + +### Documentation +- [TanStack Start Docs](https://tanstack.com/start) +- [TanStack DB Docs](https://tanstack.com/db) +- [ElectricSQL Docs](https://electric-sql.com) +- [Drizzle ORM Docs](https://orm.drizzle.team) +- [tRPC Docs](https://trpc.io) + +### Example Code +- [Original LinearLite](https://github.com/electric-sql/electric/tree/main/examples/linearlite) +- [TanStack DB React Projects Example](/home/user/db/examples/react/projects/) +- [TanStack DB React Todo Example](/home/user/db/examples/react/todo/) + +### Community +- [TanStack Discord](https://discord.gg/yjUNbvbraC) +- [ElectricSQL Discord](https://discord.electric-sql.com) + +--- + +## Next Steps + +1. **Review this plan** and provide feedback +2. **Set up development environment** +3. **Begin Phase 1 implementation** +4. **Iterate based on learnings** + +This plan provides a comprehensive roadmap for porting LinearLite to the TanStack ecosystem while adding valuable enhancements like dual-mode sync and multi-user support. diff --git a/TANSTACK_START_COMPREHENSIVE_GUIDE.md b/TANSTACK_START_COMPREHENSIVE_GUIDE.md new file mode 100644 index 000000000..1b47c1a7a --- /dev/null +++ b/TANSTACK_START_COMPREHENSIVE_GUIDE.md @@ -0,0 +1,2570 @@ +# TanStack Start Comprehensive Guide + +A complete guide to building full-stack applications with TanStack Start, including integration with TanStack DB. + +## Table of Contents + +1. [Introduction](#introduction) +2. [Project Structure](#project-structure) +3. [Getting Started](#getting-started) +4. [File-Based Routing](#file-based-routing) +5. [Pages and Layouts](#pages-and-layouts) +6. [Data Loading and Server Functions](#data-loading-and-server-functions) +7. [Client-Side Navigation](#client-side-navigation) +8. [State Management Integration](#state-management-integration) +9. [SSR and Hydration](#ssr-and-hydration) +10. [API Routes](#api-routes) +11. [Middleware and Context](#middleware-and-context) +12. [Error Handling](#error-handling) +13. [Forms and Validation](#forms-and-validation) +14. [Integration with TanStack DB](#integration-with-tanstack-db) +15. [Deployment](#deployment) +16. [Best Practices](#best-practices) +17. [Complete Examples](#complete-examples) + +--- + +## Introduction + +**TanStack Start** is a full-stack React framework powered by TanStack Router that provides: + +- **Full-document SSR** - Server-side rendering for better performance and SEO +- **Streaming** - Progressive page loading for improved user experience +- **Server Functions** - Type-safe RPCs between client and server +- **Server Routes & API Routes** - Build backend endpoints alongside your frontend +- **Middleware & Context** - Powerful request/response handling +- **Full-Stack Bundling** - Optimized builds via Vite +- **End-to-End Type Safety** - Full TypeScript support +- **Built on Nitro** - Deploy anywhere (Vercel, Cloudflare, AWS, etc.) + +TanStack Start relies 100% on TanStack Router for its routing system and integrates seamlessly with the entire TanStack ecosystem (Query, Form, Table, DB). + +--- + +## Project Structure + +A typical TanStack Start project structure: + +``` +my-tanstack-app/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ routes/ # File-based routing (pages & API routes) +β”‚ β”‚ β”œβ”€β”€ __root.tsx # Root layout +β”‚ β”‚ β”œβ”€β”€ index.tsx # Home page (/) +β”‚ β”‚ β”œβ”€β”€ about.tsx # About page (/about) +β”‚ β”‚ β”œβ”€β”€ _layout.tsx # Pathless layout +β”‚ β”‚ └── api/ # API routes +β”‚ β”‚ └── users.ts # API endpoint (/api/users) +β”‚ β”œβ”€β”€ components/ # Reusable components +β”‚ β”œβ”€β”€ hooks/ # Custom hooks +β”‚ β”œβ”€β”€ utils/ # Utility functions +β”‚ β”œβ”€β”€ router.tsx # Router configuration +β”‚ β”œβ”€β”€ client.tsx # Client entry point +β”‚ └── ssr.tsx # Server entry point +β”œβ”€β”€ public/ # Static assets +β”œβ”€β”€ vite.config.ts # Vite configuration +β”œβ”€β”€ package.json +└── tsconfig.json +``` + +### Key Files + +**`src/routes/__root.tsx`** - Root layout wrapping all routes: +```tsx +import { Outlet, createRootRoute } from '@tanstack/react-router' +import { Meta, Scripts } from '@tanstack/react-start' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + + + ) +} +``` + +**`src/router.tsx`** - Router configuration: +```typescript +import { createRouter as createTanStackRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function createRouter() { + const router = createTanStackRouter({ + routeTree, + scrollRestoration: true, + defaultPreload: 'intent', + defaultPreloadDelay: 50, + }) + return router +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} +``` + +**`vite.config.ts`** - Vite configuration: +```typescript +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths(), + tanstackStart(), + // React's vite plugin must come after start's vite plugin + viteReact(), + ], +}) +``` + +--- + +## Getting Started + +### Installation + +#### Option 1: Use Starter Template +```bash +# Clone the official basic example +npx gitpick TanStack/router/tree/main/examples/react/start-basic start-basic +cd start-basic +npm install +npm run dev +``` + +#### Option 2: Create from Scratch +```bash +# Create a new project +npm create vite@latest my-app -- --template react-ts +cd my-app + +# Install TanStack Start +npm install @tanstack/react-router @tanstack/react-start @tanstack/react-start/plugin/vite + +# Install dev dependencies +npm install -D @tanstack/router-devtools @tanstack/router-plugin + +# Start development server +npm run dev +``` + +### Available Starter Templates + +1. **dotnize/react-tanstarter** - TanStack Start + Better Auth + Drizzle ORM + shadcn/ui +2. **ally-ahmed/tss-app** - TanStack Start + shadcn/ui + tRPC + Drizzle + Lucia-Auth +3. **mwolf1989/tanstack-starter** - TanStack Start + Supabase Auth +4. **Kiranism/tanstack-start-dashboard** - Dashboard with TanStack Table + server-side operations + +--- + +## File-Based Routing + +TanStack Start uses file-based routing where files and directories in `src/routes/` represent your application's routes. + +### Routing Conventions + +#### 1. Index Routes +``` +src/routes/index.tsx β†’ / +src/routes/about/index.tsx β†’ /about +``` + +#### 2. Named Routes +``` +src/routes/about.tsx β†’ /about +src/routes/blog.tsx β†’ /blog +``` + +#### 3. Dynamic Routes (Parameters) +``` +src/routes/posts/$id.tsx β†’ /posts/:id +src/routes/users/$userId.tsx β†’ /users/:userId +src/routes/blog/$slug.tsx β†’ /blog/:slug +``` + +#### 4. Nested Routes (Directory-based) +``` +src/routes/ + β”œβ”€β”€ app/ + β”‚ β”œβ”€β”€ index.tsx β†’ /app + β”‚ β”œβ”€β”€ dashboard.tsx β†’ /app/dashboard + β”‚ └── settings.tsx β†’ /app/settings +``` + +#### 5. Nested Routes (Flat routing with dots) +``` +src/routes/ + β”œβ”€β”€ app.tsx β†’ /app (layout) + β”œβ”€β”€ app.dashboard.tsx β†’ /app/dashboard + └── app.settings.tsx β†’ /app/settings +``` + +#### 6. Pathless Layout Routes (underscore prefix) +``` +src/routes/ + β”œβ”€β”€ _layout.tsx β†’ Layout without affecting URL + β”œβ”€β”€ _layout.home.tsx β†’ / (with layout) + └── _layout.about.tsx β†’ /about (with layout) +``` + +#### 7. Co-locating Non-Route Files (dash prefix) +``` +src/routes/ + β”œβ”€β”€ posts.tsx + └── -components/ β†’ Ignored by router + └── PostCard.tsx +``` + +### Route File Example + +```tsx +// src/routes/posts/$id.tsx +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' + +// Server function to fetch post +const getPost = createServerFn({ method: 'GET' }) + .inputValidator((id: string) => id) + .handler(async ({ data: id }) => { + // Fetch from database + const post = await db.posts.findById(id) + if (!post) throw new Error('Post not found') + return post + }) + +// Route definition +export const Route = createFileRoute('/posts/$id')({ + component: PostPage, + loader: async ({ params }) => await getPost({ data: params.id }), +}) + +// Component +function PostPage() { + const post = Route.useLoaderData() + const { id } = Route.useParams() + + return ( +
+

{post.title}

+

{post.content}

+
+ ) +} +``` + +--- + +## Pages and Layouts + +### Creating a Basic Page + +```tsx +// src/routes/about.tsx +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/about')({ + component: AboutPage, +}) + +function AboutPage() { + return ( +
+

About Us

+

Welcome to our application!

+
+ ) +} +``` + +### Layout Routes with Outlet + +The `Outlet` component renders child routes: + +```tsx +// src/routes/_layout.tsx +import { Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_layout')({ + component: LayoutComponent, +}) + +function LayoutComponent() { + return ( +
+
+ +
+
+ {/* Child routes render here */} +
+
Β© 2025 My App
+
+ ) +} +``` + +### Nested Layouts + +```tsx +// src/routes/app.tsx (Parent layout) +import { Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app')({ + component: AppLayout, +}) + +function AppLayout() { + return ( +
+ +
+ +
+
+ ) +} +``` + +```tsx +// src/routes/app/dashboard.tsx (Child route) +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/dashboard')({ + component: DashboardPage, +}) + +function DashboardPage() { + return

Dashboard

+} +``` + +### Root Layout with Providers + +```tsx +// src/routes/__root.tsx +import { Outlet, createRootRoute } from '@tanstack/react-router' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Meta, Scripts } from '@tanstack/react-start' + +const queryClient = new QueryClient() + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + + + + + ) +} +``` + +--- + +## Data Loading and Server Functions + +TanStack Start provides server functions for type-safe data fetching and mutations. + +### Server Functions Basics + +Server functions execute **only on the server** but can be called from client or server code. + +```tsx +import { createServerFn } from '@tanstack/react-start' + +// GET request (default) +const getData = createServerFn({ method: 'GET' }) + .handler(async () => { + return { message: 'Hello from server!' } + }) + +// POST request with validation +const updateData = createServerFn({ method: 'POST' }) + .inputValidator((input: { name: string }) => input) + .handler(async ({ data }) => { + // data is type-safe + await db.users.update(data) + return { success: true } + }) +``` + +### Route Loaders + +Loaders fetch data before rendering: + +```tsx +// src/routes/posts/index.tsx +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' + +const getPosts = createServerFn({ method: 'GET' }).handler(async () => { + const posts = await db.posts.findAll() + return posts +}) + +export const Route = createFileRoute('/posts/')({ + component: PostsPage, + loader: async () => await getPosts(), +}) + +function PostsPage() { + const posts = Route.useLoaderData() + + return ( +
+

Posts

+
    + {posts.map(post => ( +
  • {post.title}
  • + ))} +
+
+ ) +} +``` + +### Complete Example: Counter with Server State + +```tsx +import * as fs from 'node:fs' +import { createFileRoute, useRouter } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' + +const filePath = 'count.txt' + +async function readCount() { + return parseInt( + await fs.promises.readFile(filePath, 'utf-8').catch(() => '0'), + ) +} + +const getCount = createServerFn({ method: 'GET' }).handler(() => { + return readCount() +}) + +const updateCount = createServerFn({ method: 'POST' }) + .inputValidator((d: number) => d) + .handler(async ({ data }) => { + const count = await readCount() + await fs.promises.writeFile(filePath, `${count + data}`) + }) + +export const Route = createFileRoute('/')({ + component: Home, + loader: async () => await getCount(), +}) + +function Home() { + const router = useRouter() + const state = Route.useLoaderData() + + return ( + + ) +} +``` + +### External API Example + +```tsx +import { createServerFn } from '@tanstack/react-start' +import { createFileRoute } from '@tanstack/react-router' + +const getProjects = createServerFn({ method: 'GET' }).handler(async () => { + const res = await fetch( + 'https://api.github.com/users/tanstack/repos?sort=updated&per_page=5', + { + headers: { + 'X-GitHub-Api-Version': '2022-11-28', + accept: 'application/vnd.github+json', + }, + } + ) + return res.json() +}) + +export const Route = createFileRoute('/projects')({ + component: Projects, + loader: () => getProjects(), +}) + +function Projects() { + const projects = Route.useLoaderData() + + return ( +
+

GitHub Projects

+ {projects.map((project: any) => ( +
+

{project.name}

+

{project.description}

+
+ ))} +
+ ) +} +``` + +### beforeLoad Hook + +Execute logic before loading data (e.g., authentication checks): + +```tsx +export const Route = createFileRoute('/dashboard')({ + beforeLoad: async ({ context, location }) => { + const user = await getUser() + if (!user) { + throw redirect({ to: '/login', search: { redirect: location.href } }) + } + return { user } + }, + loader: async ({ context }) => { + // context.user is available + return getUserData(context.user.id) + }, + component: DashboardPage, +}) +``` + +--- + +## Client-Side Navigation + +### Link Component + +The `Link` component provides type-safe navigation with automatic prefetching: + +```tsx +import { Link } from '@tanstack/react-router' + +function Navigation() { + return ( + + ) +} +``` + +### Programmatic Navigation + +```tsx +import { useNavigate, useRouter } from '@tanstack/react-router' + +function MyComponent() { + const navigate = useNavigate() + const router = useRouter() + + const handleClick = () => { + // Navigate to a route + navigate({ to: '/about' }) + + // Navigate with params + navigate({ to: '/posts/$id', params: { id: '123' } }) + + // Navigate with search + navigate({ to: '/search', search: { query: 'test' } }) + + // Go back + router.history.back() + + // Invalidate and refetch data + router.invalidate() + } + + return +} +``` + +### Router Invalidation + +Force refetch of route data: + +```tsx +import { useRouter } from '@tanstack/react-router' + +function RefreshButton() { + const router = useRouter() + + return ( + + ) +} +``` + +### Prefetching + +```tsx +import { Link } from '@tanstack/react-router' + +// Prefetch on intent (hover/touchstart) - default with 50ms delay +Posts + +// Prefetch immediately +Posts + +// Disable prefetching +Posts +``` + +Configure default prefetching in router: + +```typescript +// src/router.tsx +export function createRouter() { + const router = createTanStackRouter({ + routeTree, + defaultPreload: 'intent', + defaultPreloadDelay: 50, // milliseconds + }) + return router +} +``` + +--- + +## State Management Integration + +TanStack Start integrates seamlessly with the TanStack ecosystem and other state management solutions. + +### TanStack Query Integration + +```tsx +// src/routes/__root.tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Outlet, createRootRoute } from '@tanstack/react-router' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + }, + }, +}) + +export const Route = createRootRoute({ + component: () => ( + + + + ), +}) +``` + +```tsx +// Using in a component +import { useQuery, useMutation } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' + +const fetchPosts = createServerFn({ method: 'GET' }).handler(async () => { + return await db.posts.findAll() +}) + +const createPost = createServerFn({ method: 'POST' }) + .inputValidator((data: { title: string; content: string }) => data) + .handler(async ({ data }) => { + return await db.posts.create(data) + }) + +export const Route = createFileRoute('/posts/')({ + component: PostsPage, +}) + +function PostsPage() { + const { data: posts, isLoading } = useQuery({ + queryKey: ['posts'], + queryFn: () => fetchPosts(), + }) + + const mutation = useMutation({ + mutationFn: createPost, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['posts'] }) + }, + }) + + if (isLoading) return
Loading...
+ + return ( +
+

Posts

+ {posts?.map(post => ( +
{post.title}
+ ))} +
+ ) +} +``` + +### URL State Management + +TanStack Router provides built-in URL state management: + +```tsx +import { createFileRoute } from '@tanstack/react-router' +import { z } from 'zod' + +// Define search params schema +const searchSchema = z.object({ + page: z.number().default(1), + query: z.string().default(''), + sort: z.enum(['asc', 'desc']).default('asc'), +}) + +export const Route = createFileRoute('/search')({ + validateSearch: searchSchema, + component: SearchPage, +}) + +function SearchPage() { + const navigate = Route.useNavigate() + const search = Route.useSearch() + + const updateSearch = (updates: Partial) => { + navigate({ search: { ...search, ...updates } }) + } + + return ( +
+ updateSearch({ query: e.target.value })} + /> +
Page: {search.page}
+ +
+ ) +} +``` + +### Context-Based State + +```tsx +// src/routes/__root.tsx +import { createContext, useState, useContext } from 'react' +import { Outlet, createRootRoute } from '@tanstack/react-router' + +type ThemeContext = { + theme: 'light' | 'dark' + toggleTheme: () => void +} + +const ThemeContext = createContext(null) + +export const useTheme = () => { + const context = useContext(ThemeContext) + if (!context) throw new Error('useTheme must be used within ThemeProvider') + return context +} + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + const [theme, setTheme] = useState<'light' | 'dark'>('light') + + const toggleTheme = () => { + setTheme(prev => prev === 'light' ? 'dark' : 'light') + } + + return ( + +
+ +
+
+ ) +} +``` + +--- + +## SSR and Hydration + +TanStack Start provides full-document SSR with streaming support and hydration. + +### How SSR Works in TanStack Start + +1. **Server**: Routes are matched β†’ `beforeLoad` executes β†’ `loader` executes β†’ Components render to HTML +2. **Client**: HTML is sent β†’ Client hydrates β†’ Application becomes interactive +3. **Navigation**: Subsequent navigations run on client-side + +### Selective SSR Options + +Control SSR behavior per route: + +```tsx +export const Route = createFileRoute('/my-route')({ + // Option 1: Full SSR (default) + ssr: true, // Runs loaders & renders on server, then client + + // Option 2: Data-only SSR + ssr: 'data-only', // Runs loaders on server, but component renders only on client + + // Option 3: Client-only + ssr: false, // Everything runs only on client + + component: MyComponent, + loader: async () => { + return await fetchData() + }, +}) +``` + +**When to use each mode:** + +- `ssr: true` - Default, best for SEO and initial page load +- `ssr: 'data-only'` - For components with browser-only APIs but server data +- `ssr: false` - For purely client-side features (charts, maps, etc.) + +### Streaming SSR + +TanStack Start supports streaming for better perceived performance: + +```tsx +import { Suspense } from 'react' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/dashboard')({ + component: DashboardPage, +}) + +function DashboardPage() { + return ( +
+

Dashboard

+ + {/* This content streams to client as it loads */} + Loading metrics...
}> + + + + Loading chart...}> + + + + ) +} +``` + +### Handling Hydration Errors + +Common hydration errors occur when server and client render differently: + +```tsx +// ❌ Bad: Causes hydration mismatch +function Component() { + return
{Date.now()}
+} + +// βœ… Good: Consistent on server and client +function Component() { + const [time, setTime] = useState(null) + + useEffect(() => { + setTime(Date.now()) + }, []) + + return
{time ?? 'Loading...'}
+} +``` + +### Server vs Client Detection + +```tsx +import { useEffect, useState } from 'react' + +function useIsClient() { + const [isClient, setIsClient] = useState(false) + + useEffect(() => { + setIsClient(true) + }, []) + + return isClient +} + +function MyComponent() { + const isClient = useIsClient() + + if (!isClient) { + return
Server render
+ } + + return
Client render with browser APIs
+} +``` + +--- + +## API Routes + +TanStack Start allows you to create server-side API endpoints alongside your pages. + +### Creating API Routes + +API routes are defined in `src/routes/api/` or with `.api.ts` suffix: + +```tsx +// src/routes/api/users.ts +import { json } from '@tanstack/react-start' + +export async function GET() { + const users = await db.users.findAll() + return json(users) +} + +export async function POST({ request }: { request: Request }) { + const data = await request.json() + const user = await db.users.create(data) + return json(user, { status: 201 }) +} +``` + +### Dynamic API Routes + +```tsx +// src/routes/api/users/$id.ts +import { json } from '@tanstack/react-start' + +export async function GET({ params }: { params: { id: string } }) { + const user = await db.users.findById(params.id) + + if (!user) { + return json({ error: 'User not found' }, { status: 404 }) + } + + return json(user) +} + +export async function PUT({ params, request }: { params: { id: string }, request: Request }) { + const data = await request.json() + const user = await db.users.update(params.id, data) + return json(user) +} + +export async function DELETE({ params }: { params: { id: string } }) { + await db.users.delete(params.id) + return json({ success: true }) +} +``` + +### API Route Examples + +**File Naming Convention:** +``` +/routes/api/users.ts β†’ /api/users +/routes/api/users/index.ts β†’ /api/users +/routes/api/users/$id.ts β†’ /api/users/:id +/routes/api/users/$id/posts.ts β†’ /api/users/:id/posts +``` + +**Complete CRUD API:** + +```tsx +// src/routes/api/posts/index.ts +import { json } from '@tanstack/react-start' + +// GET /api/posts +export async function GET({ request }: { request: Request }) { + const url = new URL(request.url) + const page = parseInt(url.searchParams.get('page') || '1') + const limit = parseInt(url.searchParams.get('limit') || '10') + + const posts = await db.posts.findMany({ + skip: (page - 1) * limit, + take: limit, + }) + + return json({ posts, page, limit }) +} + +// POST /api/posts +export async function POST({ request }: { request: Request }) { + const data = await request.json() + + // Validate + if (!data.title || !data.content) { + return json({ error: 'Missing required fields' }, { status: 400 }) + } + + const post = await db.posts.create(data) + return json(post, { status: 201 }) +} +``` + +```tsx +// src/routes/api/posts/$id.ts +import { json } from '@tanstack/react-start' + +// GET /api/posts/:id +export async function GET({ params }: { params: { id: string } }) { + const post = await db.posts.findById(params.id) + + if (!post) { + return json({ error: 'Post not found' }, { status: 404 }) + } + + return json(post) +} + +// PUT /api/posts/:id +export async function PUT({ params, request }: { params: { id: string }, request: Request }) { + const data = await request.json() + const post = await db.posts.update(params.id, data) + return json(post) +} + +// DELETE /api/posts/:id +export async function DELETE({ params }: { params: { id: string } }) { + await db.posts.delete(params.id) + return json({ message: 'Post deleted' }) +} +``` + +### Calling API Routes from Client + +```tsx +// Using fetch +async function createPost(data: { title: string; content: string }) { + const response = await fetch('/api/posts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + return response.json() +} + +// Using TanStack Query +import { useMutation } from '@tanstack/react-query' + +function useCreatePost() { + return useMutation({ + mutationFn: async (data: { title: string; content: string }) => { + const response = await fetch('/api/posts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + return response.json() + }, + }) +} +``` + +--- + +## Middleware and Context + +Middleware allows you to customize request/response handling and inject context. + +### Server Function Middleware + +```tsx +import { createMiddleware } from '@tanstack/react-start' + +// Authentication middleware +const authMiddleware = createMiddleware().server(async ({ next, request }) => { + const session = await getSessionFromRequest(request) + + if (!session) { + throw new Error('Unauthorized') + } + + // Pass user to context + return next({ + context: { + user: session.user, + }, + }) +}) + +// Use in server function +const getProtectedData = createServerFn({ method: 'GET' }) + .middleware([authMiddleware]) + .handler(async ({ context }) => { + // context.user is available and type-safe + const data = await db.getData(context.user.id) + return data + }) +``` + +### Request/Response Utilities + +```tsx +import { getHeaders, setHeader, getWebRequest } from '@tanstack/react-start' + +const myServerFn = createServerFn({ method: 'POST' }) + .handler(async () => { + // Get request headers + const headers = getHeaders() + const authHeader = headers.get('authorization') + + // Set response header + setHeader('X-Custom-Header', 'value') + + // Get full web request + const request = getWebRequest() + const url = new URL(request.url) + + return { success: true } + }) +``` + +### Route Context + +Pass data through route hierarchy: + +```tsx +// Parent route provides context +export const Route = createFileRoute('/_authenticated')({ + beforeLoad: async () => { + const user = await getCurrentUser() + if (!user) throw redirect({ to: '/login' }) + + return { + user, // Available to child routes + } + }, +}) + +// Child route uses context +export const Route = createFileRoute('/_authenticated/dashboard')({ + loader: async ({ context }) => { + // context.user is available + return getUserDashboard(context.user.id) + }, + component: DashboardPage, +}) + +function DashboardPage() { + const { user } = Route.useRouteContext() + return

Welcome, {user.name}!

+} +``` + +### Global Middleware Example + +```tsx +// src/middleware/logger.ts +import { createMiddleware } from '@tanstack/react-start' + +export const loggerMiddleware = createMiddleware().server(async ({ next, request }) => { + const start = Date.now() + const url = new URL(request.url) + + console.log(`[${new Date().toISOString()}] ${request.method} ${url.pathname}`) + + const response = await next() + + const duration = Date.now() - start + console.log(`[${new Date().toISOString()}] Completed in ${duration}ms`) + + return response +}) +``` + +--- + +## Error Handling + +TanStack Start provides built-in error handling with error boundaries and custom error components. + +### Route-Level Error Components + +```tsx +import { createFileRoute, ErrorComponent } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts')({ + component: PostsPage, + loader: async () => { + const posts = await fetchPosts() + if (!posts) throw new Error('Failed to load posts') + return posts + }, + errorComponent: ({ error }) => { + return ( +
+

Error Loading Posts

+

{error.message}

+ +
+ ) + }, +}) + +function PostsPage() { + const posts = Route.useLoaderData() + return
{/* render posts */}
+} +``` + +### Global Error Component + +```tsx +// src/routes/__root.tsx +import { createRootRoute, ErrorComponent } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: RootComponent, + errorComponent: GlobalErrorComponent, +}) + +function GlobalErrorComponent({ error, reset }: { error: Error, reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ) +} +``` + +### Not Found Errors + +```tsx +import { createFileRoute, notFound } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/$id')({ + loader: async ({ params }) => { + const post = await getPost(params.id) + if (!post) { + throw notFound() + } + return post + }, + component: PostPage, + notFoundComponent: () => { + return ( +
+

404 - Post Not Found

+ Back to Posts +
+ ) + }, +}) +``` + +### Error Boundaries with TanStack Query + +```tsx +import { QueryErrorResetBoundary } from '@tanstack/react-query' +import { ErrorBoundary } from 'react-error-boundary' + +function MyPage() { + return ( + + {({ reset }) => ( + ( +
+

Error occurred

+

{error.message}

+ +
+ )} + > + +
+ )} +
+ ) +} +``` + +### Server Function Error Handling + +```tsx +import { createServerFn } from '@tanstack/react-start' + +const myServerFn = createServerFn({ method: 'POST' }) + .inputValidator((data: { email: string }) => data) + .handler(async ({ data }) => { + try { + const result = await sendEmail(data.email) + return { success: true } + } catch (error) { + // Log server-side + console.error('Email send failed:', error) + + // Return error to client + throw new Error('Failed to send email. Please try again.') + } + }) + +// In component +function MyForm() { + const [error, setError] = useState(null) + + const handleSubmit = async (email: string) => { + try { + await myServerFn({ data: { email } }) + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred') + } + } + + return ( +
+ {error &&
{error}
} + {/* form */} +
+ ) +} +``` + +--- + +## Forms and Validation + +TanStack Start integrates with TanStack Form for type-safe form handling with server validation. + +### Basic Form with TanStack Form + +```tsx +import { useForm } from '@tanstack/react-form' +import { createServerFn } from '@tanstack/react-start' +import { z } from 'zod' + +const createUserSchema = z.object({ + name: z.string().min(2), + email: z.string().email(), +}) + +const createUser = createServerFn({ method: 'POST' }) + .inputValidator((data: unknown) => createUserSchema.parse(data)) + .handler(async ({ data }) => { + const user = await db.users.create(data) + return user + }) + +function UserForm() { + const form = useForm({ + defaultValues: { + name: '', + email: '', + }, + onSubmit: async ({ value }) => { + try { + await createUser({ data: value }) + alert('User created!') + } catch (error) { + alert('Error creating user') + } + }, + }) + + return ( +
{ + e.preventDefault() + form.handleSubmit() + }} + > + + {(field) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {field.state.meta.errors && ( + {field.state.meta.errors.join(', ')} + )} +
+ )} +
+ + + {(field) => ( +
+ + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {field.state.meta.errors && ( + {field.state.meta.errors.join(', ')} + )} +
+ )} +
+ + +
+ ) +} +``` + +### Field-Level Validation + +```tsx +import { useForm } from '@tanstack/react-form' + +function MyForm() { + const form = useForm({ + defaultValues: { + username: '', + password: '', + }, + onSubmit: async ({ value }) => { + await submitForm(value) + }, + }) + + return ( +
+ { + if (value.length < 3) { + return 'Username must be at least 3 characters' + } + }, + onBlur: async ({ value }) => { + // Async validation + const exists = await checkUsernameExists(value) + if (exists) { + return 'Username already taken' + } + }, + }} + > + {(field) => ( +
+ field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + {field.state.meta.errors && ( + {field.state.meta.errors[0]} + )} +
+ )} +
+
+ ) +} +``` + +### Server-Side Validation + +```tsx +import { createServerValidate, ServerValidateError } from '@tanstack/react-form/start' +import { z } from 'zod' + +const userSchema = z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), + email: z.string().email('Invalid email address'), +}) + +const serverValidateUser = createServerValidate({ + validator: userSchema, +}) + +const createUser = createServerFn({ method: 'POST' }) + .handler(async ({ data }) => { + // Server-side validation + const validation = await serverValidateUser(data) + + if (!validation.success) { + throw new ServerValidateError(validation.errors) + } + + // Proceed with validated data + return await db.users.create(validation.data) + }) +``` + +### Integration with Mutations + +```tsx +import { useForm } from '@tanstack/react-form' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +function PostForm() { + const queryClient = useQueryClient() + + const mutation = useMutation({ + mutationFn: createPost, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['posts'] }) + }, + }) + + const form = useForm({ + defaultValues: { + title: '', + content: '', + }, + onSubmit: async ({ value }) => { + mutation.mutate(value) + }, + }) + + return ( +
{ e.preventDefault(); form.handleSubmit() }}> + {/* form fields */} + + {mutation.isError && ( +
Error: {mutation.error.message}
+ )} +
+ ) +} +``` + +--- + +## Integration with TanStack DB + +TanStack DB provides a local-first database solution that integrates seamlessly with TanStack Start and TanStack Query. + +### Installation + +```bash +npm install @tanstack/db @tanstack/query-db-collection +``` + +### Setting Up TanStack DB Collections + +```tsx +// src/db/collections.ts +import { createCollection } from '@tanstack/db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' + +// Define schema +interface Todo { + id: string + title: string + completed: boolean + createdAt: number +} + +// Create collection +export const todosCollection = createCollection({ + name: 'todos', + key: 'id', + + // Local-first storage + storage: { + type: 'localStorage', + key: 'todos-db', + }, +}) + +// Create query collection options for TanStack Query integration +export const todosQueryOptions = queryCollectionOptions({ + collection: todosCollection, + + // Define sync with server + queryFn: async () => { + const response = await fetch('/api/todos') + return response.json() + }, + + // Define mutation handlers + onMutate: async ({ type, data }) => { + if (type === 'create') { + await fetch('/api/todos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + } + if (type === 'update') { + await fetch(`/api/todos/${data.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + } + if (type === 'delete') { + await fetch(`/api/todos/${data.id}`, { + method: 'DELETE', + }) + } + }, +}) +``` + +### Using Collections in Components + +```tsx +import { createFileRoute } from '@tanstack/react-router' +import { useCollection } from '@tanstack/db' +import { useCollectionQuery } from '@tanstack/query-db-collection' +import { todosCollection, todosQueryOptions } from '../db/collections' + +export const Route = createFileRoute('/todos')({ + component: TodosPage, +}) + +function TodosPage() { + // Use collection with TanStack Query for sync + const { data: todos } = useCollectionQuery(todosQueryOptions) + + // Access collection methods + const collection = useCollection(todosCollection) + + const addTodo = async (title: string) => { + await collection.add({ + id: crypto.randomUUID(), + title, + completed: false, + createdAt: Date.now(), + }) + } + + const toggleTodo = async (id: string) => { + const todo = await collection.get(id) + if (todo) { + await collection.update(id, { completed: !todo.completed }) + } + } + + const deleteTodo = async (id: string) => { + await collection.delete(id) + } + + return ( +
+

Todos

+ +
{ + e.preventDefault() + const input = e.currentTarget.elements.namedItem('title') as HTMLInputElement + addTodo(input.value) + input.value = '' + }}> + + +
+ +
    + {todos?.map(todo => ( +
  • + toggleTodo(todo.id)} + /> + + {todo.title} + + +
  • + ))} +
+
+ ) +} +``` + +### Server Functions with TanStack DB + +```tsx +// src/routes/api/todos.ts +import { createServerFn } from '@tanstack/react-start' +import { json } from '@tanstack/react-start' + +// Server-side database (e.g., Prisma, Drizzle) +import { db } from '../db/server' + +export async function GET() { + const todos = await db.todo.findMany() + return json(todos) +} + +export async function POST({ request }: { request: Request }) { + const data = await request.json() + const todo = await db.todo.create({ data }) + return json(todo) +} +``` + +### Live Queries + +TanStack DB supports live queries that automatically update: + +```tsx +import { useLiveQuery } from '@tanstack/db' +import { todosCollection } from '../db/collections' + +function TodosList() { + // Automatically updates when collection changes + const todos = useLiveQuery( + todosCollection, + (collection) => collection.find({ completed: false }) + ) + + return ( +
    + {todos.map(todo => ( +
  • {todo.title}
  • + ))} +
+ ) +} +``` + +### Filtering and Querying + +```tsx +import { useCollectionQuery } from '@tanstack/query-db-collection' +import { todosCollection } from '../db/collections' + +function FilteredTodos() { + const { data: completedTodos } = useCollectionQuery({ + collection: todosCollection, + filter: (todo) => todo.completed === true, + sort: (a, b) => b.createdAt - a.createdAt, + }) + + return ( +
+

Completed Todos

+ {completedTodos?.map(todo => ( +
{todo.title}
+ ))} +
+ ) +} +``` + +### Offline-First Pattern + +```tsx +// The collection automatically handles offline/online sync +import { useCollection } from '@tanstack/db' +import { todosCollection } from '../db/collections' + +function OfflineFirstTodos() { + const collection = useCollection(todosCollection) + + const addTodo = async (title: string) => { + // Works offline - will sync when online + await collection.add({ + id: crypto.randomUUID(), + title, + completed: false, + createdAt: Date.now(), + }) + } + + return ( +
+ {/* UI works seamlessly offline/online */} +
+ ) +} +``` + +--- + +## Deployment + +TanStack Start is built on Nitro, allowing deployment to any hosting provider. + +### Vercel + +```typescript +// vite.config.ts +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' + +export default defineConfig({ + plugins: [ + tanstackStart({ + target: 'vercel', + }), + ], +}) +``` + +```bash +# Deploy to Vercel +vercel deploy +``` + +### Cloudflare Workers/Pages + +```typescript +// vite.config.ts +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' + +export default defineConfig({ + plugins: [ + tanstackStart({ + target: 'cloudflare-pages', + }), + ], +}) +``` + +```bash +# Build +npm run build + +# Deploy +wrangler pages deploy .output/public +``` + +### AWS (via SST) + +```typescript +// sst.config.ts +import { TanstackStart } from 'sst/constructs' + +export default { + config() { + return { + name: 'my-tanstack-app', + region: 'us-east-1', + } + }, + stacks(app) { + app.stack(function Site({ stack }) { + const site = new TanstackStart(stack, 'site', { + domain: 'myapp.com', + }) + + stack.addOutputs({ + url: site.url, + }) + }) + }, +} +``` + +```bash +# Deploy +npx sst deploy +``` + +### Netlify + +```typescript +// vite.config.ts +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' + +export default defineConfig({ + plugins: [ + tanstackStart({ + target: 'netlify', + }), + ], +}) +``` + +### Node.js Server + +```typescript +// vite.config.ts +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' + +export default defineConfig({ + plugins: [ + tanstackStart({ + target: 'node-server', + }), + ], +}) +``` + +```bash +# Build +npm run build + +# Run production server +node .output/server/index.mjs +``` + +### Build for Production + +```bash +# Build the application +npm run build + +# Preview production build locally +npm run preview +``` + +--- + +## Best Practices + +### 1. Project Organization + +``` +src/ +β”œβ”€β”€ routes/ # File-based routes +β”‚ β”œβ”€β”€ __root.tsx +β”‚ β”œβ”€β”€ index.tsx +β”‚ └── api/ +β”œβ”€β”€ components/ # Reusable UI components +β”‚ β”œβ”€β”€ ui/ # Base components (buttons, inputs) +β”‚ └── features/ # Feature-specific components +β”œβ”€β”€ hooks/ # Custom React hooks +β”œβ”€β”€ lib/ # Utilities and helpers +β”œβ”€β”€ db/ # Database schemas and collections +β”œβ”€β”€ middleware/ # Server middleware +└── types/ # TypeScript types +``` + +### 2. Type Safety + +Always use TypeScript and leverage type inference: + +```tsx +// Define shared types +// src/types/index.ts +export interface User { + id: string + name: string + email: string +} + +// Use in server functions +const getUser = createServerFn({ method: 'GET' }) + .inputValidator((id: string) => id) + .handler(async ({ data: id }): Promise => { + return await db.users.findById(id) + }) + +// Type-safe in components +function UserProfile() { + const user = Route.useLoaderData() // TypeScript knows this is User + return
{user.name}
+} +``` + +### 3. Data Loading Strategy + +- Use **loaders** for initial page data +- Use **TanStack Query** for dynamic/refetchable data +- Use **server functions** for mutations + +```tsx +export const Route = createFileRoute('/posts')({ + // Initial load on server + loader: async () => await getInitialPosts(), + component: PostsPage, +}) + +function PostsPage() { + const initialPosts = Route.useLoaderData() + + // Dynamic data with refetching + const { data: posts } = useQuery({ + queryKey: ['posts'], + queryFn: getPosts, + initialData: initialPosts, + }) + + // Mutations + const mutation = useMutation({ + mutationFn: createPost, + }) + + return
{/* UI */}
+} +``` + +### 4. Error Handling + +Provide error boundaries at multiple levels: + +```tsx +// Global error boundary in __root.tsx +export const Route = createRootRoute({ + errorComponent: GlobalError, +}) + +// Route-specific errors +export const Route = createFileRoute('/posts')({ + errorComponent: PostsError, +}) + +// Component-level error boundaries for critical sections +}> + + +``` + +### 5. Performance Optimization + +```tsx +// Prefetch on intent +Posts + +// Selective SSR +export const Route = createFileRoute('/dashboard')({ + ssr: 'data-only', // Fetch data on server, render on client +}) + +// Code splitting +const HeavyComponent = lazy(() => import('./HeavyComponent')) + +}> + + +``` + +### 6. Authentication Pattern + +```tsx +// src/middleware/auth.ts +const authMiddleware = createMiddleware().server(async ({ next, request }) => { + const session = await getSession(request) + if (!session) { + throw redirect({ to: '/login' }) + } + return next({ context: { user: session.user } }) +}) + +// Protected layout route +export const Route = createFileRoute('/_authenticated')({ + beforeLoad: async ({ request }) => { + const user = await getCurrentUser(request) + if (!user) throw redirect({ to: '/login' }) + return { user } + }, +}) + +// Child routes automatically protected +export const Route = createFileRoute('/_authenticated/dashboard')({ + component: DashboardPage, +}) +``` + +### 7. Environment Variables + +```tsx +// Server-side only +const apiKey = process.env.API_KEY // Never sent to client + +// Client-safe (prefix with VITE_) +const publicUrl = import.meta.env.VITE_PUBLIC_URL +``` + +### 8. Database Access + +```tsx +// NEVER import database client in client code +// ❌ Bad: db imported in component +import { db } from './db' +function MyComponent() { + const data = db.query() // ERROR: db is server-only +} + +// βœ… Good: Use server functions +const getData = createServerFn({ method: 'GET' }) + .handler(async () => { + const { db } = await import('./db') // Server-only import + return db.query() + }) +``` + +### 9. Avoid Over-fetching + +```tsx +// Use search params to control data loading +export const Route = createFileRoute('/posts')({ + validateSearch: z.object({ + limit: z.number().default(10), + offset: z.number().default(0), + }), + loader: async ({ search }) => { + return getPosts({ limit: search.limit, offset: search.offset }) + }, +}) +``` + +### 10. Testing + +```tsx +// Test server functions +import { describe, it, expect } from 'vitest' + +describe('getUserData', () => { + it('should return user data', async () => { + const result = await getUserData({ data: 'user-123' }) + expect(result).toHaveProperty('id') + expect(result).toHaveProperty('name') + }) +}) + +// Test components +import { render, screen } from '@testing-library/react' + +it('renders user name', () => { + render() + expect(screen.getByText('John')).toBeInTheDocument() +}) +``` + +--- + +## Complete Examples + +### Example 1: Todo App with TanStack DB + +```tsx +// src/db/todos.ts +import { createCollection } from '@tanstack/db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' + +export interface Todo { + id: string + title: string + completed: boolean + createdAt: number +} + +export const todosCollection = createCollection({ + name: 'todos', + key: 'id', + storage: { type: 'localStorage', key: 'todos-db' }, +}) + +export const todosQueryOptions = queryCollectionOptions({ + collection: todosCollection, + queryFn: async () => { + const res = await fetch('/api/todos') + return res.json() + }, + onMutate: async ({ type, data }) => { + if (type === 'create') { + await fetch('/api/todos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + } + if (type === 'update') { + await fetch(`/api/todos/${data.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + } + if (type === 'delete') { + await fetch(`/api/todos/${data.id}`, { method: 'DELETE' }) + } + }, +}) +``` + +```tsx +// src/routes/todos.tsx +import { createFileRoute } from '@tanstack/react-router' +import { useCollection } from '@tanstack/db' +import { useCollectionQuery } from '@tanstack/query-db-collection' +import { todosCollection, todosQueryOptions, type Todo } from '../db/todos' +import { useState } from 'react' + +export const Route = createFileRoute('/todos')({ + component: TodosPage, +}) + +function TodosPage() { + const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all') + const { data: allTodos } = useCollectionQuery(todosQueryOptions) + const collection = useCollection(todosCollection) + + const filteredTodos = allTodos?.filter(todo => { + if (filter === 'active') return !todo.completed + if (filter === 'completed') return todo.completed + return true + }) + + const addTodo = async (title: string) => { + await collection.add({ + id: crypto.randomUUID(), + title, + completed: false, + createdAt: Date.now(), + }) + } + + const toggleTodo = async (id: string) => { + const todo = await collection.get(id) + if (todo) { + await collection.update(id, { completed: !todo.completed }) + } + } + + const deleteTodo = async (id: string) => { + await collection.delete(id) + } + + return ( +
+

Todos

+ +
{ + e.preventDefault() + const input = e.currentTarget.elements.namedItem('title') as HTMLInputElement + if (input.value.trim()) { + addTodo(input.value) + input.value = '' + } + }} + className="mb-8" + > + +
+ +
+ + + +
+ +
    + {filteredTodos?.map(todo => ( +
  • + toggleTodo(todo.id)} + /> + + {todo.title} + + +
  • + ))} +
+ +
+ {filteredTodos?.filter(t => !t.completed).length} items left +
+
+ ) +} +``` + +```tsx +// src/routes/api/todos.ts +import { json } from '@tanstack/react-start' + +// In-memory storage for demo (use real DB in production) +let todos: Array<{ + id: string + title: string + completed: boolean + createdAt: number +}> = [] + +export async function GET() { + return json(todos) +} + +export async function POST({ request }: { request: Request }) { + const data = await request.json() + todos.push(data) + return json(data, { status: 201 }) +} +``` + +```tsx +// src/routes/api/todos/$id.ts +import { json } from '@tanstack/react-start' + +export async function PUT({ params, request }: { params: { id: string }, request: Request }) { + const data = await request.json() + const index = todos.findIndex(t => t.id === params.id) + if (index !== -1) { + todos[index] = { ...todos[index], ...data } + return json(todos[index]) + } + return json({ error: 'Not found' }, { status: 404 }) +} + +export async function DELETE({ params }: { params: { id: string } }) { + todos = todos.filter(t => t.id !== params.id) + return json({ success: true }) +} +``` + +### Example 2: Blog with Authentication + +```tsx +// src/middleware/auth.ts +import { createMiddleware } from '@tanstack/react-start' + +export const authMiddleware = createMiddleware().server(async ({ next, request }) => { + const sessionToken = request.headers.get('cookie')?.match(/session=([^;]+)/)?.[1] + + if (!sessionToken) { + throw new Error('Unauthorized') + } + + const user = await verifySession(sessionToken) + + return next({ + context: { user }, + }) +}) +``` + +```tsx +// src/routes/_authenticated.tsx +import { createFileRoute, redirect, Outlet } from '@tanstack/react-router' + +export const Route = createFileRoute('/_authenticated')({ + beforeLoad: async ({ request }) => { + const user = await getCurrentUser(request) + if (!user) { + throw redirect({ to: '/login' }) + } + return { user } + }, + component: () => , +}) +``` + +```tsx +// src/routes/_authenticated/blog/new.tsx +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { useForm } from '@tanstack/react-form' +import { authMiddleware } from '../../../middleware/auth' + +const createPost = createServerFn({ method: 'POST' }) + .middleware([authMiddleware]) + .inputValidator((data: { title: string; content: string }) => data) + .handler(async ({ data, context }) => { + const post = await db.posts.create({ + ...data, + authorId: context.user.id, + }) + return post + }) + +export const Route = createFileRoute('/_authenticated/blog/new')({ + component: NewPostPage, +}) + +function NewPostPage() { + const navigate = Route.useNavigate() + + const form = useForm({ + defaultValues: { + title: '', + content: '', + }, + onSubmit: async ({ value }) => { + const post = await createPost({ data: value }) + navigate({ to: '/blog/$id', params: { id: post.id } }) + }, + }) + + return ( +
+

Create New Post

+ +
{ + e.preventDefault() + form.handleSubmit() + }} + > + + {(field) => ( +
+ + field.handleChange(e.target.value)} + /> +
+ )} +
+ + + {(field) => ( +
+ +