├── src ├── styles │ ├── custom.css │ └── app.css ├── types │ └── browser-echo.d.ts ├── db │ └── schema │ │ ├── .ruler │ │ └── db.schema.md │ │ └── AGENTS.md ├── tanstack-start.d.ts ├── start.ts ├── lib │ ├── utils.ts │ ├── theme.ts │ └── todos │ │ └── queries.ts ├── env │ ├── server.ts │ └── client.ts ├── components │ ├── PostError.tsx │ ├── mode-toggle.tsx │ ├── gradient-orb.tsx │ ├── NotFound.tsx │ ├── DefaultCatchBoundary.tsx │ ├── theme-init-script.tsx │ ├── theme-provider.tsx │ ├── ui │ │ ├── button.tsx │ │ ├── card.tsx │ │ └── dropdown-menu.tsx │ └── Header.tsx ├── utils │ ├── id-generator.ts │ └── seo.ts ├── entry-client.tsx ├── hooks │ └── useSessionStorage.ts ├── routes │ ├── (marketing) │ │ ├── route.tsx │ │ ├── docs.tsx │ │ └── index.tsx │ ├── api │ │ └── test.ts │ ├── .ruler │ │ └── tanstack-server-routes.md │ ├── AGENTS.md │ └── __root.tsx ├── router.tsx ├── server │ └── function │ │ ├── todos.ts │ │ ├── .ruler │ │ └── tanstack-server-fn.md │ │ └── AGENTS.md ├── .ruler │ ├── tanstack-query-rules.md │ └── tanstack-environment-server-client-only-rules.md └── AGENTS.md ├── .cursor └── commands │ ├── token-shortener.md │ └── problem-analyzer.md ├── .cursorignore ├── .cursorindexingignore ├── public ├── favicon.ico ├── favicon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png └── site.webmanifest ├── tests └── setup.ts ├── .prettierrc ├── vitest.config.ts ├── .vscode ├── launch.json └── settings.json ├── components.json ├── tsconfig.json ├── LICENSE ├── vite.config.ts ├── .ruler ├── ruler.toml └── AGENTS.md ├── .gitignore ├── .oxlintrc.json ├── package.json ├── docs ├── avoid-useEffect-summary.md └── tanstack-rc1-upgrade-guide.md ├── CHANGELOG.md ├── README.md └── AGENTS.md /src/styles/custom.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.cursor/commands/token-shortener.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.cursorignore: -------------------------------------------------------------------------------- 1 | .env 2 | public/** 3 | drizzle/meta/ -------------------------------------------------------------------------------- /.cursorindexingignore: -------------------------------------------------------------------------------- 1 | docs/** 2 | cli/** 3 | drizzle/** */ -------------------------------------------------------------------------------- /src/types/browser-echo.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'virtual:browser-echo'; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/constructa-starter-min/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/constructa-starter-min/HEAD/public/favicon.png -------------------------------------------------------------------------------- /src/db/schema/.ruler/db.schema.md: -------------------------------------------------------------------------------- 1 | - Schema files have always this naming pattern `.schema.ts` 2 | 3 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/constructa-starter-min/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/constructa-starter-min/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/constructa-starter-min/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /src/tanstack-start.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import '../.tanstack-start/server-routes/routeTree.gen' 3 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/constructa-starter-min/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instructa/constructa-starter-min/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/start.ts: -------------------------------------------------------------------------------- 1 | import { createStart } from '@tanstack/react-start'; 2 | export const startInstance = createStart(async () => ({ 3 | functionMiddleware: [], 4 | })); 5 | -------------------------------------------------------------------------------- /src/db/schema/AGENTS.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | - Schema files have always this naming pattern `.schema.ts` 7 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/vitest" 2 | 3 | // ensure env vars are loaded before any database import 4 | import dotenv from "dotenv" 5 | dotenv.config({ path: ".env.test" }) 6 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/env/server.ts: -------------------------------------------------------------------------------- 1 | import { createEnv } from '@t3-oss/env-core'; 2 | import * as z from 'zod'; 3 | 4 | export const env = createEnv({ 5 | server: { 6 | MY_SECRET_VAR: z.url(), 7 | }, 8 | runtimeEnv: process.env, 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/PostError.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorComponent, type ErrorComponentProps } from "@tanstack/react-router" 2 | 3 | export function PostErrorComponent({ error }: ErrorComponentProps) { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "bracketSpacing": true, 9 | "arrowParens": "always", 10 | "endOfLine": "lf" 11 | } 12 | -------------------------------------------------------------------------------- /src/env/client.ts: -------------------------------------------------------------------------------- 1 | import { createEnv } from '@t3-oss/env-core'; 2 | import * as z from 'zod'; 3 | 4 | export const env = createEnv({ 5 | clientPrefix: 'VITE_', 6 | client: { 7 | VITE_BASE_URL: z.url().default('http://localhost:3000'), 8 | }, 9 | runtimeEnv: import.meta.env, 10 | }); 11 | -------------------------------------------------------------------------------- /src/utils/id-generator.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto'; 2 | 3 | const prefixes = { 4 | files: 'file', 5 | user: 'user', 6 | } as const; 7 | 8 | export const generateId = (prefix: keyof typeof prefixes | string) => { 9 | const resolvedPrefix = (prefix in prefixes) ? prefixes[prefix as keyof typeof prefixes] : prefix; 10 | return `${resolvedPrefix}_${randomUUID()}`; 11 | } -------------------------------------------------------------------------------- /src/entry-client.tsx: -------------------------------------------------------------------------------- 1 | import { StartClient } from "@tanstack/react-start/client" 2 | import { StrictMode, startTransition } from "react" 3 | import { hydrateRoot } from "react-dom/client" 4 | 5 | startTransition(() => { 6 | hydrateRoot( 7 | document, 8 | ( 9 | 10 | 11 | 12 | ) 13 | ) 14 | }) 15 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config" 2 | import react from "@vitejs/plugin-react" 3 | import tsconfigPaths from "vite-tsconfig-paths" 4 | 5 | export default defineConfig({ 6 | plugins: [react(), tsconfigPaths()], 7 | test: { 8 | globals: true, 9 | environment: "jsdom", 10 | setupFiles: "./tests/setup.ts", 11 | coverage: { 12 | reporter: ["text", "html"] 13 | } 14 | } 15 | }) -------------------------------------------------------------------------------- /src/hooks/useSessionStorage.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export function useSessionStorage(key: string, initialValue: T) { 4 | const state = React.useState(() => { 5 | const stored = sessionStorage.getItem(key); 6 | return stored ? JSON.parse(stored) : initialValue; 7 | }); 8 | 9 | React.useEffect(() => { 10 | sessionStorage.setItem(key, JSON.stringify(state[0])); 11 | }, [state[0]]); 12 | 13 | return state; 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node-terminal", 6 | "name": "dev-server", 7 | "request": "launch", 8 | "command": "pnpm run dev", 9 | "cwd": "${workspaceFolder}", 10 | "env": { "NODE_OPTIONS": "--enable-source-maps" } 11 | }, 12 | { 13 | "name": "client-side", 14 | "type": "chrome", 15 | "request": "launch", 16 | "url": "http://localhost:3000" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/routes/(marketing)/route.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet, createFileRoute } from '@tanstack/react-router'; 2 | import { Suspense } from 'react'; 3 | import { Header } from '~/components/Header'; 4 | 5 | export const Route = createFileRoute('/(marketing)')({ 6 | component: RouteComponent, 7 | }); 8 | 9 | function RouteComponent() { 10 | return ( 11 |
12 |
13 | Loading...
}> 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/styles/app.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "~/components", 15 | "utils": "~/lib/utils", 16 | "ui": "~/components/ui", 17 | "lib": "~/lib", 18 | "hooks": "~/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /src/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { ClientOnly } from '@tanstack/react-router'; 2 | import { useTheme } from '~/components/theme-provider'; 3 | 4 | function ModeToggleInner() { 5 | const { theme, setTheme } = useTheme(); 6 | const next = theme === 'light' ? 'dark' : 'light'; 7 | 8 | return ( 9 | 12 | ); 13 | } 14 | export function ModeToggle() { 15 | return ( 16 | ☀️}> 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/gradient-orb.tsx: -------------------------------------------------------------------------------- 1 | import type { HTMLAttributes } from "react" 2 | import { cn } from "~/lib/utils" 3 | 4 | interface GradientOrbProps extends HTMLAttributes {} 5 | 6 | export default function GradientOrb({ className, ...props }: GradientOrbProps) { 7 | return ( 8 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "strict": true, 5 | "esModuleInterop": true, 6 | "jsx": "react-jsx", 7 | "module": "ESNext", 8 | "moduleResolution": "Bundler", 9 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 10 | "isolatedModules": true, 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | "target": "ES2022", 14 | "allowJs": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./src/*"] 19 | }, 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.cursor/commands/problem-analyzer.md: -------------------------------------------------------------------------------- 1 | Tasks: 2 | 1) Locate all files/modules affected by the issue. List paths and why each is implicated. 3 | 2) Explain the root cause(s): what changed, how it propagates to the failure, and any environmental factors. 4 | 3) Propose the minimal, safe fix. Include code-level steps, side effects, and tests to add/update. 5 | 4) Flag any missing or outdated documentation/configs/schemas that should be updated or added (especially if code appears outdated vs. current behavior). Specify exact docs/sections to create or amend. 6 | 7 | Output format: 8 | - Affected files: 9 | - : 10 | - Root cause: 11 | - 12 | - Proposed fix: 13 | - 14 | - Tests: 15 | - Documentation gaps: 16 | - 17 | - Open questions/assumptions: 18 | - 19 | 20 | DON'T WRITE ANY CODE -------------------------------------------------------------------------------- /src/components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@tanstack/react-router'; 2 | 3 | export function NotFound({ children }: { children?: any }) { 4 | return ( 5 |
6 |
7 | {children ||

The page you are looking for does not exist.

} 8 |
9 |

10 | 16 | 20 | Start Over 21 | 22 |

23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/routes/api/test.ts: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router'; 2 | import { json } from '@tanstack/react-start'; 3 | 4 | export const Route = createFileRoute('/api/test')({ 5 | server: { 6 | handlers: { 7 | GET: async ({ request }) => { 8 | return json({ 9 | message: 'Hello from GET!', 10 | method: 'GET', 11 | timestamp: new Date().toISOString(), 12 | url: request.url, 13 | }); 14 | }, 15 | POST: async ({ request }) => { 16 | const body = await request.json().catch(() => ({})); 17 | 18 | return json( 19 | { 20 | message: 'Hello from POST!', 21 | method: 'POST', 22 | received: body, 23 | timestamp: new Date().toISOString(), 24 | }, 25 | { 26 | status: 201, 27 | } 28 | ); 29 | }, 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/router.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | import { createRouter } from '@tanstack/react-router'; 3 | import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'; 4 | import { routeTree } from './routeTree.gen'; 5 | import { DefaultCatchBoundary } from './components/DefaultCatchBoundary'; 6 | import { NotFound } from './components/NotFound'; 7 | 8 | export function getRouter() { 9 | const queryClient = new QueryClient(); 10 | 11 | const router = createRouter({ 12 | routeTree, 13 | context: { queryClient }, 14 | defaultPreload: 'intent', 15 | defaultErrorComponent: DefaultCatchBoundary, 16 | defaultNotFoundComponent: () => , 17 | }); 18 | setupRouterSsrQueryIntegration({ 19 | router, 20 | queryClient, 21 | }); 22 | 23 | return router; 24 | } 25 | 26 | declare module '@tanstack/react-router' { 27 | interface Register { 28 | router: ReturnType; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/theme.ts: -------------------------------------------------------------------------------- 1 | import { createServerFn } from "@tanstack/react-start" 2 | import { getCookie, setCookie } from "@tanstack/react-start/server" 3 | 4 | export type Theme = "light" | "dark" | "system" 5 | const COOKIE = "vite-ui-theme" 6 | 7 | /** Read the theme for *this* request */ 8 | export const getTheme = createServerFn().handler(async () => { 9 | const raw = getCookie(COOKIE) 10 | return raw === "light" || raw === "dark" || raw === "system" ? (raw as Theme) : "system" 11 | }) 12 | 13 | /** Persist a new theme (POST from the client) */ 14 | export const setTheme = createServerFn({ method: "POST" }) 15 | .inputValidator((data: unknown): Theme => { 16 | if (data !== "light" && data !== "dark" && data !== "system") { 17 | throw new Error("theme must be light | dark | system") 18 | } 19 | return data 20 | }) 21 | .handler(async ({ data }) => { 22 | setCookie(COOKIE, data, { path: "/", maxAge: 60 * 60 * 24 * 365 }) 23 | }) 24 | -------------------------------------------------------------------------------- /src/utils/seo.ts: -------------------------------------------------------------------------------- 1 | export const seo = ({ 2 | title, 3 | description, 4 | keywords, 5 | image 6 | }: { 7 | title: string 8 | description?: string 9 | image?: string 10 | keywords?: string 11 | }) => { 12 | const tags = [ 13 | { title }, 14 | { name: "description", content: description }, 15 | { name: "keywords", content: keywords }, 16 | { name: "twitter:title", content: title }, 17 | { name: "twitter:description", content: description }, 18 | { name: "twitter:creator", content: "@tannerlinsley" }, 19 | { name: "twitter:site", content: "@tannerlinsley" }, 20 | { name: "og:type", content: "website" }, 21 | { name: "og:title", content: title }, 22 | { name: "og:description", content: description }, 23 | ...(image 24 | ? [ 25 | { name: "twitter:image", content: image }, 26 | { name: "twitter:card", content: "summary_large_image" }, 27 | { name: "og:image", content: image } 28 | ] 29 | : []) 30 | ] 31 | 32 | return tags 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Instructa Kevin Kern 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import browserEcho from '@browser-echo/vite'; 2 | import tailwindcss from '@tailwindcss/vite'; 3 | import { tanstackStart } from '@tanstack/react-start/plugin/vite'; 4 | import react from '@vitejs/plugin-react'; 5 | import Icons from 'unplugin-icons/vite'; 6 | import { defineConfig, loadEnv, type ConfigEnv } from 'vite'; 7 | import tsConfigPaths from 'vite-tsconfig-paths'; 8 | import { nitro } from 'nitro/vite'; 9 | 10 | export default ({ mode }: ConfigEnv) => { 11 | const env = loadEnv(mode, process.cwd(), ''); 12 | Object.assign(process.env, env); 13 | 14 | return defineConfig({ 15 | server: { 16 | port: 3000, 17 | }, 18 | plugins: [ 19 | tsConfigPaths({ 20 | projects: ['./tsconfig.json'], 21 | }), 22 | tanstackStart(), 23 | nitro(), 24 | react(), 25 | Icons({ 26 | compiler: 'jsx', 27 | jsx: 'react', 28 | }), 29 | browserEcho({ 30 | include: ['error', 'warn', 'info'], 31 | stackMode: 'condensed', 32 | tag: 'tanstack-start', 33 | showSource: true, 34 | fileLog: { 35 | enabled: false, 36 | }, 37 | }), 38 | tailwindcss(), 39 | ], 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit" 4 | }, 5 | "editor.formatOnSave": true, 6 | "editor.defaultFormatter": "esbenp.prettier-vscode", 7 | "[javascript]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "[javascriptreact]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "[typescript]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "[typescriptreact]": { 17 | "editor.defaultFormatter": "esbenp.prettier-vscode" 18 | }, 19 | "[json]": { 20 | "editor.defaultFormatter": "esbenp.prettier-vscode" 21 | }, 22 | "[jsonc]": { 23 | "editor.defaultFormatter": "esbenp.prettier-vscode" 24 | }, 25 | "files.watcherExclude": { 26 | "**/routeTree.gen.ts": true, 27 | "**/.tanstack/**/*": true, 28 | "pnpm-lock.yaml": true 29 | }, 30 | "search.exclude": { 31 | "**/routeTree.gen.ts": true, 32 | "**/.tanstack/**/*": true, 33 | "pnpm-lock.yaml": true 34 | }, 35 | "files.readonlyInclude": { 36 | "**/routeTree.gen.ts": true, 37 | "**/.tanstack/**/*": true, 38 | "pnpm-lock.yaml": true 39 | }, 40 | "typescript.tsdk": "./node_modules/typescript/lib" 41 | } 42 | -------------------------------------------------------------------------------- /.ruler/ruler.toml: -------------------------------------------------------------------------------- 1 | # Ruler Configuration File 2 | # See https://ai.intellectronica.net/ruler for documentation. 3 | 4 | # To specify which agents are active by default when --agents is not used, 5 | # uncomment and populate the following line. If omitted, all agents are active. 6 | default_agents = ["codex", "claude", "cursor"] 7 | 8 | # Enable nested rule loading from nested .ruler directories 9 | # When enabled, ruler will search for and process .ruler directories throughout the project hierarchy 10 | nested = true 11 | 12 | # --- Agent Specific Configurations --- 13 | # You can enable/disable agents and override their default output paths here. 14 | # Use lowercase agent identifiers: amp, copilot, claude, codex, cursor, windsurf, cline, aider, kilocode 15 | 16 | # [agents.copilot] 17 | # enabled = true 18 | # output_path = ".github/copilot-instructions.md" 19 | 20 | # [agents.aider] 21 | # enabled = true 22 | # output_path_instructions = "AGENTS.md" 23 | # output_path_config = ".aider.conf.yml" 24 | 25 | # [agents.gemini-cli] 26 | # enabled = true 27 | 28 | # --- MCP Servers --- 29 | # Define Model Context Protocol servers here. Two examples: 30 | # 1. A stdio server (local executable) 31 | # 2. A remote server (HTTP-based) 32 | 33 | # [mcp_servers.example_stdio] 34 | # command = "node" 35 | # args = ["scripts/your-mcp-server.js"] 36 | # env = { API_KEY = "replace_me" } 37 | 38 | # [mcp_servers.example_remote] 39 | # url = "https://api.example.com/mcp" 40 | # headers = { Authorization = "Bearer REPLACE_ME" } 41 | 42 | -------------------------------------------------------------------------------- /src/components/DefaultCatchBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorComponent, Link, rootRouteId, useMatch, useRouter } from '@tanstack/react-router'; 2 | import type { ErrorComponentProps } from '@tanstack/react-router'; 3 | 4 | export function DefaultCatchBoundary({ error }: ErrorComponentProps) { 5 | const router = useRouter(); 6 | const isRoot = useMatch({ 7 | strict: false, 8 | select: (state) => state.id === rootRouteId, 9 | }); 10 | 11 | console.error(error); 12 | 13 | return ( 14 |
15 | 16 |
17 | 25 | {isRoot ? ( 26 | 30 | Home 31 | 32 | ) : ( 33 | { 37 | e.preventDefault(); 38 | window.history.back(); 39 | }} 40 | > 41 | Go Back 42 | 43 | )} 44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/server/function/todos.ts: -------------------------------------------------------------------------------- 1 | import { createServerFn } from '@tanstack/react-start'; 2 | import { z } from 'zod'; 3 | 4 | // Simple in-memory store for demo (in production, use a database) 5 | const todos: Array<{ id: string; text: string; completed: boolean }> = []; 6 | 7 | const todoSchema = z.object({ 8 | text: z.string().min(1, 'Todo text is required'), 9 | }); 10 | 11 | export const getTodos = createServerFn({ method: 'GET' }).handler(async () => { 12 | return todos; 13 | }); 14 | 15 | export const createTodo = createServerFn({ method: 'POST' }) 16 | .inputValidator((data: unknown) => { 17 | return todoSchema.parse(data); 18 | }) 19 | .handler(async ({ data }) => { 20 | const newTodo = { 21 | id: crypto.randomUUID(), 22 | text: data.text, 23 | completed: false, 24 | }; 25 | todos.push(newTodo); 26 | return newTodo; 27 | }); 28 | 29 | export const toggleTodo = createServerFn({ method: 'POST' }) 30 | .inputValidator((data: unknown) => { 31 | return z.object({ id: z.string() }).parse(data); 32 | }) 33 | .handler(async ({ data }) => { 34 | const todo = todos.find((t) => t.id === data.id); 35 | if (todo) { 36 | todo.completed = !todo.completed; 37 | return todo; 38 | } 39 | throw new Error('Todo not found'); 40 | }); 41 | 42 | export const deleteTodo = createServerFn({ method: 'POST' }) 43 | .inputValidator((data: unknown) => { 44 | return z.object({ id: z.string() }).parse(data); 45 | }) 46 | .handler(async ({ data }) => { 47 | const index = todos.findIndex((t) => t.id === data.id); 48 | if (index !== -1) { 49 | todos.splice(index, 1); 50 | return { success: true }; 51 | } 52 | throw new Error('Todo not found'); 53 | }); 54 | -------------------------------------------------------------------------------- /src/routes/.ruler/tanstack-server-routes.md: -------------------------------------------------------------------------------- 1 | # Server Routes — TanStack Start 2 | 3 | Server HTTP endpoints for requests, forms, auth. Location: ./src/routes. Export Route to create API route. ServerRoute and Route can coexist in same file. 4 | 5 | Routing mirrors TanStack Router: dynamic $id, splat $, escaped [.], nested dirs/dotted filenames map to paths. One handler per resolved path (duplicates error). Examples: users.ts → /users; users/$id.ts → /users/$id; api/file/$.ts → /api/file/$; my-script[.]js.ts → /my-script.js. 6 | 7 | Middleware: pathless layout routes add group middleware; break-out routes skip parents. 8 | 9 | RC1 server entry signature: export default { fetch(req: Request): Promise { ... } } 10 | 11 | Define handlers: use createFileRoute() from @tanstack/react-router with server: { handlers: { ... } }. Methods per HTTP verb, with optional middleware builder. createServerFileRoute removed in RC1; use createFileRoute with server property. 12 | 13 | Handler receives { request, params, context }; return Response or Promise. Helpers from @tanstack/react-start allowed. 14 | 15 | Bodies: request.json(), request.text(), request.formData() for POST/PUT/PATCH/DELETE. 16 | 17 | JSON/status/headers: return JSON manually or via json(); set status via Response init or setResponseStatus(); set headers via Response init or setHeaders(). 18 | 19 | Params: /users/$id → params.id; /users/$id/posts/$postId → params.id + params.postId; /file/$ → params._splat. 20 | 21 | Unique path rule: one file per resolved path; users.ts vs users.index.ts vs users/index.ts conflicts. 22 | 23 | RC1 structure: 24 | ```typescript 25 | import { createFileRoute } from '@tanstack/react-router' 26 | 27 | export const Route = createFileRoute('/api/example')({ 28 | server: { 29 | handlers: { 30 | GET: ({ request }) => new Response('Hello'), 31 | POST: ({ request }) => new Response('Created', { status: 201 }) 32 | } 33 | } 34 | }) 35 | ``` 36 | -------------------------------------------------------------------------------- /src/routes/(marketing)/docs.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router' 2 | export const Route = createFileRoute('/(marketing)/docs')({ 3 | component: DocsPage 4 | }) 5 | 6 | function DocsPage() { 7 | return ( 8 |
9 |

Documentation

10 |
11 |

12 | Welcome to the documentation for the TanStack Starter project. 13 |

14 | 15 |

Getting Started

16 |

17 | This starter template provides a modern foundation for building web applications 18 | with TanStack Router, React Query, and other powerful tools. 19 |

20 | 21 |

Features

22 |
    23 |
  • Type-safe routing with TanStack Router
  • 24 |
  • Server-side rendering (SSR) support
  • 25 |
  • Dark mode with theme persistence
  • 26 |
  • Tailwind CSS for styling
  • 27 |
  • TypeScript for type safety
  • 28 |
29 | 30 |

Project Structure

31 |
32 |                     {`src/
33 | ├── components/     # Reusable UI components
34 | ├── routes/         # Route definitions
35 | ├── styles/         # Global styles
36 | ├── lib/           # Utility functions
37 | └── utils/         # Helper utilities`}
38 |                 
39 |
40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/todos/queries.ts: -------------------------------------------------------------------------------- 1 | import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'; 2 | import { getTodos, createTodo, toggleTodo, deleteTodo } from '~/server/function/todos'; 3 | import { toast } from 'sonner'; 4 | 5 | export type Todo = { id: string; text: string; completed: boolean }; 6 | 7 | export const todosQueries = { 8 | list: () => 9 | queryOptions({ 10 | queryKey: ['todos'], 11 | queryFn: async ({ signal }) => await getTodos({ signal }), 12 | staleTime: 1000 * 60 * 5, 13 | }), 14 | }; 15 | 16 | export function useCreateTodoMutation() { 17 | const queryClient = useQueryClient(); 18 | return useMutation({ 19 | mutationFn: async (text: string) => await createTodo({ data: { text } }), 20 | onSuccess: () => { 21 | queryClient.invalidateQueries({ queryKey: todosQueries.list().queryKey }); 22 | toast.success('Todo created successfully!'); 23 | }, 24 | onError: (error) => { 25 | toast.error(error.message || 'Failed to create todo'); 26 | }, 27 | }); 28 | } 29 | 30 | export function useToggleTodoMutation() { 31 | const queryClient = useQueryClient(); 32 | return useMutation({ 33 | mutationFn: async (id: string) => await toggleTodo({ data: { id } }), 34 | onSuccess: () => { 35 | queryClient.invalidateQueries({ queryKey: todosQueries.list().queryKey }); 36 | }, 37 | onError: (error) => { 38 | toast.error(error.message || 'Failed to toggle todo'); 39 | }, 40 | }); 41 | } 42 | 43 | export function useDeleteTodoMutation() { 44 | const queryClient = useQueryClient(); 45 | return useMutation({ 46 | mutationFn: async (id: string) => await deleteTodo({ data: { id } }), 47 | onSuccess: () => { 48 | queryClient.invalidateQueries({ queryKey: todosQueries.list().queryKey }); 49 | toast.success('Todo deleted successfully!'); 50 | }, 51 | onError: (error) => { 52 | toast.error(error.message || 'Failed to delete todo'); 53 | }, 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /src/routes/AGENTS.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | # Server Routes — TanStack Start 7 | 8 | Server HTTP endpoints for requests, forms, auth. Location: ./src/routes. Export Route to create API route. ServerRoute and Route can coexist in same file. 9 | 10 | Routing mirrors TanStack Router: dynamic $id, splat $, escaped [.], nested dirs/dotted filenames map to paths. One handler per resolved path (duplicates error). Examples: users.ts → /users; users/$id.ts → /users/$id; api/file/$.ts → /api/file/$; my-script[.]js.ts → /my-script.js. 11 | 12 | Middleware: pathless layout routes add group middleware; break-out routes skip parents. 13 | 14 | RC1 server entry signature: export default { fetch(req: Request): Promise { ... } } 15 | 16 | Define handlers: use createFileRoute() from @tanstack/react-router with server: { handlers: { ... } }. Methods per HTTP verb, with optional middleware builder. createServerFileRoute removed in RC1; use createFileRoute with server property. 17 | 18 | Handler receives { request, params, context }; return Response or Promise. Helpers from @tanstack/react-start allowed. 19 | 20 | Bodies: request.json(), request.text(), request.formData() for POST/PUT/PATCH/DELETE. 21 | 22 | JSON/status/headers: return JSON manually or via json(); set status via Response init or setResponseStatus(); set headers via Response init or setHeaders(). 23 | 24 | Params: /users/$id → params.id; /users/$id/posts/$postId → params.id + params.postId; /file/$ → params._splat. 25 | 26 | Unique path rule: one file per resolved path; users.ts vs users.index.ts vs users/index.ts conflicts. 27 | 28 | RC1 structure: 29 | ```typescript 30 | import { createFileRoute } from '@tanstack/react-router' 31 | 32 | export const Route = createFileRoute('/api/example')({ 33 | server: { 34 | handlers: { 35 | GET: ({ request }) => new Response('Hello'), 36 | POST: ({ request }) => new Response('Created', { status: 201 }) 37 | } 38 | } 39 | }) 40 | ``` 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | *.db-* 3 | *.local 4 | .DS_Store 5 | .cache 6 | .env 7 | .env.development 8 | .env.local 9 | .env.sentry-build-plugin 10 | .hidden 11 | .mastra 12 | .nitro 13 | .output 14 | .tanstack 15 | .tanstack-start 16 | .vercel 17 | .vinxi 18 | /api/ 19 | /blob-report/ 20 | /build/ 21 | /playwright-report/ 22 | /playwright/.cache/ 23 | /public/build 24 | /server/build 25 | /test-results/ 26 | (gen) 27 | dist 28 | dist-ssr 29 | node_modules 30 | output.txt 31 | package-lock.json 32 | yarn.lock 33 | src/routeTree.gen.ts 34 | .ck 35 | .spezi 36 | 37 | # START Ruler Generated Files 38 | /.codex/config.toml 39 | /.codex/config.toml.bak 40 | /.cursor/rules/ruler_cursor_instructions.mdc 41 | /.cursor/rules/ruler_cursor_instructions.mdc.bak 42 | /AGENTS.md 43 | /AGENTS.md.bak 44 | /CLAUDE.md 45 | /CLAUDE.md.bak 46 | /src/.codex/config.toml 47 | /src/.codex/config.toml.bak 48 | /src/.cursor/rules/ruler_cursor_instructions.mdc 49 | /src/.cursor/rules/ruler_cursor_instructions.mdc.bak 50 | /src/AGENTS.md 51 | /src/AGENTS.md.bak 52 | /src/CLAUDE.md 53 | /src/CLAUDE.md.bak 54 | /src/db/schema/.codex/config.toml 55 | /src/db/schema/.codex/config.toml.bak 56 | /src/db/schema/.cursor/rules/ruler_cursor_instructions.mdc 57 | /src/db/schema/.cursor/rules/ruler_cursor_instructions.mdc.bak 58 | /src/db/schema/AGENTS.md 59 | /src/db/schema/AGENTS.md.bak 60 | /src/db/schema/CLAUDE.md 61 | /src/db/schema/CLAUDE.md.bak 62 | /src/routes/.codex/config.toml 63 | /src/routes/.codex/config.toml.bak 64 | /src/routes/.cursor/rules/ruler_cursor_instructions.mdc 65 | /src/routes/.cursor/rules/ruler_cursor_instructions.mdc.bak 66 | /src/routes/AGENTS.md 67 | /src/routes/AGENTS.md.bak 68 | /src/routes/CLAUDE.md 69 | /src/routes/CLAUDE.md.bak 70 | /src/server/function/.codex/config.toml 71 | /src/server/function/.codex/config.toml.bak 72 | /src/server/function/.cursor/rules/ruler_cursor_instructions.mdc 73 | /src/server/function/.cursor/rules/ruler_cursor_instructions.mdc.bak 74 | /src/server/function/AGENTS.md 75 | /src/server/function/AGENTS.md.bak 76 | /src/server/function/CLAUDE.md 77 | /src/server/function/CLAUDE.md.bak 78 | # END Ruler Generated Files 79 | -------------------------------------------------------------------------------- /src/components/theme-init-script.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Decides the initial theme on first paint. 3 | * Order of precedence: 4 | * 1. localStorage("vite-ui-theme") – updated instantly on toggle 5 | * 2. "vite-ui-theme" cookie – updated on the next server round-trip 6 | * 3. OS preference via prefers-color-scheme 7 | * 8 | * Result: Adds either "light" or "dark" class to and ensures a 9 | * tag exists. 10 | */ 11 | export function ThemeInitScript() { 12 | const js = `(() => { 13 | try { 14 | const COOKIE = "vite-ui-theme"; 15 | // 1. Try localStorage first – instant client-side updates when the user toggles. 16 | // 2. Fallback to the cookie (updated on the next server round-trip). 17 | // This prevents a flicker where the cookie still contains the old value 18 | // between the client update and the (async) server response. 19 | let theme = null; 20 | 21 | try { 22 | theme = localStorage.getItem(COOKIE); 23 | } catch (_) {} 24 | 25 | if (!theme) { 26 | const match = document.cookie.match(new RegExp("(?:^|; )" + COOKIE + "=([^;]*)")); 27 | theme = match ? decodeURIComponent(match[1]) : null; 28 | } 29 | 30 | if (theme !== "light" && theme !== "dark") { 31 | theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; 32 | } 33 | 34 | const root = document.documentElement; 35 | root.classList.remove("light", "dark"); 36 | root.classList.add(theme); 37 | 38 | let meta = document.querySelector('meta[name="color-scheme"]'); 39 | if (!meta) { 40 | meta = document.createElement("meta"); 41 | meta.setAttribute("name", "color-scheme"); 42 | document.head.appendChild(meta); 43 | } 44 | meta.setAttribute("content", "light dark"); 45 | } catch (_) { /* never block page load */ } 46 | })();` 47 | 48 | // Children string executes while avoiding react/no-danger complaints. 49 | return ( 50 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useLayoutEffect, useState } from 'react'; 2 | import { type Theme, setTheme as setThemeServer } from '~/lib/theme'; 3 | 4 | const LS_KEY = 'vite-ui-theme'; 5 | type Ctx = { theme: Theme; setTheme: (t: Theme) => void }; 6 | 7 | const ThemeCtx = createContext(null); 8 | 9 | export function ThemeProvider({ 10 | initial, 11 | children, 12 | }: { 13 | initial: Theme; 14 | children: React.ReactNode; 15 | }) { 16 | // 1 Initialize with the server value to avoid hydration mismatch 17 | const [theme, setThemeState] = useState(initial); 18 | 19 | // 2 After mount, check localStorage and update if different 20 | useEffect(() => { 21 | const ls = localStorage.getItem(LS_KEY) as Theme | null; 22 | if (ls && ls !== initial) { 23 | setThemeState(ls); 24 | } 25 | }, [initial]); 26 | 27 | // 3 keep DOM and LS up to date 28 | useLayoutEffect(() => { 29 | const root = document.documentElement; 30 | root.classList.remove('light', 'dark'); 31 | 32 | const applied = 33 | theme === 'system' 34 | ? matchMedia('(prefers-color-scheme: dark)').matches 35 | ? 'dark' 36 | : 'light' 37 | : theme; 38 | 39 | root.classList.add(applied); 40 | localStorage.setItem(LS_KEY, theme); 41 | }, [theme]); 42 | 43 | // 3 listen to cross-tab changes 44 | useEffect(() => { 45 | const handler = (e: StorageEvent) => { 46 | if (e.key === LS_KEY && e.newValue) { 47 | const t = e.newValue as Theme; 48 | if (t !== theme) setThemeState(t); 49 | } 50 | }; 51 | window.addEventListener('storage', handler); 52 | return () => window.removeEventListener('storage', handler); 53 | }, [theme]); 54 | 55 | // 4 update both stores on toggle 56 | const setTheme = (next: Theme) => { 57 | setThemeState(next); 58 | localStorage.setItem(LS_KEY, next); 59 | setThemeServer({ data: next }); // persist cookie for future requests 60 | }; 61 | 62 | return {children}; 63 | } 64 | 65 | export const useTheme = () => { 66 | const ctx = useContext(ThemeCtx); 67 | if (!ctx) throw new Error('useTheme must be inside ThemeProvider'); 68 | return ctx; 69 | }; 70 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "~/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "~/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | //import { SignedIn, SignedOut, UserButton } from "@daveyplate/better-auth-ui" 2 | import { Link } from '@tanstack/react-router'; 3 | import { ModeToggle } from './mode-toggle'; 4 | 5 | export function Header() { 6 | return ( 7 |
8 |
9 | 10 | ex0 11 | 12 | 13 | 60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/.ruler/tanstack-query-rules.md: -------------------------------------------------------------------------------- 1 | # TanStack Query Rules 2 | 3 | Server state via TanStack Query + server functions. Type-safe fetching and mutations. 4 | 5 | ## Query Pattern 6 | 7 | Define in `lib/{resource}/queries.ts` using `queryOptions`: 8 | 9 | ```typescript 10 | export const todosQueryOptions = () => 11 | queryOptions({ 12 | queryKey: ['todos'], 13 | queryFn: async ({ signal }) => await getTodos({ signal }), 14 | staleTime: 1000 * 60 * 5, 15 | gcTime: 1000 * 60 * 10, 16 | }); 17 | ``` 18 | 19 | Use: `const { data, isLoading } = useQuery(todosQueryOptions())`. Prefer `useSuspenseQuery` with Suspense. 20 | 21 | ## Server Functions in Queries 22 | 23 | Call server functions directly in `queryFn`. No `useServerFn` hook. TanStack Start proxies. Pass `signal` for cancellation. 24 | 25 | ## Mutation Pattern 26 | 27 | ```typescript 28 | const mutation = useMutation({ 29 | mutationFn: async (text: string) => await createTodo({ data: { text } }), 30 | onSuccess: () => { 31 | queryClient.invalidateQueries({ queryKey: todosQueryOptions().queryKey }); 32 | toast.success('Success'); 33 | }, 34 | onError: (error) => toast.error(error.message || 'Failed'), 35 | }); 36 | ``` 37 | 38 | Call via `mutation.mutate(data)` or `mutateAsync` for promises. 39 | 40 | ## Query Invalidation 41 | 42 | After mutations: `queryClient.invalidateQueries({ queryKey: ... })`. Use specific keys, not broad. 43 | 44 | ## Mutation States 45 | 46 | Access: `isPending`, `isError`, `isSuccess`, `error`, `data`. Disable UI during `isPending`. 47 | 48 | ## Error Handling 49 | 50 | Handle in `onError`. Toast messages. Access: `error.message || 'Default'`. 51 | 52 | ## Query Keys 53 | 54 | Hierarchical: `['todos']`, `['todo', id]`, `['todos', 'completed']`. Include all affecting variables. 55 | 56 | ## Stale Time vs GC Time 57 | 58 | `staleTime`: freshness duration (no refetch). Default 0. Set for stable data. 59 | `gcTime`: unused cache duration (was `cacheTime`). Default 5min. Memory management. 60 | 61 | ## Infinite Queries 62 | 63 | `useInfiniteQuery` for pagination. Required: `initialPageParam`, `getNextPageParam`, `fetchNextPage`. Access `data.pages`. Check `hasNextPage` before fetching. 64 | 65 | ## Optimistic Updates 66 | 67 | `onMutate` for optimistic updates. Rollback in `onError`. Update cache via `queryClient.setQueryData`. 68 | 69 | ## Best Practices 70 | 71 | 1. Queries in `lib/{resource}/queries.ts` with `queryOptions` 72 | 2. Call server functions directly (no `useServerFn` in callbacks) 73 | 3. Invalidate after mutations 74 | 4. Toast for feedback 75 | 5. Handle loading/error states 76 | 6. Use TypeScript types from query options 77 | 7. Set `staleTime`/`gcTime` appropriately 78 | 8. Prefer `useSuspenseQuery` with Suspense 79 | -------------------------------------------------------------------------------- /.oxlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2022": true, 5 | "node": true 6 | }, 7 | "rules": { 8 | "no-console": ["warn", { "allow": ["error"] }], 9 | "no-debugger": "error", 10 | "no-unused-vars": "off", 11 | "@typescript-eslint/no-unused-vars": "warn", 12 | "react/react-in-jsx-scope": "off", 13 | "no-restricted-imports": [ 14 | "error", 15 | { 16 | "paths": [ 17 | { 18 | "name": "@tanstack/react-start/server", 19 | "importNames": ["createAPIFileRoute"], 20 | "message": "Use createFileRoute + server handlers instead." 21 | }, 22 | { 23 | "name": "@tanstack/react-start", 24 | "importNames": ["createAPIFileRoute"], 25 | "message": "Use createFileRoute + server handlers instead." 26 | }, 27 | { 28 | "name": "@tanstack/react-start/server", 29 | "importNames": ["createServerFileRoute"], 30 | "message": "Use createFileRoute + server handlers instead." 31 | }, 32 | { 33 | "name": "@tanstack/react-start", 34 | "importNames": ["serverOnly", "clientOnly"], 35 | "message": "Use createServerOnlyFn and createClientOnlyFn instead." 36 | }, 37 | { 38 | "name": "@tanstack/react-start/server", 39 | "importNames": [ 40 | "getWebRequest", 41 | "getHeaders", 42 | "getHeader", 43 | "setHeaders", 44 | "setHeader", 45 | "parseCookies" 46 | ], 47 | "message": "RC1 renamed: getWebRequest→getRequest, getHeaders→getRequestHeaders, getHeader→getRequestHeader, setHeaders→setResponseHeaders, setHeader→setResponseHeader, parseCookies→getCookies" 48 | } 49 | ], 50 | "patterns": ["*createAPIFileRoute*", "*createServerFileRoute*"] 51 | } 52 | ], 53 | "no-restricted-syntax": [ 54 | "error", 55 | { 56 | "selector": "ExportNamedDeclaration > ExportSpecifier[exported.name='APIRoute']", 57 | "message": "Use createFileRoute with server handlers, not legacy API routes." 58 | }, 59 | { 60 | "selector": "ExportNamedDeclaration > ExportSpecifier[exported.name='ServerRoute']", 61 | "message": "Use createFileRoute with server handlers, not legacy server routes." 62 | } 63 | ] 64 | }, 65 | "overrides": [ 66 | { 67 | "files": ["tests/**/*", "**/*.test.ts", "**/*.test.tsx", "cli/**/*"], 68 | "rules": { 69 | "no-console": "off" 70 | } 71 | } 72 | ], 73 | "ignorePatterns": [ 74 | "dist", 75 | "build", 76 | ".output", 77 | "node_modules", 78 | "*.config.js", 79 | "*.config.ts", 80 | ".tanstack", 81 | "routeTree.gen.ts" 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "constructa-starter-min", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite dev", 8 | "build": "vite build", 9 | "start": "node .output/server/index.mjs", 10 | "test": "vitest", 11 | "lint": "oxlint", 12 | "lint:fix": "oxlint --fix", 13 | "rules:apply": "ruler apply --nested", 14 | "prepare": "ruler apply --nested" 15 | }, 16 | "dependencies": { 17 | "@radix-ui/react-avatar": "^1.1.10", 18 | "@radix-ui/react-checkbox": "^1.3.2", 19 | "@radix-ui/react-dialog": "^1.1.14", 20 | "@radix-ui/react-dropdown-menu": "^2.1.15", 21 | "@radix-ui/react-label": "^2.1.7", 22 | "@radix-ui/react-select": "^2.2.5", 23 | "@radix-ui/react-separator": "^1.1.7", 24 | "@radix-ui/react-slot": "^1.2.3", 25 | "@radix-ui/react-switch": "^1.2.5", 26 | "@radix-ui/react-tabs": "^1.1.12", 27 | "@radix-ui/react-toggle": "^1.1.9", 28 | "@radix-ui/react-toggle-group": "^1.1.10", 29 | "@radix-ui/react-tooltip": "^1.2.7", 30 | "@tanstack/react-query": "^5.90.5", 31 | "@tanstack/react-query-devtools": "^5.89.0", 32 | "@tanstack/react-router": "^1.134.4", 33 | "@tanstack/react-router-devtools": "^1.134.4", 34 | "@tanstack/react-start": "^1.134.7", 35 | "@tanstack/react-table": "^8.21.3", 36 | "clsx": "^2.1.1", 37 | "framer-motion": "^12.23.24", 38 | "lucide-react": "^0.552.0", 39 | "motion": "^12.23.24", 40 | "nitro": "3.0.1-alpha.0", 41 | "react": "^19.2.0", 42 | "react-dom": "^19.2.0", 43 | "redaxios": "^0.5.1", 44 | "sonner": "^2.0.5", 45 | "tailwind-merge": "^3.3.0", 46 | "tailwindcss": "^4.1.16", 47 | "vaul": "^1.1.2", 48 | "vite": "^7.1.12", 49 | "zod": "^4.1.12" 50 | }, 51 | "devDependencies": { 52 | "@biomejs/biome": "^2.3.2", 53 | "@browser-echo/vite": "^1.1.0", 54 | "@iconify/json": "^2.2.402", 55 | "@intellectronica/ruler": "^0.3.11", 56 | "@tailwindcss/postcss": "^4.1.16", 57 | "@tailwindcss/vite": "^4.1.16", 58 | "@tanstack/config": "^0.22.0", 59 | "@tanstack/react-router-ssr-query": "^1.134.4", 60 | "@tanstack/react-router-with-query": "^1.130.17", 61 | "@testing-library/jest-dom": "^6.9.1", 62 | "@testing-library/react": "^16.3.0", 63 | "@types/node": "^24.9.2", 64 | "@types/pg": "^8.15.6", 65 | "@types/react": "^19.2.2", 66 | "@types/react-dom": "^19.2.2", 67 | "@vitejs/plugin-react": "^5.1.0", 68 | "autoprefixer": "^10.4.21", 69 | "@t3-oss/env-core": "^0.13.8", 70 | "class-variance-authority": "^0.7.1", 71 | "dotenv": "^17.2.3", 72 | "oxlint": "^1.25.0", 73 | "shadcn": "^3.5.0", 74 | "tailwindcss": "^4.1.6", 75 | "tw-animate-css": "^1.3.4", 76 | "typescript": "^5.9.3", 77 | "unplugin-icons": "^22.5.0", 78 | "vite-tsconfig-paths": "^5.1.4", 79 | "vitest": "^4.0.6" 80 | }, 81 | "packageManager": "pnpm@9.14.4+sha512.c8180b3fbe4e4bca02c94234717896b5529740a6cbadf19fa78254270403ea2f27d4e1d46a08a0f56c89b63dc8ebfd3ee53326da720273794e6200fcf0d184ab", 82 | "engines": { 83 | "node": ">=22.12" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /docs/avoid-useEffect-summary.md: -------------------------------------------------------------------------------- 1 | 2 | Don’t fetch or derive app state in useEffect. 3 | 4 | 1. Fetch on navigation via TanStack Router loaders (SSR + streaming). Optionally seed TanStack Query in the loader with queryClient.ensureQueryData. \[1] 5 | 2. Do server work on the server via TanStack Start Server Functions; after mutations call router.invalidate() and/or queryClient.invalidateQueries(). \[2] 6 | 3. Keep page/UI state in the URL with typed search params (validateSearch, Route.useSearch, navigate). \[3] 7 | 4. Reserve useEffect for real external side-effects only (DOM, subscriptions, analytics). \[4]\[6] 8 | 9 | # If your useEffect was doing X → Use Y 10 | 11 | Fetching on mount/params change → route loader (+ ensureQueryData). \[1] 12 | Submitting/mutating → Server Function → invalidate router/queries. \[2] 13 | Syncing UI to querystring → typed search params + navigate. \[3] 14 | Derived state → compute during render (useMemo only if expensive). \[4] 15 | Subscribing to external stores → useSyncExternalStore. \[5] 16 | DOM/non-React widgets/listeners → small useEffect/useLayoutEffect. \[6] 17 | 18 | # Idiomatic patterns (names only, no boilerplate) 19 | 20 | Loader: queryClient.ensureQueryData(queryOptions({ queryKey, queryFn })) → useSuspenseQuery reads hydrated cache. \[1] 21 | Mutation: createServerFn(...).handler(...) → onSuccess: qc.invalidateQueries, router.invalidate. Supports
for progressive enhancement. \[2] 22 | Search params as state: validateSearch → Route.useSearch → navigate({ search }). \[3] 23 | External store read: useSyncExternalStore(subscribe, getSnapshot). \[5] 24 | 25 | # Decision checklist 26 | 27 | Data needed at render → loader (defer/stream as needed). \[1] 28 | User changed data → Server Function → invalidate. \[2] 29 | Belongs in URL → typed search params. \[3] 30 | Purely derived → compute in render. \[4] 31 | External system only → useEffect/useLayoutEffect. \[6] 32 | SSR/SEO → loader-based fetching; configure streaming/deferred. \[7] 33 | 34 | # React 19 helpers 35 | 36 | useActionState for form pending/error/result (pairs with Server Functions or TanStack Form). \[8] 37 | use to suspend on promises (client or server). \[9] 38 | 39 | # Zustand in TanStack Start (where it fits) 40 | 41 | Use for client/UI/session and push-based domain state (theme, modals, wizards, optimistic UI, WebSocket buffers). Keep server data in loaders/Query. 42 | Per request store instance to avoid SSR leaks. Inject via Router context; provide with Wrap; dehydrate/hydrate via router.dehydrate/router.hydrate so snapshots stream with the page. After navigation resolution, clear transient UI (router.subscribe('onResolved', ...)). 43 | Mutations: do work in Server Function → optionally update store optimistically → router.invalidate to reconcile with loader data. 44 | Add persist middleware only for client/session state; avoid touching storage during SSR. 45 | Use atomic selectors (useStore(s => slice)) and equality helpers to limit re-renders. 46 | 47 | Docs map: \[1] Router data loading, \[2] Server Functions, \[3] Search Params, \[4] You Might Not Need an Effect, \[5] useSyncExternalStore, \[6] Synchronizing with Effects, \[7] SSR, \[8] useActionState, \[9] use. -------------------------------------------------------------------------------- /src/server/function/.ruler/tanstack-server-fn.md: -------------------------------------------------------------------------------- 1 | # TanStack Server Functions 2 | 3 | Server-only logic callable anywhere (loaders, hooks, components, routes, client). File top level. No stable public URL. Access request context, headers/cookies, env secrets. Return primitives/JSON/Response, throw redirect/notFound. Framework-agnostic HTTP, no serial bottlenecks. 4 | 5 | How it works: Server bundle executes. Client strips and proxies via fetch. RPC but isomorphic. Middleware supported. 6 | 7 | Import: import { createServerFn } from '@tanstack/react-start' 8 | 9 | Define: createServerFn({ method: 'GET'|'POST' }).handler(...). Callable from server/client/other server functions. RC1: response modes removed; return Response object for custom behavior. 10 | 11 | Params: single param may be primitive, Array, Object, FormData, ReadableStream, Promise. Typical { data, signal? }. 12 | 13 | Validation: .inputValidator enforces runtime input, drives types. Works with Zod. Transformed output → ctx.data. Identity validator for typed I/O without checks. Use .inputValidator() not deprecated .validator(). 14 | 15 | JSON/FormData: supports JSON. FormData requires encType="multipart/form-data". 16 | 17 | Context (from @tanstack/react-start/server, h3): RC1 renames: getWebRequest→getRequest, getHeaders→getRequestHeaders, getHeader→getRequestHeader, setHeaders→setResponseHeaders, setHeader→setResponseHeader, parseCookies→getCookies. Available: getRequest, getRequestHeaders|getRequestHeader, setResponseHeader, setResponseStatus, getCookies, sessions, multipart, custom context. 18 | 19 | Returns: primitives/JSON, redirect/notFound, or Response. Return Response directly for custom. 20 | 21 | Errors: thrown errors → 500 JSON; catch as needed. 22 | 23 | Cancellation: AbortSignal supported. Server notified on disconnect. 24 | 25 | Integration: route lifecycles auto-handle redirect/notFound. Components use useServerFn. Elsewhere handle manually. 26 | 27 | Redirects: use redirect from @tanstack/react-router with to|href, status, headers, path/search/hash/params. SSR: 302. Client auto-handles. Don't use sendRedirect. 28 | 29 | Not Found: use notFound() for router 404 in lifecycles. 30 | 31 | No-JS: execute via HTML form with serverFn.url. Pass args via inputs. Use encType for multipart. Cannot read return value; redirect or reload via loader. 32 | 33 | Static functions: use staticFunctionMiddleware from @tanstack/start-static-server-functions. Must be final middleware. Caches build-time as static JSON (key: function ID+params hash). Used in prerender/hydration. Client fetches static JSON. Default cache: fs+fetch. Override: createServerFnStaticCache + setServerFnStaticCache. 34 | 35 | Example: 36 | ```typescript 37 | import { createServerFn } from '@tanstack/react-start' 38 | import { staticFunctionMiddleware } from '@tanstack/start-static-server-functions' 39 | 40 | const myServerFn = createServerFn({ method: 'GET' }) 41 | .middleware([staticFunctionMiddleware]) 42 | .handler(async () => 'Hello, world!') 43 | ``` 44 | 45 | Compilation: injects use server if missing. Client extracts to server bundle, proxies. Server runs as-is. Dead-code elimination. 46 | 47 | Notes: inspired by tRPC. Always invoke normalizeInput(schema, preprocess?) inside handler. Don't rely on .validator(). When writing preprocess, unwrap wrappers ({ data: ... }, SuperJSON $values, stringified arrays) so validation runs on real payload. 48 | -------------------------------------------------------------------------------- /src/.ruler/tanstack-environment-server-client-only-rules.md: -------------------------------------------------------------------------------- 1 | # ClientOnly 2 | 3 | Client-only render to avoid SSR hydration issues. Import from `@tanstack/react-router`: 4 | 5 | ```typescript 6 | import { ClientOnly } from '@tanstack/react-router'; 7 | 8 | —}> 9 | 10 | 11 | ``` 12 | 13 | Alternative: Custom implementation using mounted pattern if needed (see hydration errors below). 14 | 15 | # Environment functions 16 | 17 | From `@tanstack/react-start`: 18 | 19 | ## createIsomorphicFn 20 | 21 | Adapts to client/server: 22 | 23 | ```typescript 24 | import { createIsomorphicFn } from '@tanstack/react-start'; 25 | const getEnv = createIsomorphicFn() 26 | .server(() => 'server') 27 | .client(() => 'client'); 28 | getEnv(); // 'server' on server, 'client' on client 29 | ``` 30 | 31 | Partial: `.server()` no-op on client, `.client()` no-op on server. 32 | 33 | ## createServerOnlyFn / createClientOnlyFn 34 | 35 | RC1: `serverOnly` → `createServerOnlyFn`, `clientOnly` → `createClientOnlyFn` 36 | 37 | Strict environment execution (throws if called wrong env): 38 | 39 | ```typescript 40 | import { createServerOnlyFn, createClientOnlyFn } from '@tanstack/react-start'; 41 | const serverFn = createServerOnlyFn(() => 'bar'); // throws on client 42 | const clientFn = createClientOnlyFn(() => 'bar'); // throws on server 43 | ``` 44 | 45 | Tree-shaken: client code removed from server bundle, server code removed from client bundle. 46 | 47 | # Hydration errors 48 | 49 | Mismatch: Server HTML differs from client render. Common causes: Intl (locale/timezone), Date.now(), random IDs, responsive logic, feature flags, user prefs. 50 | 51 | Strategies: 52 | 1. Make server and client match: deterministic locale/timezone on server (cookie or Accept-Language header), compute once and hydrate as initial state. 53 | 2. Let client tell environment: set cookie with client timezone on first visit, SSR uses UTC until then. 54 | 3. Make it client-only: wrap unstable UI in `` to avoid SSR mismatches. 55 | 4. Disable/limit SSR: use selective SSR (`ssr: 'data-only'` or `false`) when server HTML cannot be stable. 56 | 5. Last resort: React's `suppressHydrationWarning` for small known-different nodes (use sparingly). 57 | 58 | Checklist: Deterministic inputs (locale, timezone, feature flags). Prefer cookies for client context. Use `` for dynamic UI. Use selective SSR when server HTML unstable. Avoid blind suppression. 59 | 60 | # TanStack Start basics 61 | 62 | Depends: @tanstack/react-router, Vite. Router: getRouter() (was createRouter() in beta). routeTree.gen.ts auto-generated on first dev run. Optional: server handler via @tanstack/react-start/server; client hydrate via StartClient from @tanstack/react-start/client. RC1: Import StartClient from @tanstack/react-start/client (not @tanstack/react-start). StartClient no longer requires router prop. Root route head: utf-8, viewport, title; component wraps Outlet in RootDocument. Routes: createFileRoute() code-split + lazy-load; loader runs server/client. Navigation: Link (typed), useNavigate (imperative), useRouter (instance). 63 | 64 | # Server functions 65 | 66 | createServerFn({ method }) + zod .inputValidator + .handler(ctx). After mutations: router.invalidate(); queryClient.invalidateQueries(['entity', id]). 67 | 68 | # Typed Links 69 | 70 | Link to="/posts/$postId" with params; activeProps for styling. 71 | -------------------------------------------------------------------------------- /src/server/function/AGENTS.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | # TanStack Server Functions 7 | 8 | Server-only logic callable anywhere (loaders, hooks, components, routes, client). File top level. No stable public URL. Access request context, headers/cookies, env secrets. Return primitives/JSON/Response, throw redirect/notFound. Framework-agnostic HTTP, no serial bottlenecks. 9 | 10 | How it works: Server bundle executes. Client strips and proxies via fetch. RPC but isomorphic. Middleware supported. 11 | 12 | Import: import { createServerFn } from '@tanstack/react-start' 13 | 14 | Define: createServerFn({ method: 'GET'|'POST' }).handler(...). Callable from server/client/other server functions. RC1: response modes removed; return Response object for custom behavior. 15 | 16 | Params: single param may be primitive, Array, Object, FormData, ReadableStream, Promise. Typical { data, signal? }. 17 | 18 | Validation: .inputValidator enforces runtime input, drives types. Works with Zod. Transformed output → ctx.data. Identity validator for typed I/O without checks. Use .inputValidator() not deprecated .validator(). 19 | 20 | JSON/FormData: supports JSON. FormData requires encType="multipart/form-data". 21 | 22 | Context (from @tanstack/react-start/server, h3): RC1 renames: getWebRequest→getRequest, getHeaders→getRequestHeaders, getHeader→getRequestHeader, setHeaders→setResponseHeaders, setHeader→setResponseHeader, parseCookies→getCookies. Available: getRequest, getRequestHeaders|getRequestHeader, setResponseHeader, setResponseStatus, getCookies, sessions, multipart, custom context. 23 | 24 | Returns: primitives/JSON, redirect/notFound, or Response. Return Response directly for custom. 25 | 26 | Errors: thrown errors → 500 JSON; catch as needed. 27 | 28 | Cancellation: AbortSignal supported. Server notified on disconnect. 29 | 30 | Integration: route lifecycles auto-handle redirect/notFound. Components use useServerFn. Elsewhere handle manually. 31 | 32 | Redirects: use redirect from @tanstack/react-router with to|href, status, headers, path/search/hash/params. SSR: 302. Client auto-handles. Don't use sendRedirect. 33 | 34 | Not Found: use notFound() for router 404 in lifecycles. 35 | 36 | No-JS: execute via HTML form with serverFn.url. Pass args via inputs. Use encType for multipart. Cannot read return value; redirect or reload via loader. 37 | 38 | Static functions: use staticFunctionMiddleware from @tanstack/start-static-server-functions. Must be final middleware. Caches build-time as static JSON (key: function ID+params hash). Used in prerender/hydration. Client fetches static JSON. Default cache: fs+fetch. Override: createServerFnStaticCache + setServerFnStaticCache. 39 | 40 | Example: 41 | ```typescript 42 | import { createServerFn } from '@tanstack/react-start' 43 | import { staticFunctionMiddleware } from '@tanstack/start-static-server-functions' 44 | 45 | const myServerFn = createServerFn({ method: 'GET' }) 46 | .middleware([staticFunctionMiddleware]) 47 | .handler(async () => 'Hello, world!') 48 | ``` 49 | 50 | Compilation: injects use server if missing. Client extracts to server bundle, proxies. Server runs as-is. Dead-code elimination. 51 | 52 | Notes: inspired by tRPC. Always invoke normalizeInput(schema, preprocess?) inside handler. Don't rely on .validator(). When writing preprocess, unwrap wrappers ({ data: ... }, SuperJSON $values, stringified arrays) so validation runs on real payload. 53 | -------------------------------------------------------------------------------- /docs/tanstack-rc1-upgrade-guide.md: -------------------------------------------------------------------------------- 1 | # TanStack Start RC1 Upgrade Guide 2 | 3 | This guide captures the mandatory changes and local patches we applied while upgrading the project to TanStack Start RC1 (router v1.132.x). 4 | 5 | ## Platform Requirements 6 | - Node.js **>= 22.12** (enforced via `package.json` / engines). 7 | - Vite **>= 7**. Install `@vitejs/plugin-react` (or the matching framework plugin) manually; the Start plugin no longer autoconfigures React/Solid. 8 | 9 | ## Vite Configuration 10 | - `tanstackStart()` options renamed: 11 | - `tsr` → `router` for the virtual route config. 12 | - `srcDirectory` moved to the top level of the plugin options. 13 | - Wrap `defineConfig` with a factory and call `loadEnv(mode, process.cwd(), '')`, then `Object.assign(process.env, ...)`. This restores the pre-RC behaviour where all `.env` keys are exposed (RC1 regression currently filters out non-`VITE_` prefixes). 14 | - Ensure `tanstackStart()` is registered **before** `viteReact()` in the plugin array. The RC1 router plugin throws if React runs first. 15 | - Continue including `viteReact()`, `tailwindcss()`, and other project plugins explicitly. 16 | 17 | ## Router Entry 18 | - `createRouter` export renamed to `getRouter`. Update module augmentation to reference `ReturnType`. 19 | - Initialising any browser-only tooling (like Browser Echo) should be wrapped in `if (typeof window !== 'undefined')` to keep SSR builds safe. 20 | - Route tree generation now emits the module declaration automatically; remove any manual declarations in `routeTree.gen.ts`. 21 | 22 | ## Server Functions & Helpers 23 | - `.validator()` → `.inputValidator()`. 24 | - `getWebRequest` → `getRequest`, `getHeaders` → `getRequestHeaders`, etc. Apply the full set of renames listed in `docs/tasks/03-upgrade-tanstack-rc1.md`. 25 | - Response modes were removed—return a `Response` directly when needed. 26 | - Keep shared types (e.g. `Theme`) exported from server modules so route loaders and components can import them without circular dependencies. 27 | 28 | ## API Routes 29 | - Replace `createServerFileRoute` with `createFileRoute` and wrap server handlers inside `server: { handlers: { ... } }`. 30 | 31 | ## Global Middleware 32 | - `registerGlobalMiddleware` was removed. Create `src/start.ts` and export `startInstance = createStart(async () => ({ ... }))`, registering request/function middleware there. 33 | - Harden `src/utils/loggingMiddleware.tsx`: drop the `{ type: 'function' }` option and guard every context read before logging timings so RC1's reordered execution doesn't crash the client. 34 | 35 | ## Client Entry 36 | - Import `StartClient` from `@tanstack/react-start/client` and render `` without the router prop. 37 | - Add `src/entry-client.tsx` that hydrates `` via `startTransition()` and `StrictMode`. 38 | 39 | ## Known Regressions / Local Patches 40 | - **Env loading**: Add the `loadEnv(..., '', )` workaround in `vite.config.ts` until the upstream fix lands. 41 | - **Logging middleware**: Use the guarded implementation noted above; RC1 sometimes runs the server middleware before the client context exists. 42 | - **Root Route Devtools**: Mount `` and `` behind `import.meta.env.DEV`, and ensure the root route imports any loader types it returns. 43 | 44 | ## Validation Steps 45 | - Rebuild after changes: `pnpm vite build` (confirms route tree generation and SSR build succeed). 46 | - Verify the dev server launches without env validation errors or blank screens. 47 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.0.0] - 2025-09-25 9 | 10 | ### Added 11 | - **TanStack Start RC1** - Full-stack React framework with modern routing and server functions 12 | - **React 19** - Latest React version with concurrent features and improved performance 13 | - **Tailwind CSS v4** - Modern utility-first CSS framework with enhanced performance 14 | - **shadcn/ui Integration** - Beautiful, accessible component library with pre-configured components: 15 | - Button, Card, Dropdown Menu components 16 | - Radix UI primitives for advanced interactions 17 | - **Browser Echo** - Advanced client-side logging and debugging tool with Vite integration 18 | - **Unplugin Icons** - Automatic icon loading and optimization system 19 | - **TypeScript Support** - Full type safety with strict configuration and path aliases 20 | - **Modern Development Tools**: 21 | - Biome for fast code formatting and linting 22 | - Oxlint for additional linting rules 23 | - Vitest for unit testing with modern API 24 | - **TanStack Ecosystem**: 25 | - TanStack Query for server state management 26 | - TanStack Table for advanced data table functionality 27 | - TanStack Router Devtools for development debugging 28 | - **UI/UX Enhancements**: 29 | - Framer Motion for smooth animations 30 | - Lucide React icons for consistent iconography 31 | - Sonner for toast notifications 32 | - Theme provider with dark/light mode support 33 | - Gradient orb component for visual appeal 34 | - **Developer Experience**: 35 | - File-based routing with automatic route generation 36 | - Path aliases (`~` resolves to root `./src`) 37 | - Hot module replacement with Vite 38 | - Environment variable management 39 | - SEO utilities for meta tags and structured data 40 | 41 | ### Technical Features 42 | - **Server-Side Rendering (SSR)** - Built-in SSR with TanStack Start 43 | - **API Routes** - File-based API route handling 44 | - **Middleware System** - Request and function middleware support 45 | - **Error Boundaries** - Comprehensive error handling with custom boundaries 46 | - **Build Optimization** - Production-ready build with code splitting and tree shaking 47 | - **Security** - Modern security practices with proper environment variable handling 48 | 49 | ### Infrastructure 50 | - **Vite 7** - Fast build tool with modern bundling 51 | - **Node.js 22.12+** - Minimum Node.js version requirement 52 | - **PNPM** - Fast package manager with strict dependency resolution 53 | - **Docker Support** - Containerization ready (docker-compose) 54 | 55 | ### Documentation 56 | - **Comprehensive README** - Setup instructions, project structure, and deployment guide 57 | - **TanStack RC1 Upgrade Guide** - Migration documentation for framework updates 58 | 59 | ### Dependencies 60 | - **Core Framework**: `@tanstack/react-start@^1.132.6`, `react@^19.1.0` 61 | - **Styling**: `tailwindcss@^4.1.8`, `@tailwindcss/vite@^4.1.8` 62 | - **UI Components**: Multiple Radix UI packages for accessible primitives 63 | - **Development**: `@browser-echo/vite@^1.1.0`, `unplugin-icons@^22.3.0`, `@biomejs/biome@1.9.4` 64 | 65 | ### Breaking Changes 66 | - Requires Node.js >= 22.12 67 | - Uses TanStack Start RC1 API (may change before stable release) 68 | - Path aliases changed from `@` to `~` for consistency 69 | 70 | ### Performance 71 | - Optimized bundle size with tree shaking 72 | - Fast development server with Vite HMR 73 | - Efficient CSS processing with Tailwind v4 74 | - Icon optimization through unplugin-icons 75 | 76 | ### Known Issues 77 | - TanStack Start RC1 is pre-release software - some APIs may change 78 | - Browser Echo logging requires manual initialization for SSR compatibility 79 | -------------------------------------------------------------------------------- /src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import type { QueryClient } from "@tanstack/react-query" 2 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools" 3 | import { HeadContent, Outlet, Scripts, createRootRouteWithContext } from "@tanstack/react-router" 4 | import { TanStackRouterDevtools } from "@tanstack/react-router-devtools" 5 | import type * as React from "react" 6 | import { Toaster } from "sonner" 7 | import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary" 8 | import { NotFound } from "~/components/NotFound" 9 | import { ThemeInitScript } from "~/components/theme-init-script" 10 | import { ThemeProvider } from "~/components/theme-provider" 11 | import { getTheme } from "~/lib/theme" 12 | import type { Theme } from "~/lib/theme" 13 | import { seo } from "~/utils/seo" 14 | import appCss from "../styles/app.css?url" 15 | import customCss from "../styles/custom.css?url" 16 | 17 | export const Route = createRootRouteWithContext<{ 18 | queryClient: QueryClient 19 | }>()({ 20 | loader: () => getTheme(), 21 | head: () => ({ 22 | meta: [ 23 | { 24 | charSet: "utf-8" 25 | }, 26 | { 27 | name: "viewport", 28 | content: "width=device-width, initial-scale=1" 29 | }, 30 | ...seo({ 31 | title: "Instructa Start", 32 | description: "Instructa App Starter" 33 | }) 34 | ], 35 | links: [ 36 | { 37 | rel: "stylesheet", 38 | href: appCss 39 | }, 40 | { 41 | rel: "stylesheet", 42 | href: customCss 43 | }, 44 | { 45 | rel: "apple-touch-icon", 46 | sizes: "180x180", 47 | href: "/apple-touch-icon.png" 48 | }, 49 | { 50 | rel: "icon", 51 | type: "image/png", 52 | sizes: "32x32", 53 | href: "/favicon-32x32.png" 54 | }, 55 | { 56 | rel: "icon", 57 | type: "image/png", 58 | sizes: "16x16", 59 | href: "/favicon-16x16.png" 60 | }, 61 | { rel: "manifest", href: "/site.webmanifest", color: "#fffff" }, 62 | { rel: "icon", href: "/favicon.ico" } 63 | ] 64 | }), 65 | errorComponent: (props) => { 66 | return ( 67 | 68 | 69 | 70 | ) 71 | }, 72 | notFoundComponent: () => , 73 | component: RootComponent 74 | }) 75 | 76 | function RootComponent() { 77 | return ( 78 | 79 | 80 | {import.meta.env.DEV ? ( 81 | <> 82 | 83 | 84 | 85 | ) : null} 86 | 87 | ) 88 | } 89 | 90 | function RootDocument({ children }: { children: React.ReactNode }) { 91 | const initial = Route.useLoaderData() as Theme 92 | return ( 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 |
{children}
101 | 102 |
103 | 104 | 105 | 106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 |

Constructa Starter Min

4 |

A modern Web App Starter Kit based on Tanstack Starter using React, shadcn/ui and Tailwind CSS 4

5 | 6 | [![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white)](https://typescriptlang.org/) 7 | [![React](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB)](https://reactjs.org/) 8 | [![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white)](https://tailwindcss.com/) 9 |
10 | 11 | ## ✨ Features 12 | 13 | - **[TanStack Start RC1](https://tanstack.com/start)** - Modern full-stack React framework 14 | - **[shadcn/ui](https://ui.shadcn.com/)** - Beautiful, accessible component library 15 | - **[Tailwind CSS v4](https://tailwindcss.com/)** - Modern utility-first CSS framework 16 | - **[TypeScript](https://typescriptlang.org/)** - Full type safety 17 | - **[TanStack Router](https://tanstack.com/router)** - Type-safe file-based routing 18 | - **[Browser Echo](https://github.com/browser-echo/browser-echo)** - Advanced client-side logging and debugging 19 | - **[Unplugin Icons](https://github.com/antfu/unplugin-icons)** - Automatic icon loading and optimization 20 | 21 | ## 🚀 Quick Start 22 | 23 | ### Prerequisites 24 | - **Node.js** 18+ 25 | - **pnpm** (recommended package manager) 26 | 27 | ### Download 28 | 29 | ```bash 30 | # Clone the starter template (replace with your repo) 31 | npx gitpick git@github.com:instructa/constructa-starter-min.git my-app 32 | cd my-app 33 | ``` 34 | 35 | > **Recommended:** This starter uses [gitpick](https://github.com/nrjdalal/gitpick) for easy cloning without `.git` directory, making it perfect for creating new projects from this template. 36 | 37 | ### Installation 38 | 39 | ```bash 40 | # Install dependencies 41 | pnpm install 42 | 43 | # Start development server 44 | pnpm dev 45 | ``` 46 | 47 | ### Available Scripts 48 | 49 | ```bash 50 | # Development 51 | pnpm dev # Start development server 52 | pnpm build # Build for production 53 | pnpm start # Start production server 54 | 55 | # Code Quality 56 | pnpm biome:check # Check code formatting and linting 57 | pnpm biome:fix:unsafe # Fix code issues (unsafe) 58 | ``` 59 | 60 | ## 📁 Project Structure 61 | 62 | ``` 63 | src/ 64 | ├── app/ 65 | │ ├── routes/ # File-based routing 66 | │ │ ├── __root.tsx # Root layout 67 | │ │ ├── index.tsx # Home page 68 | │ │ └── api/ # API routes 69 | │ └── styles/ # Global styles 70 | ├── components/ 71 | │ └── ui/ # shadcn/ui components 72 | └── utils/ # Utility functions 73 | ``` 74 | 75 | ## 🎯 Core Technologies 76 | 77 | | Technology | Purpose | Documentation | 78 | |------------|---------|---------------| 79 | | **TanStack Start RC1** | Full-stack framework | [Docs](https://tanstack.com/start) | 80 | | **shadcn/ui** | Component library | [Docs](https://ui.shadcn.com/) | 81 | | **Tailwind CSS v4** | Styling framework | [Docs](https://tailwindcss.com/) | 82 | | **TypeScript** | Type safety | [Docs](https://typescriptlang.org/) | 83 | | **Browser Echo** | Client-side logging | [Docs](https://github.com/browser-echo/browser-echo) | 84 | | **Unplugin Icons** | Icon optimization | [Docs](https://github.com/antfu/unplugin-icons) | 85 | 86 | ## 🔧 Configuration 87 | 88 | ### Adding shadcn/ui Components 89 | ```bash 90 | # Add new components 91 | npx shadcn@latest add button 92 | npx shadcn@latest add card 93 | npx shadcn@latest add input 94 | ``` 95 | 96 | ### Tailwind CSS 97 | - Uses Tailwind CSS v4 with modern CSS-first configuration 98 | - Configured in `app.config.ts` 99 | - Global styles in `src/app/styles/` 100 | 101 | ### TypeScript 102 | - **Path aliases**: `@` resolves to the root `./` directory 103 | - **Route files**: Must use `.tsx` extension 104 | 105 | ## 🚀 Deployment 106 | 107 | ### Build for Production 108 | ```bash 109 | pnpm build 110 | ``` 111 | 112 | ### Start Production Server 113 | ```bash 114 | pnpm start 115 | ``` 116 | 117 | ## 📄 License 118 | 119 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 120 | 121 | --- 122 | 123 |
124 |

Built with ❤️ using modern React tools

125 |
126 | 127 | 128 | -------------------------------------------------------------------------------- /src/AGENTS.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | # ClientOnly 7 | 8 | Client-only render to avoid SSR hydration issues. Import from `@tanstack/react-router`: 9 | 10 | ```typescript 11 | import { ClientOnly } from '@tanstack/react-router'; 12 | 13 | —}> 14 | 15 | 16 | ``` 17 | 18 | Alternative: Custom implementation using mounted pattern if needed (see hydration errors below). 19 | 20 | # Environment functions 21 | 22 | From `@tanstack/react-start`: 23 | 24 | ## createIsomorphicFn 25 | 26 | Adapts to client/server: 27 | 28 | ```typescript 29 | import { createIsomorphicFn } from '@tanstack/react-start'; 30 | const getEnv = createIsomorphicFn() 31 | .server(() => 'server') 32 | .client(() => 'client'); 33 | getEnv(); // 'server' on server, 'client' on client 34 | ``` 35 | 36 | Partial: `.server()` no-op on client, `.client()` no-op on server. 37 | 38 | ## createServerOnlyFn / createClientOnlyFn 39 | 40 | RC1: `serverOnly` → `createServerOnlyFn`, `clientOnly` → `createClientOnlyFn` 41 | 42 | Strict environment execution (throws if called wrong env): 43 | 44 | ```typescript 45 | import { createServerOnlyFn, createClientOnlyFn } from '@tanstack/react-start'; 46 | const serverFn = createServerOnlyFn(() => 'bar'); // throws on client 47 | const clientFn = createClientOnlyFn(() => 'bar'); // throws on server 48 | ``` 49 | 50 | Tree-shaken: client code removed from server bundle, server code removed from client bundle. 51 | 52 | # Hydration errors 53 | 54 | Mismatch: Server HTML differs from client render. Common causes: Intl (locale/timezone), Date.now(), random IDs, responsive logic, feature flags, user prefs. 55 | 56 | Strategies: 57 | 1. Make server and client match: deterministic locale/timezone on server (cookie or Accept-Language header), compute once and hydrate as initial state. 58 | 2. Let client tell environment: set cookie with client timezone on first visit, SSR uses UTC until then. 59 | 3. Make it client-only: wrap unstable UI in `` to avoid SSR mismatches. 60 | 4. Disable/limit SSR: use selective SSR (`ssr: 'data-only'` or `false`) when server HTML cannot be stable. 61 | 5. Last resort: React's `suppressHydrationWarning` for small known-different nodes (use sparingly). 62 | 63 | Checklist: Deterministic inputs (locale, timezone, feature flags). Prefer cookies for client context. Use `` for dynamic UI. Use selective SSR when server HTML unstable. Avoid blind suppression. 64 | 65 | # TanStack Start basics 66 | 67 | Depends: @tanstack/react-router, Vite. Router: getRouter() (was createRouter() in beta). routeTree.gen.ts auto-generated on first dev run. Optional: server handler via @tanstack/react-start/server; client hydrate via StartClient from @tanstack/react-start/client. RC1: Import StartClient from @tanstack/react-start/client (not @tanstack/react-start). StartClient no longer requires router prop. Root route head: utf-8, viewport, title; component wraps Outlet in RootDocument. Routes: createFileRoute() code-split + lazy-load; loader runs server/client. Navigation: Link (typed), useNavigate (imperative), useRouter (instance). 68 | 69 | # Server functions 70 | 71 | createServerFn({ method }) + zod .inputValidator + .handler(ctx). After mutations: router.invalidate(); queryClient.invalidateQueries(['entity', id]). 72 | 73 | # Typed Links 74 | 75 | Link to="/posts/$postId" with params; activeProps for styling. 76 | 77 | 78 | 79 | 80 | 81 | # TanStack Query Rules 82 | 83 | Server state via TanStack Query + server functions. Type-safe fetching and mutations. 84 | 85 | ## Query Pattern 86 | 87 | Define in `lib/{resource}/queries.ts` using `queryOptions`: 88 | 89 | ```typescript 90 | export const todosQueryOptions = () => 91 | queryOptions({ 92 | queryKey: ['todos'], 93 | queryFn: async ({ signal }) => await getTodos({ signal }), 94 | staleTime: 1000 * 60 * 5, 95 | gcTime: 1000 * 60 * 10, 96 | }); 97 | ``` 98 | 99 | Use: `const { data, isLoading } = useQuery(todosQueryOptions())`. Prefer `useSuspenseQuery` with Suspense. 100 | 101 | ## Server Functions in Queries 102 | 103 | Call server functions directly in `queryFn`. No `useServerFn` hook. TanStack Start proxies. Pass `signal` for cancellation. 104 | 105 | ## Mutation Pattern 106 | 107 | ```typescript 108 | const mutation = useMutation({ 109 | mutationFn: async (text: string) => await createTodo({ data: { text } }), 110 | onSuccess: () => { 111 | queryClient.invalidateQueries({ queryKey: todosQueryOptions().queryKey }); 112 | toast.success('Success'); 113 | }, 114 | onError: (error) => toast.error(error.message || 'Failed'), 115 | }); 116 | ``` 117 | 118 | Call via `mutation.mutate(data)` or `mutateAsync` for promises. 119 | 120 | ## Query Invalidation 121 | 122 | After mutations: `queryClient.invalidateQueries({ queryKey: ... })`. Use specific keys, not broad. 123 | 124 | ## Mutation States 125 | 126 | Access: `isPending`, `isError`, `isSuccess`, `error`, `data`. Disable UI during `isPending`. 127 | 128 | ## Error Handling 129 | 130 | Handle in `onError`. Toast messages. Access: `error.message || 'Default'`. 131 | 132 | ## Query Keys 133 | 134 | Hierarchical: `['todos']`, `['todo', id]`, `['todos', 'completed']`. Include all affecting variables. 135 | 136 | ## Stale Time vs GC Time 137 | 138 | `staleTime`: freshness duration (no refetch). Default 0. Set for stable data. 139 | `gcTime`: unused cache duration (was `cacheTime`). Default 5min. Memory management. 140 | 141 | ## Infinite Queries 142 | 143 | `useInfiniteQuery` for pagination. Required: `initialPageParam`, `getNextPageParam`, `fetchNextPage`. Access `data.pages`. Check `hasNextPage` before fetching. 144 | 145 | ## Optimistic Updates 146 | 147 | `onMutate` for optimistic updates. Rollback in `onError`. Update cache via `queryClient.setQueryData`. 148 | 149 | ## Best Practices 150 | 151 | 1. Queries in `lib/{resource}/queries.ts` with `queryOptions` 152 | 2. Call server functions directly (no `useServerFn` in callbacks) 153 | 3. Invalidate after mutations 154 | 4. Toast for feedback 155 | 5. Handle loading/error states 156 | 6. Use TypeScript types from query options 157 | 7. Set `staleTime`/`gcTime` appropriately 158 | 8. Prefer `useSuspenseQuery` with Suspense 159 | -------------------------------------------------------------------------------- /src/routes/(marketing)/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router'; 2 | import GradientOrb from '~/components/gradient-orb'; 3 | import { useState } from 'react'; 4 | import { useSuspenseQuery } from '@tanstack/react-query'; 5 | import { 6 | todosQueries, 7 | useCreateTodoMutation, 8 | useToggleTodoMutation, 9 | useDeleteTodoMutation, 10 | type Todo, 11 | } from '~/lib/todos/queries'; 12 | import { Button } from '~/components/ui/button'; 13 | import axios from 'redaxios'; 14 | import { toast } from 'sonner'; 15 | 16 | export const Route = createFileRoute('/(marketing)/')({ 17 | loader: async (opts) => { 18 | await opts.context.queryClient.ensureQueryData(todosQueries.list()); 19 | }, 20 | component: RouteComponent, 21 | }); 22 | 23 | function RouteComponent() { 24 | const [getResponse, setGetResponse] = useState(null); 25 | const [postResponse, setPostResponse] = useState(null); 26 | 27 | // Query for todos using TanStack Query (suspense) 28 | const todosQuery = useSuspenseQuery(todosQueries.list()); 29 | const { data: todos = [], refetch: refetchTodos } = todosQuery; 30 | 31 | // Mutations 32 | const createTodoMutation = useCreateTodoMutation(); 33 | const toggleTodoMutation = useToggleTodoMutation(); 34 | const deleteTodoMutation = useDeleteTodoMutation(); 35 | 36 | // Todo input state 37 | const [newTodoText, setNewTodoText] = useState(''); 38 | 39 | const handleGet = async () => { 40 | try { 41 | const res = await axios.get('/api/test'); 42 | setGetResponse(JSON.stringify(res.data, null, 2)); 43 | } catch (error) { 44 | setGetResponse(`Error: ${error instanceof Error ? error.message : String(error)}`); 45 | } 46 | }; 47 | 48 | const handlePost = async () => { 49 | try { 50 | const res = await axios.post('/api/test', { test: 'data', number: 42 }); 51 | setPostResponse(JSON.stringify(res.data, null, 2)); 52 | } catch (error) { 53 | setPostResponse(`Error: ${error instanceof Error ? error.message : String(error)}`); 54 | } 55 | }; 56 | 57 | const handleCreateTodo = () => { 58 | if (!newTodoText.trim()) { 59 | toast.error('Todo text cannot be empty'); 60 | return; 61 | } 62 | createTodoMutation.mutate(newTodoText.trim()); 63 | setNewTodoText(''); 64 | }; 65 | 66 | const handleToggleTodo = (id: string) => { 67 | toggleTodoMutation.mutate(id); 68 | }; 69 | 70 | const handleDeleteTodo = (id: string) => { 71 | deleteTodoMutation.mutate(id); 72 | }; 73 | 74 | return ( 75 |
76 | {/* Hero Section */} 77 |
78 | 79 | 80 |

81 | TanStack Start React boilerplate with Tailwind 4 & shadcn 82 |

83 | 84 |

85 | The perfect starting point for your next web application 86 |

87 | 88 |

89 | Under heavy development 90 |

91 | 92 | {/* API Test Section */} 93 |
94 |

API Test

95 | 96 |
97 |
98 | 99 | {getResponse && ( 100 |
101 |                   {getResponse}
102 |                 
103 | )} 104 |
105 | 106 |
107 | 108 | {postResponse && ( 109 |
110 |                   {postResponse}
111 |                 
112 | )} 113 |
114 |
115 |
116 | 117 | {/* Todo List Section */} 118 |
119 |
120 |

Todos (Server Functions + TanStack Query)

121 | 124 |
125 | 126 |
127 | setNewTodoText(e.target.value)} 131 | onKeyDown={(e) => e.key === 'Enter' && handleCreateTodo()} 132 | placeholder="Add todo..." 133 | className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm" 134 | disabled={createTodoMutation.isPending} 135 | /> 136 | 142 |
143 | 144 |
145 | {todos.length === 0 ? ( 146 |

No todos yet. Add one above!

147 | ) : ( 148 | todos.map((todo) => ( 149 |
153 | handleToggleTodo(todo.id)} 157 | disabled={ 158 | toggleTodoMutation.isPending || 159 | deleteTodoMutation.isPending || 160 | createTodoMutation.isPending 161 | } 162 | className="rounded" 163 | /> 164 | 169 | {todo.text} 170 | 171 | 183 |
184 | )) 185 | )} 186 |
187 |
188 |
189 |
190 | ); 191 | } 192 | -------------------------------------------------------------------------------- /src/styles/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | :root { 6 | --background: oklch(1.0 0 0); 7 | --foreground: oklch(0.19 0.01 248.51); 8 | --card: oklch(0.98 0.0 197.14); 9 | --card-foreground: oklch(0.19 0.01 248.51); 10 | --popover: oklch(1.0 0 0); 11 | --popover-foreground: oklch(0.19 0.01 248.51); 12 | --primary: oklch(0.67 0.16 245.0); 13 | --primary-foreground: oklch(1.0 0 0); 14 | --secondary: oklch(0.19 0.01 248.51); 15 | --secondary-foreground: oklch(1.0 0 0); 16 | --muted: oklch(0.92 0.0 286.37); 17 | --muted-foreground: oklch(0.19 0.01 248.51); 18 | --accent: oklch(0.94 0.02 250.85); 19 | --accent-foreground: oklch(0.67 0.16 245.0); 20 | --destructive: oklch(0.62 0.24 25.77); 21 | --destructive-foreground: oklch(1.0 0 0); 22 | --border: oklch(0.93 0.01 231.66); 23 | --input: oklch(0.98 0.0 228.78); 24 | --ring: oklch(0.68 0.16 243.35); 25 | --chart-1: oklch(0.67 0.16 245.0); 26 | --chart-2: oklch(0.69 0.16 160.35); 27 | --chart-3: oklch(0.82 0.16 82.53); 28 | --chart-4: oklch(0.71 0.18 151.71); 29 | --chart-5: oklch(0.59 0.22 10.58); 30 | --sidebar: oklch(0.98 0.0 197.14); 31 | --sidebar-foreground: oklch(0.19 0.01 248.51); 32 | --sidebar-primary: oklch(0.67 0.16 245.0); 33 | --sidebar-primary-foreground: oklch(1.0 0 0); 34 | --sidebar-accent: oklch(0.94 0.02 250.85); 35 | --sidebar-accent-foreground: oklch(0.67 0.16 245.0); 36 | --sidebar-border: oklch(0.93 0.01 238.52); 37 | --sidebar-ring: oklch(0.68 0.16 243.35); 38 | --font-sans: Open Sans, sans-serif; 39 | --font-serif: Georgia, serif; 40 | --font-mono: Menlo, monospace; 41 | --radius: 1.3rem; 42 | --shadow-2xs: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0); 43 | --shadow-xs: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0); 44 | --shadow-sm: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0), 0px 1px 2px -1px 45 | hsl(202.82 89.12% 53.14% / 0.0); 46 | --shadow: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0), 0px 1px 2px -1px 47 | hsl(202.82 89.12% 53.14% / 0.0); 48 | --shadow-md: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0), 0px 2px 4px -1px 49 | hsl(202.82 89.12% 53.14% / 0.0); 50 | --shadow-lg: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0), 0px 4px 6px -1px 51 | hsl(202.82 89.12% 53.14% / 0.0); 52 | --shadow-xl: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0), 0px 8px 10px -1px 53 | hsl(202.82 89.12% 53.14% / 0.0); 54 | --shadow-2xl: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0); 55 | } 56 | 57 | .dark { 58 | --background: oklch(0 0 0); 59 | --foreground: oklch(0.93 0.0 228.79); 60 | --card: oklch(0.21 0.01 274.53); 61 | --card-foreground: oklch(0.89 0 0); 62 | --popover: oklch(0 0 0); 63 | --popover-foreground: oklch(0.93 0.0 228.79); 64 | --primary: oklch(0.67 0.16 245.01); 65 | --primary-foreground: oklch(1.0 0 0); 66 | --secondary: oklch(0.96 0.0 219.53); 67 | --secondary-foreground: oklch(0.19 0.01 248.51); 68 | --muted: oklch(0.21 0 0); 69 | --muted-foreground: oklch(0.56 0.01 247.97); 70 | --accent: oklch(0.19 0.03 242.55); 71 | --accent-foreground: oklch(0.67 0.16 245.01); 72 | --destructive: oklch(0.62 0.24 25.77); 73 | --destructive-foreground: oklch(1.0 0 0); 74 | --border: oklch(0.27 0.0 248.0); 75 | --input: oklch(0.3 0.03 244.82); 76 | --ring: oklch(0.68 0.16 243.35); 77 | --chart-1: oklch(0.67 0.16 245.0); 78 | --chart-2: oklch(0.69 0.16 160.35); 79 | --chart-3: oklch(0.82 0.16 82.53); 80 | --chart-4: oklch(0.71 0.18 151.71); 81 | --chart-5: oklch(0.59 0.22 10.58); 82 | --sidebar: oklch(0.21 0.01 274.53); 83 | --sidebar-foreground: oklch(0.89 0 0); 84 | --sidebar-primary: oklch(0.68 0.16 243.35); 85 | --sidebar-primary-foreground: oklch(1.0 0 0); 86 | --sidebar-accent: oklch(0.19 0.03 242.55); 87 | --sidebar-accent-foreground: oklch(0.67 0.16 245.01); 88 | --sidebar-border: oklch(0.38 0.02 240.59); 89 | --sidebar-ring: oklch(0.68 0.16 243.35); 90 | --font-sans: Open Sans, sans-serif; 91 | --font-serif: Georgia, serif; 92 | --font-mono: Menlo, monospace; 93 | --radius: 1.3rem; 94 | --shadow-2xs: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0); 95 | --shadow-xs: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0); 96 | --shadow-sm: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0), 0px 1px 2px -1px 97 | hsl(202.82 89.12% 53.14% / 0.0); 98 | --shadow: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0), 0px 1px 2px -1px 99 | hsl(202.82 89.12% 53.14% / 0.0); 100 | --shadow-md: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0), 0px 2px 4px -1px 101 | hsl(202.82 89.12% 53.14% / 0.0); 102 | --shadow-lg: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0), 0px 4px 6px -1px 103 | hsl(202.82 89.12% 53.14% / 0.0); 104 | --shadow-xl: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0), 0px 8px 10px -1px 105 | hsl(202.82 89.12% 53.14% / 0.0); 106 | --shadow-2xl: 0px 2px 0px 0px hsl(202.82 89.12% 53.14% / 0.0); 107 | } 108 | 109 | @theme inline { 110 | --color-background: var(--background); 111 | --color-foreground: var(--foreground); 112 | --color-card: var(--card); 113 | --color-card-foreground: var(--card-foreground); 114 | --color-popover: var(--popover); 115 | --color-popover-foreground: var(--popover-foreground); 116 | --color-primary: var(--primary); 117 | --color-primary-foreground: var(--primary-foreground); 118 | --color-secondary: var(--secondary); 119 | --color-secondary-foreground: var(--secondary-foreground); 120 | --color-muted: var(--muted); 121 | --color-muted-foreground: var(--muted-foreground); 122 | --color-accent: var(--accent); 123 | --color-accent-foreground: var(--accent-foreground); 124 | --color-destructive: var(--destructive); 125 | --color-destructive-foreground: var(--destructive-foreground); 126 | --color-border: var(--border); 127 | --color-input: var(--input); 128 | --color-ring: var(--ring); 129 | --color-chart-1: var(--chart-1); 130 | --color-chart-2: var(--chart-2); 131 | --color-chart-3: var(--chart-3); 132 | --color-chart-4: var(--chart-4); 133 | --color-chart-5: var(--chart-5); 134 | --color-sidebar: var(--sidebar); 135 | --color-sidebar-foreground: var(--sidebar-foreground); 136 | --color-sidebar-primary: var(--sidebar-primary); 137 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 138 | --color-sidebar-accent: var(--sidebar-accent); 139 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 140 | --color-sidebar-border: var(--sidebar-border); 141 | --color-sidebar-ring: var(--sidebar-ring); 142 | 143 | --font-sans: var(--font-sans); 144 | --font-mono: var(--font-mono); 145 | --font-serif: var(--font-serif); 146 | 147 | --radius-sm: calc(var(--radius) - 4px); 148 | --radius-md: calc(var(--radius) - 2px); 149 | --radius-lg: var(--radius); 150 | --radius-xl: calc(var(--radius) + 4px); 151 | 152 | --shadow-2xs: var(--shadow-2xs); 153 | --shadow-xs: var(--shadow-xs); 154 | --shadow-sm: var(--shadow-sm); 155 | --shadow: var(--shadow); 156 | --shadow-md: var(--shadow-md); 157 | --shadow-lg: var(--shadow-lg); 158 | --shadow-xl: var(--shadow-xl); 159 | --shadow-2xl: var(--shadow-2xl); 160 | } 161 | @layer base { 162 | * { 163 | @apply border-border outline-ring/50; 164 | } 165 | body { 166 | @apply bg-background text-foreground; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /.ruler/AGENTS.md: -------------------------------------------------------------------------------- 1 | # don't fetch or derive app state in useEffect 2 | 3 | # core rules 4 | 5 | 1. Fetch on navigation in route loaders (SSR + streaming); optionally seed via `queryClient.ensureQueryData`. \[1] 6 | 2. Do server work on the server via TanStack Start server functions; after mutations call `router.invalidate()` and/or `queryClient.invalidateQueries()`. \[2] 7 | 3. Keep page/UI state in the URL with typed search params (`validateSearch`, `Route.useSearch`, `navigate`). \[3] 8 | 4. Reserve effects for real external effects only (DOM, subscriptions, analytics). Compute derived state during render; `useMemo` only if expensive. \[4]\[6] 9 | 5. Hydration + Suspense: any update that suspends during hydration replaces SSR content with fallbacks. Wrap sync updates that might suspend in `startTransition` (direct import). Avoid rendering `isPending` during hydration. `useSyncExternalStore` always triggers fallbacks during hydration. \[10] 10 | 6. Data placement: 11 | 12 | * Server-synced domain data → TanStack DB collections (often powered by TanStack Query via `queryCollectionOptions`, or a sync engine). Read with live queries. \[11]\[12]\[14] 13 | * Ephemeral UI/session (theme, modals, steppers, optimistic buffers) → zustand or local-only/localStorage collection. Do not mirror server data into zustand. \[16]\[14] 14 | * Derived views → compute in render or via live queries. \[12] 15 | 16 | # if your useEffect did X → use Y 17 | 18 | * Fetch on mount/param change → route loader (+ `ensureQueryData`). \[1] 19 | * Submit/mutate → server function → then `router.invalidate()`/`qc.invalidateQueries()`. \[2] 20 | * Sync UI ↔ querystring → typed search params + `navigate`. \[3] 21 | * Derived state → compute during render (`useMemo` only if expensive). \[4] 22 | * Subscribe external stores → `useSyncExternalStore` (expect hydration fallbacks). \[5]\[10] 23 | * DOM/listeners/widgets → small `useEffect`/`useLayoutEffect`. \[6] 24 | * Synced list + optimistic UI → DB query collection + `onInsert`/`onUpdate`/`onDelete` or server fn + invalidate. \[11]\[13] 25 | * Realtime websocket/SSE patches → TanStack DB direct writes (`writeInsert/update/delete/upsert/batch`). \[13] 26 | * Joins/aggregations → live queries. \[12] 27 | * Local-only prefs/cross-tab → localStorage collection (no effects). \[14] 28 | 29 | # idioms (names only) 30 | 31 | * Loader: `queryClient.ensureQueryData(queryOptions({ queryKey, queryFn }))` → read via `useSuspenseQuery` hydrated from loader. \[1] 32 | * DB query collection: `createCollection(queryCollectionOptions({ queryKey, queryFn, queryClient, getKey }))` → read via live query. \[11]\[12] 33 | * Mutation (server-first): `createServerFn(...).handler(...)` → on success `qc.invalidateQueries`, `router.invalidate`; supports ``. \[2] 34 | * DB persistence handlers: `onInsert`/`onUpdate`/`onDelete` → return `{ refetch?: boolean }`; pair with direct writes when skipping refetch. \[13] 35 | * Search params as state: `validateSearch → Route.useSearch → navigate({ search })`. \[3] 36 | * External store read: `useSyncExternalStore(subscribe, getSnapshot)`. \[5] 37 | * Hydration-safe: `import { startTransition } from 'react'` for sync updates; avoid `useTransition`/`isPending` during hydration. \[10] 38 | 39 | # decision checklist 40 | 41 | * Needed at render → loader (defer/stream). \[1]\[7] 42 | * User changed data → server fn → invalidate; or DB handlers/direct writes. \[2]\[13] 43 | * Belongs in URL → typed search params. \[3] 44 | * Purely derived → render/live query. \[4]\[12] 45 | * External system only → effect. \[6] 46 | * Hydration sensitive → `startTransition` for sync updates; expect fallbacks from external stores; avoid `isPending` during hydration. \[10] 47 | * SSR/SEO → loader-based fetching with streaming/deferred; dehydrate/hydrate caches and DB snapshots. \[7] 48 | 49 | # React 19 helpers 50 | 51 | * `useActionState` for form pending/error/result. \[8] 52 | * `use` to suspend on promises. \[9] 53 | 54 | # hydration + suspense playbook \[10] 55 | 56 | * Rule: sync updates that suspend during hydration → fallback replaces SSR. 57 | * Quick fix: wrap updates with `startTransition` (direct import); re-wrap after `await`. 58 | * Avoid during hydration: using `useTransition` for the update, rendering `isPending`, `useDeferredValue` unless the suspensey child is memoized, any `useSyncExternalStore` mutation. 59 | * Safe during hydration: setting same value with `useState`/`useReducer`, `startTransition`-wrapped sync updates, `useDeferredValue` with `React.memo` around the suspensey child. 60 | * Compiler auto-memoization may help; treat as optimization. 61 | 62 | # TanStack DB: when/how \[11]\[12]\[13]\[14]\[15]\[16] 63 | 64 | * Use DB for server-synced domain data. 65 | * Load: `queryCollectionOptions` (simple fetch; optional refetch) or sync collections (Electric/Trailbase/RxDB). 66 | * Read: live queries (reactive, incremental; joins, `groupBy`, `distinct`, `order`, `limit`). \[12] 67 | * Writes: 68 | 69 | * Server-first → server fn → `router.invalidate()`/`qc.invalidateQueries()`. \[2] 70 | * Client-first → `onInsert`/`onUpdate`/`onDelete` (return `{ refetch: false }` if reconciling via direct writes/realtime). \[13] 71 | * Direct writes → `writeInsert/update/delete/upsert/batch` for websocket/SSE deltas, incremental pagination, server-computed fields; bypass optimistic layer and skip refetch. \[13] 72 | * Behaviors: query collection treats `queryFn` result as full state; empty array deletes all; merge partial fetches before returning. \[13] 73 | * Transaction merging reduces churn: 74 | 75 | * insert+update → merged insert 76 | * insert+delete → cancel 77 | * update+delete → delete 78 | * update+update → single union 79 | * same type back-to-back → keep latest \[15] 80 | * SSR: per-request store instances; never touch storage during SSR. \[16]\[14] 81 | 82 | # SSR/streaming/hydration with router + DB 83 | 84 | * In loaders: seed query via `ensureQueryData`; for DB, preload or dehydrate/hydrate snapshots so lists render instantly and stream updates. \[1]\[7]\[12]\[14] 85 | * After mutations: loader-owned → invalidate router/query; DB-owned → let collection refetch or apply direct writes. \[2]\[13] 86 | 87 | # micro-recipes 88 | 89 | * Avoid first-click spinner after SSR: wrap clicks with `startTransition`; don't render `isPending` until post-hydration. \[10] 90 | * External store during hydration: defer interaction or isolate the suspense boundary; expect fallbacks. \[5]\[10] 91 | * Paginated load-more: fetch next page, then `collection.utils.writeBatch(() => writeInsert(...))` to append without refetching old pages. \[13] 92 | * Realtime patches: `writeUpsert`/`writeDelete` from socket callback inside `writeBatch`. \[13] 93 | 94 | # TanStack Start best practices 95 | 96 | ## Selective SSR 97 | 98 | * Default `ssr: true` (change via `getRouter({ defaultSsr: false })`). SPA mode disables all server loaders/SSR. 99 | * Per-route `ssr`: `true` | `'data-only'` | `false`. 100 | * Functional `ssr(props)`: runs only on server initial request; can return `true` | `'data-only'` | `false` based on validated params/search. 101 | * Inheritance: child can only get less SSR (true → `'data-only'` or false; `'data-only'` → false). 102 | * Fallback: first route with `ssr: false` or `'data-only'` renders `pendingComponent` (or `defaultPendingComponent`) at least `minPendingMs` (or `defaultPendingMinMs`). 103 | * Root: you can disable SSR of root route component; `shellComponent` is always SSRed. 104 | 105 | ## Zustand in TanStack Start 106 | 107 | * Use for client/UI/session and push-based domain state (theme, modals, wizards, optimistic UI, websocket buffers). Keep server data in loaders/Query. 108 | * Per-request store instance to avoid SSR leaks; inject via Router context; dehydrate/hydrate via `router.dehydrate`/`router.hydrate` so snapshots stream with the page. 109 | * After navigation resolution, clear transient UI with `router.subscribe('onResolved', ...)`. 110 | * Mutations: do work in server fn → optionally update store optimistically → `router.invalidate` to reconcile with loader data. 111 | * Persist middleware only for client/session; avoid touching storage during SSR. 112 | * Use atomic selectors (`useStore(s => slice)`) and equality helpers. 113 | 114 | ## Project constraints 115 | 116 | * Use pnpm. 117 | * All route files are TypeScript React (`.tsx`). 118 | * Use alias imports: `~` resolves to root `./src`. 119 | * Never update `.env`; update `.env.example` instead. 120 | * Never start the dev server with `pnpm run dev` or `npm run dev`. 121 | * Never create a local pnpm --store 122 | 123 | ## docs map 124 | 125 | \[1] router data loading · \[2] server functions · \[3] search params · \[4] you might not need an effect · \[5] `useSyncExternalStore` · \[6] synchronizing with effects · \[7] SSR/streaming · \[8] `useActionState` · \[9] `use` · \[10] hydration + suspense guide · \[11] TanStack DB query collection · \[12] live queries · \[13] direct writes + persistence handlers · \[14] collections catalog · \[15] transactions + optimistic actions · \[16] zustand in TanStack Start 126 | 127 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | # don't fetch or derive app state in useEffect 7 | 8 | # core rules 9 | 10 | 1. Fetch on navigation in route loaders (SSR + streaming); optionally seed via `queryClient.ensureQueryData`. \[1] 11 | 2. Do server work on the server via TanStack Start server functions; after mutations call `router.invalidate()` and/or `queryClient.invalidateQueries()`. \[2] 12 | 3. Keep page/UI state in the URL with typed search params (`validateSearch`, `Route.useSearch`, `navigate`). \[3] 13 | 4. Reserve effects for real external effects only (DOM, subscriptions, analytics). Compute derived state during render; `useMemo` only if expensive. \[4]\[6] 14 | 5. Hydration + Suspense: any update that suspends during hydration replaces SSR content with fallbacks. Wrap sync updates that might suspend in `startTransition` (direct import). Avoid rendering `isPending` during hydration. `useSyncExternalStore` always triggers fallbacks during hydration. \[10] 15 | 6. Data placement: 16 | 17 | * Server-synced domain data → TanStack DB collections (often powered by TanStack Query via `queryCollectionOptions`, or a sync engine). Read with live queries. \[11]\[12]\[14] 18 | * Ephemeral UI/session (theme, modals, steppers, optimistic buffers) → zustand or local-only/localStorage collection. Do not mirror server data into zustand. \[16]\[14] 19 | * Derived views → compute in render or via live queries. \[12] 20 | 21 | # if your useEffect did X → use Y 22 | 23 | * Fetch on mount/param change → route loader (+ `ensureQueryData`). \[1] 24 | * Submit/mutate → server function → then `router.invalidate()`/`qc.invalidateQueries()`. \[2] 25 | * Sync UI ↔ querystring → typed search params + `navigate`. \[3] 26 | * Derived state → compute during render (`useMemo` only if expensive). \[4] 27 | * Subscribe external stores → `useSyncExternalStore` (expect hydration fallbacks). \[5]\[10] 28 | * DOM/listeners/widgets → small `useEffect`/`useLayoutEffect`. \[6] 29 | * Synced list + optimistic UI → DB query collection + `onInsert`/`onUpdate`/`onDelete` or server fn + invalidate. \[11]\[13] 30 | * Realtime websocket/SSE patches → TanStack DB direct writes (`writeInsert/update/delete/upsert/batch`). \[13] 31 | * Joins/aggregations → live queries. \[12] 32 | * Local-only prefs/cross-tab → localStorage collection (no effects). \[14] 33 | 34 | # idioms (names only) 35 | 36 | * Loader: `queryClient.ensureQueryData(queryOptions({ queryKey, queryFn }))` → read via `useSuspenseQuery` hydrated from loader. \[1] 37 | * DB query collection: `createCollection(queryCollectionOptions({ queryKey, queryFn, queryClient, getKey }))` → read via live query. \[11]\[12] 38 | * Mutation (server-first): `createServerFn(...).handler(...)` → on success `qc.invalidateQueries`, `router.invalidate`; supports ``. \[2] 39 | * DB persistence handlers: `onInsert`/`onUpdate`/`onDelete` → return `{ refetch?: boolean }`; pair with direct writes when skipping refetch. \[13] 40 | * Search params as state: `validateSearch → Route.useSearch → navigate({ search })`. \[3] 41 | * External store read: `useSyncExternalStore(subscribe, getSnapshot)`. \[5] 42 | * Hydration-safe: `import { startTransition } from 'react'` for sync updates; avoid `useTransition`/`isPending` during hydration. \[10] 43 | 44 | # decision checklist 45 | 46 | * Needed at render → loader (defer/stream). \[1]\[7] 47 | * User changed data → server fn → invalidate; or DB handlers/direct writes. \[2]\[13] 48 | * Belongs in URL → typed search params. \[3] 49 | * Purely derived → render/live query. \[4]\[12] 50 | * External system only → effect. \[6] 51 | * Hydration sensitive → `startTransition` for sync updates; expect fallbacks from external stores; avoid `isPending` during hydration. \[10] 52 | * SSR/SEO → loader-based fetching with streaming/deferred; dehydrate/hydrate caches and DB snapshots. \[7] 53 | 54 | # React 19 helpers 55 | 56 | * `useActionState` for form pending/error/result. \[8] 57 | * `use` to suspend on promises. \[9] 58 | 59 | # hydration + suspense playbook \[10] 60 | 61 | * Rule: sync updates that suspend during hydration → fallback replaces SSR. 62 | * Quick fix: wrap updates with `startTransition` (direct import); re-wrap after `await`. 63 | * Avoid during hydration: using `useTransition` for the update, rendering `isPending`, `useDeferredValue` unless the suspensey child is memoized, any `useSyncExternalStore` mutation. 64 | * Safe during hydration: setting same value with `useState`/`useReducer`, `startTransition`-wrapped sync updates, `useDeferredValue` with `React.memo` around the suspensey child. 65 | * Compiler auto-memoization may help; treat as optimization. 66 | 67 | # TanStack DB: when/how \[11]\[12]\[13]\[14]\[15]\[16] 68 | 69 | * Use DB for server-synced domain data. 70 | * Load: `queryCollectionOptions` (simple fetch; optional refetch) or sync collections (Electric/Trailbase/RxDB). 71 | * Read: live queries (reactive, incremental; joins, `groupBy`, `distinct`, `order`, `limit`). \[12] 72 | * Writes: 73 | 74 | * Server-first → server fn → `router.invalidate()`/`qc.invalidateQueries()`. \[2] 75 | * Client-first → `onInsert`/`onUpdate`/`onDelete` (return `{ refetch: false }` if reconciling via direct writes/realtime). \[13] 76 | * Direct writes → `writeInsert/update/delete/upsert/batch` for websocket/SSE deltas, incremental pagination, server-computed fields; bypass optimistic layer and skip refetch. \[13] 77 | * Behaviors: query collection treats `queryFn` result as full state; empty array deletes all; merge partial fetches before returning. \[13] 78 | * Transaction merging reduces churn: 79 | 80 | * insert+update → merged insert 81 | * insert+delete → cancel 82 | * update+delete → delete 83 | * update+update → single union 84 | * same type back-to-back → keep latest \[15] 85 | * SSR: per-request store instances; never touch storage during SSR. \[16]\[14] 86 | 87 | # SSR/streaming/hydration with router + DB 88 | 89 | * In loaders: seed query via `ensureQueryData`; for DB, preload or dehydrate/hydrate snapshots so lists render instantly and stream updates. \[1]\[7]\[12]\[14] 90 | * After mutations: loader-owned → invalidate router/query; DB-owned → let collection refetch or apply direct writes. \[2]\[13] 91 | 92 | # micro-recipes 93 | 94 | * Avoid first-click spinner after SSR: wrap clicks with `startTransition`; don't render `isPending` until post-hydration. \[10] 95 | * External store during hydration: defer interaction or isolate the suspense boundary; expect fallbacks. \[5]\[10] 96 | * Paginated load-more: fetch next page, then `collection.utils.writeBatch(() => writeInsert(...))` to append without refetching old pages. \[13] 97 | * Realtime patches: `writeUpsert`/`writeDelete` from socket callback inside `writeBatch`. \[13] 98 | 99 | # TanStack Start best practices 100 | 101 | ## Selective SSR 102 | 103 | * Default `ssr: true` (change via `getRouter({ defaultSsr: false })`). SPA mode disables all server loaders/SSR. 104 | * Per-route `ssr`: `true` | `'data-only'` | `false`. 105 | * Functional `ssr(props)`: runs only on server initial request; can return `true` | `'data-only'` | `false` based on validated params/search. 106 | * Inheritance: child can only get less SSR (true → `'data-only'` or false; `'data-only'` → false). 107 | * Fallback: first route with `ssr: false` or `'data-only'` renders `pendingComponent` (or `defaultPendingComponent`) at least `minPendingMs` (or `defaultPendingMinMs`). 108 | * Root: you can disable SSR of root route component; `shellComponent` is always SSRed. 109 | 110 | ## Zustand in TanStack Start 111 | 112 | * Use for client/UI/session and push-based domain state (theme, modals, wizards, optimistic UI, websocket buffers). Keep server data in loaders/Query. 113 | * Per-request store instance to avoid SSR leaks; inject via Router context; dehydrate/hydrate via `router.dehydrate`/`router.hydrate` so snapshots stream with the page. 114 | * After navigation resolution, clear transient UI with `router.subscribe('onResolved', ...)`. 115 | * Mutations: do work in server fn → optionally update store optimistically → `router.invalidate` to reconcile with loader data. 116 | * Persist middleware only for client/session; avoid touching storage during SSR. 117 | * Use atomic selectors (`useStore(s => slice)`) and equality helpers. 118 | 119 | ## Project constraints 120 | 121 | * Use pnpm. 122 | * All route files are TypeScript React (`.tsx`). 123 | * Use alias imports: `~` resolves to root `./src`. 124 | * Never update `.env`; update `.env.example` instead. 125 | * Never start the dev server with `pnpm run dev` or `npm run dev`. 126 | * Never create a local pnpm --store 127 | 128 | ## docs map 129 | 130 | \[1] router data loading · \[2] server functions · \[3] search params · \[4] you might not need an effect · \[5] `useSyncExternalStore` · \[6] synchronizing with effects · \[7] SSR/streaming · \[8] `useActionState` · \[9] `use` · \[10] hydration + suspense guide · \[11] TanStack DB query collection · \[12] live queries · \[13] direct writes + persistence handlers · \[14] collections catalog · \[15] transactions + optimistic actions · \[16] zustand in TanStack Start 131 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 3 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" 4 | 5 | import { cn } from "~/lib/utils" 6 | 7 | function DropdownMenu({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return 11 | } 12 | 13 | function DropdownMenuPortal({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return ( 17 | 18 | ) 19 | } 20 | 21 | function DropdownMenuTrigger({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 29 | ) 30 | } 31 | 32 | function DropdownMenuContent({ 33 | className, 34 | sideOffset = 4, 35 | ...props 36 | }: React.ComponentProps) { 37 | return ( 38 | 39 | 48 | 49 | ) 50 | } 51 | 52 | function DropdownMenuGroup({ 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 57 | ) 58 | } 59 | 60 | function DropdownMenuItem({ 61 | className, 62 | inset, 63 | variant = "default", 64 | ...props 65 | }: React.ComponentProps & { 66 | inset?: boolean 67 | variant?: "default" | "destructive" 68 | }) { 69 | return ( 70 | 80 | ) 81 | } 82 | 83 | function DropdownMenuCheckboxItem({ 84 | className, 85 | children, 86 | checked, 87 | ...props 88 | }: React.ComponentProps) { 89 | return ( 90 | 99 | 100 | 101 | 102 | 103 | 104 | {children} 105 | 106 | ) 107 | } 108 | 109 | function DropdownMenuRadioGroup({ 110 | ...props 111 | }: React.ComponentProps) { 112 | return ( 113 | 117 | ) 118 | } 119 | 120 | function DropdownMenuRadioItem({ 121 | className, 122 | children, 123 | ...props 124 | }: React.ComponentProps) { 125 | return ( 126 | 134 | 135 | 136 | 137 | 138 | 139 | {children} 140 | 141 | ) 142 | } 143 | 144 | function DropdownMenuLabel({ 145 | className, 146 | inset, 147 | ...props 148 | }: React.ComponentProps & { 149 | inset?: boolean 150 | }) { 151 | return ( 152 | 161 | ) 162 | } 163 | 164 | function DropdownMenuSeparator({ 165 | className, 166 | ...props 167 | }: React.ComponentProps) { 168 | return ( 169 | 174 | ) 175 | } 176 | 177 | function DropdownMenuShortcut({ 178 | className, 179 | ...props 180 | }: React.ComponentProps<"span">) { 181 | return ( 182 | 190 | ) 191 | } 192 | 193 | function DropdownMenuSub({ 194 | ...props 195 | }: React.ComponentProps) { 196 | return 197 | } 198 | 199 | function DropdownMenuSubTrigger({ 200 | className, 201 | inset, 202 | children, 203 | ...props 204 | }: React.ComponentProps & { 205 | inset?: boolean 206 | }) { 207 | return ( 208 | 217 | {children} 218 | 219 | 220 | ) 221 | } 222 | 223 | function DropdownMenuSubContent({ 224 | className, 225 | ...props 226 | }: React.ComponentProps) { 227 | return ( 228 | 236 | ) 237 | } 238 | 239 | export { 240 | DropdownMenu, 241 | DropdownMenuPortal, 242 | DropdownMenuTrigger, 243 | DropdownMenuContent, 244 | DropdownMenuGroup, 245 | DropdownMenuLabel, 246 | DropdownMenuItem, 247 | DropdownMenuCheckboxItem, 248 | DropdownMenuRadioGroup, 249 | DropdownMenuRadioItem, 250 | DropdownMenuSeparator, 251 | DropdownMenuShortcut, 252 | DropdownMenuSub, 253 | DropdownMenuSubTrigger, 254 | DropdownMenuSubContent, 255 | } 256 | --------------------------------------------------------------------------------