├── .prettierrc ├── .prettierignore ├── .gitignore ├── .eslintrc ├── src ├── components │ ├── NotFound.tsx │ ├── DefaultCatchBoundary.tsx │ ├── ui │ │ ├── skeleton.tsx │ │ ├── input.tsx │ │ ├── button.tsx │ │ └── card.tsx │ ├── CodeSample.tsx │ └── Chat.tsx ├── lib │ └── utils.ts ├── styles │ └── app.css ├── routes │ ├── loaders │ │ ├── no-loader.tsx │ │ ├── ensure.tsx │ │ └── prefetch.tsx │ ├── ssr.tsx │ ├── loaders.tsx │ ├── __root.tsx │ ├── react-query.tsx │ ├── gcTime.tsx │ └── index.tsx ├── router.tsx └── routeTree.gen.ts ├── convex.json ├── components.json ├── convex ├── _generated │ ├── api.js │ ├── api.d.ts │ ├── dataModel.d.ts │ ├── server.js │ └── server.d.ts ├── schema.ts ├── tsconfig.json └── messages.ts ├── vite.config.ts ├── tsconfig.json └── package.json /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/api 2 | **/build 3 | **/public 4 | pnpm-lock.yaml 5 | routeTree.gen.ts 6 | convex/_generated/* 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | app.config.timestamp_*.js 3 | node_modules 4 | dist 5 | 6 | .env.local 7 | .output 8 | .nitro 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["react-app"], 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["react-hooks"] 5 | } 6 | -------------------------------------------------------------------------------- /src/components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | export function NotFound({ children }: { children?: any }) { 2 | return children ||

The page you are looking for does not exist.

3 | } 4 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /convex.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/get-convex/convex-backend/refs/heads/main/npm-packages/convex/schemas/convex.schema.json", 3 | "node": { 4 | "nodeVersion": "22" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/components/DefaultCatchBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorComponent } from '@tanstack/react-router' 2 | import type { ErrorComponentProps } from '@tanstack/react-router' 3 | 4 | export function DefaultCatchBoundary({ error }: ErrorComponentProps) { 5 | console.error(error) 6 | 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "~/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /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": "tailwind.config.js", 8 | "css": "app/styles/app.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "~/components", 15 | "utils": "~/lib/utils", 16 | "ui": "~/components/ui", 17 | "hooks": "~/hooks", 18 | "lib": "~/lib" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { anyApi } from "convex/server"; 12 | 13 | /** 14 | * A utility for referencing Convex functions in your app's API. 15 | * 16 | * Usage: 17 | * ```js 18 | * const myFunctionReference = api.myModule.myFunction; 19 | * ``` 20 | */ 21 | export const api = anyApi; 22 | export const internal = anyApi; 23 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { tanstackStart } from '@tanstack/react-start/plugin/vite' 2 | import { defineConfig } from 'vite' 3 | import tsConfigPaths from 'vite-tsconfig-paths' 4 | import tailwindcss from '@tailwindcss/vite' 5 | import viteReact from '@vitejs/plugin-react' 6 | import { nitro } from 'nitro/vite' 7 | 8 | export default defineConfig({ 9 | server: { 10 | port: 3000, 11 | }, 12 | plugins: [ 13 | tailwindcss(), 14 | tsConfigPaths({ 15 | projects: ['./tsconfig.json'], 16 | }), 17 | tanstackStart(), 18 | nitro(), 19 | viteReact(), 20 | ], 21 | }) 22 | -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineSchema, defineTable } from 'convex/server' 2 | import { v } from 'convex/values' 3 | 4 | export default defineSchema({ 5 | messages: defineTable({ 6 | body: v.string(), 7 | user: v.id('users'), 8 | channel: v.id('channels'), 9 | }).index('by_channel', ['channel']), 10 | users: defineTable({ 11 | name: v.string(), 12 | }).index('by_name', ['name']), 13 | channels: defineTable({ 14 | name: v.string(), 15 | }).index('by_name', ['name']), 16 | simulating: defineTable({ 17 | finishingAt: v.number(), 18 | }), 19 | channelMembers: defineTable({ 20 | userId: v.id('users'), 21 | channelId: v.id('channels'), 22 | }), 23 | }) 24 | -------------------------------------------------------------------------------- /src/styles/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @plugin '@tailwindcss/typography'; 4 | 5 | @utility container { 6 | margin-inline: auto; 7 | padding-inline: 2rem; 8 | @media (width >= --theme(--breakpoint-sm)) { 9 | max-width: none; 10 | } 11 | @media (width >= 1400px) { 12 | max-width: 1400px; 13 | } 14 | } 15 | 16 | @layer base { 17 | *, 18 | ::after, 19 | ::before, 20 | ::backdrop, 21 | ::file-selector-button { 22 | border-color: var(--color-gray-200, currentcolor); 23 | } 24 | } 25 | 26 | @layer base { 27 | body { 28 | @apply bg-slate-950 text-slate-200 antialiased; 29 | } 30 | 31 | p a { 32 | @apply hover:text-white transition-colors font-medium underline underline-offset-2; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /convex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* This TypeScript project config describes the environment that 3 | * Convex functions run in and is used to typecheck them. 4 | * You can modify it, but some settings required to use Convex. 5 | */ 6 | "compilerOptions": { 7 | /* These settings are not required by Convex and can be modified. */ 8 | "allowJs": true, 9 | "strict": true, 10 | "moduleResolution": "Bundler", 11 | "jsx": "react-jsx", 12 | "skipLibCheck": true, 13 | "allowSyntheticDefaultImports": true, 14 | 15 | /* These compiler options are required by Convex */ 16 | "target": "ESNext", 17 | "lib": ["ES2021", "dom"], 18 | "forceConsistentCasingInFileNames": true, 19 | "module": "ESNext", 20 | "isolatedModules": true, 21 | "noEmit": true 22 | }, 23 | "include": ["./**/*"], 24 | "exclude": ["./_generated"] 25 | } 26 | -------------------------------------------------------------------------------- /convex/_generated/api.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { 12 | ApiFromModules, 13 | FilterApi, 14 | FunctionReference, 15 | } from "convex/server"; 16 | import type * as messages from "../messages.js"; 17 | 18 | /** 19 | * A utility for referencing Convex functions in your app's API. 20 | * 21 | * Usage: 22 | * ```js 23 | * const myFunctionReference = api.myModule.myFunction; 24 | * ``` 25 | */ 26 | declare const fullApi: ApiFromModules<{ 27 | messages: typeof messages; 28 | }>; 29 | export declare const api: FilterApi< 30 | typeof fullApi, 31 | FunctionReference 32 | >; 33 | export declare const internal: FilterApi< 34 | typeof fullApi, 35 | FunctionReference 36 | >; 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx", "public/script*.js"], 3 | "compilerOptions": { 4 | "target": "ES2022", 5 | "jsx": "react-jsx", 6 | "module": "ESNext", 7 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 8 | "types": ["vite/client"], 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "noUncheckedSideEffectImports": true, 21 | "baseUrl": ".", 22 | "paths": { 23 | "~/*": ["./src/*"] 24 | }, 25 | 26 | "esModuleInterop": true, 27 | "isolatedModules": true, 28 | "resolveJsonModule": true, 29 | "allowJs": true, 30 | "forceConsistentCasingInFileNames": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '~/lib/utils' 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | }, 22 | ) 23 | Input.displayName = 'Input' 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /src/routes/loaders/no-loader.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router' 2 | import Chat from '~/components/Chat' 3 | import CodeSample from '../../components/CodeSample' 4 | 5 | export const Route = createFileRoute('/loaders/no-loader')({ 6 | component: Messages, 7 | validateSearch: (search: Record) => { 8 | return { 9 | cacheBust: typeof search.cacheBust === 'string' ? search.cacheBust : '', 10 | } 11 | }, 12 | }) 13 | 14 | function Messages() { 15 | const { cacheBust } = Route.useSearch() 16 | return ( 17 |
18 | 24 |
25 | { 28 | const { data } = useQuery( 29 | convexQuery(api.messages.list, {}) 30 | ); 31 | }, 32 | })`} 33 | /> 34 |
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/components/CodeSample.tsx: -------------------------------------------------------------------------------- 1 | import { Highlight } from 'prism-react-renderer' 2 | 3 | interface CodeBlockProps { 4 | code: string 5 | } 6 | 7 | export default function CodeBlock({ code }: CodeBlockProps) { 8 | return ( 9 |
10 | 11 | {({ className, style, tokens, getLineProps, getTokenProps }) => ( 12 |
13 |             {tokens.map((line, i) => (
14 |               
15 | 16 | {i + 1} 17 | 18 | 19 | {line.map((token, key) => ( 20 | 21 | ))} 22 | 23 |
24 | ))} 25 |
26 | )} 27 |
28 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/routes/loaders/ensure.tsx: -------------------------------------------------------------------------------- 1 | import { convexQuery } from '@convex-dev/react-query' 2 | import { createFileRoute } from '@tanstack/react-router' 3 | import { api } from 'convex/_generated/api' 4 | import Chat from '~/components/Chat' 5 | import CodeSample from '../../components/CodeSample' 6 | 7 | export const Route = createFileRoute('/loaders/ensure')({ 8 | component: Messages, 9 | validateSearch: (search: Record) => { 10 | return { 11 | cacheBust: typeof search.cacheBust === 'string' ? search.cacheBust : '', 12 | } 13 | }, 14 | loaderDeps: ({ search: { cacheBust } }) => ({ cacheBust }), 15 | loader: async ({ deps: { cacheBust }, context }) => { 16 | await context.queryClient.ensureQueryData({ 17 | ...convexQuery(api.messages.listMessages, { channel: 'sf', cacheBust }), 18 | gcTime: 10000, 19 | }) 20 | }, 21 | }) 22 | 23 | function Messages() { 24 | const { cacheBust } = Route.useSearch() 25 | return ( 26 |
27 | 33 |
34 | { 37 | await opts.context.queryClient.ensureQueryData( 38 | convexQuery(api.messages.list, {}), 39 | ); 40 | }, 41 | component: () => { 42 | const { data } = useQuery( 43 | convexQuery(api.messages.list, {}) 44 | ); 45 | }, 46 | })`} 47 | /> 48 |
49 |
50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/routes/loaders/prefetch.tsx: -------------------------------------------------------------------------------- 1 | import { convexQuery } from '@convex-dev/react-query' 2 | import { createFileRoute } from '@tanstack/react-router' 3 | import { api } from 'convex/_generated/api' 4 | import Chat from '~/components/Chat' 5 | import CodeSample from '../../components/CodeSample' 6 | 7 | export const Route = createFileRoute('/loaders/prefetch')({ 8 | component: Messages, 9 | validateSearch: (search: Record) => { 10 | return { 11 | cacheBust: typeof search.cacheBust === 'string' ? search.cacheBust : '', 12 | } 13 | }, 14 | loaderDeps: ({ search: { cacheBust } }) => ({ cacheBust }), 15 | loader: async ({ deps: { cacheBust }, context }) => { 16 | context.queryClient.prefetchQuery({ 17 | ...convexQuery(api.messages.listMessages, { 18 | channel: 'seattle', 19 | cacheBust, 20 | }), 21 | gcTime: 10000, 22 | }) 23 | }, 24 | }) 25 | 26 | function Messages() { 27 | const { cacheBust } = Route.useSearch() 28 | return ( 29 |
30 | 36 |
37 | { 40 | await opts.context.queryClient.prefetchQuery( 41 | convexQuery(api.messages.list, {}), 42 | ); 43 | }, 44 | component: () => { 45 | const { data } = useQuery( 46 | convexQuery(api.messages.list, {}) 47 | ); 48 | }, 49 | })`} 50 | /> 51 |
52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/router.tsx: -------------------------------------------------------------------------------- 1 | import { createRouter } from '@tanstack/react-router' 2 | import { routeTree } from './routeTree.gen' 3 | import { ConvexQueryClient } from '@convex-dev/react-query' 4 | import { QueryClient } from '@tanstack/react-query' 5 | import { routerWithQueryClient } from '@tanstack/react-router-with-query' 6 | import { ConvexProvider, ConvexReactClient } from 'convex/react' 7 | import { DefaultCatchBoundary } from './components/DefaultCatchBoundary' 8 | import { NotFound } from './components/NotFound' 9 | 10 | export function getRouter() { 11 | let CONVEX_URL = (import.meta as any).env.VITE_CONVEX_URL! 12 | // hardcoded, Tom's dev instance 13 | // this is temporary 14 | const BACKUP_CONVEX_URL = 'https://coordinated-lion-120.convex.cloud' 15 | CONVEX_URL = CONVEX_URL || BACKUP_CONVEX_URL 16 | if (!CONVEX_URL) { 17 | throw new Error('missing VITE_CONVEX_URL envar') 18 | } 19 | const convex = new ConvexReactClient(CONVEX_URL, { 20 | unsavedChangesWarning: false, 21 | }) 22 | const convexQueryClient = new ConvexQueryClient(convex) 23 | 24 | const queryClient: QueryClient = new QueryClient({ 25 | defaultOptions: { 26 | queries: { 27 | queryKeyHashFn: convexQueryClient.hashFn(), 28 | queryFn: convexQueryClient.queryFn(), 29 | }, 30 | }, 31 | }) 32 | convexQueryClient.connect(queryClient) 33 | 34 | const router = routerWithQueryClient( 35 | createRouter({ 36 | routeTree, 37 | defaultPreload: 'intent', 38 | defaultErrorComponent: DefaultCatchBoundary, 39 | defaultNotFoundComponent: () => , 40 | scrollRestoration: true, 41 | context: { queryClient }, 42 | Wrap: ({ children }) => { 43 | return ( 44 | 45 | {children} 46 | 47 | ) 48 | }, 49 | }), 50 | queryClient, 51 | ) 52 | 53 | return router 54 | } 55 | -------------------------------------------------------------------------------- /convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated data model types. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { 12 | DataModelFromSchemaDefinition, 13 | DocumentByName, 14 | TableNamesInDataModel, 15 | SystemTableNames, 16 | } from "convex/server"; 17 | import type { GenericId } from "convex/values"; 18 | import schema from "../schema.js"; 19 | 20 | /** 21 | * The names of all of your Convex tables. 22 | */ 23 | export type TableNames = TableNamesInDataModel; 24 | 25 | /** 26 | * The type of a document stored in Convex. 27 | * 28 | * @typeParam TableName - A string literal type of the table name (like "users"). 29 | */ 30 | export type Doc = DocumentByName< 31 | DataModel, 32 | TableName 33 | >; 34 | 35 | /** 36 | * An identifier for a document in Convex. 37 | * 38 | * Convex documents are uniquely identified by their `Id`, which is accessible 39 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 40 | * 41 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 42 | * 43 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 44 | * strings when type checking. 45 | * 46 | * @typeParam TableName - A string literal type of the table name (like "users"). 47 | */ 48 | export type Id = 49 | GenericId; 50 | 51 | /** 52 | * A type describing your Convex data model. 53 | * 54 | * This type includes information about what tables you have, the type of 55 | * documents stored in those tables, and the indexes defined on them. 56 | * 57 | * This type is used to parameterize methods like `queryGeneric` and 58 | * `mutationGeneric` to make them type-safe. 59 | */ 60 | export type DataModel = DataModelFromSchemaDefinition; 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tanstack-start", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "npx convex run messages:seed --push && concurrently -r npm:dev:web npm:dev:db", 8 | "dev:web": "vite dev", 9 | "dev:db": "npx convex dev", 10 | "build": "vite build && tsc --noEmit", 11 | "start": "node .output/server/index.mjs", 12 | "lint": "prettier --check '**/*' --ignore-unknown && eslint --ext .ts,.tsx ./app", 13 | "format": "prettier --write '**/*' --ignore-unknown" 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "ISC", 18 | "dependencies": { 19 | "@convex-dev/react-query": "^0.0.0-alpha.11", 20 | "@radix-ui/react-icons": "~1.3.0", 21 | "@radix-ui/react-slot": "^1.1.0", 22 | "@tailwindcss/vite": "^4.1.13", 23 | "@tanstack/react-query": "^5.89.0", 24 | "@tanstack/react-query-devtools": "^5.89.0", 25 | "@tanstack/react-router": "^1.132.25", 26 | "@tanstack/react-router-with-query": "^1.130.17", 27 | "@tanstack/react-start": "^1.132.25", 28 | "@tanstack/router-devtools": "^1.132.25", 29 | "@vitejs/plugin-react": "^5.0.3", 30 | "class-variance-authority": "^0.7.0", 31 | "clsx": "1.2.1", 32 | "convex": "^1.27.3", 33 | "nitro": "npm:nitro-nightly", 34 | "prism-react-renderer": "^2.4.0", 35 | "react": "^18.3.1", 36 | "react-dom": "^18.3.1", 37 | "shadcn": "~2.1.2", 38 | "tailwind-merge": "^3.3.1" 39 | }, 40 | "devDependencies": { 41 | "@tailwindcss/typography": "^0.5.19", 42 | "@types/react": "^18.2.65", 43 | "@types/react-dom": "^18.2.21", 44 | "@typescript-eslint/parser": "^6.7.4", 45 | "concurrently": "~8.2.2", 46 | "eslint": "^8.29.0", 47 | "eslint-config-react-app": "~7.0.1", 48 | "prettier": "3.2.5", 49 | "tailwindcss": "^4.1.13", 50 | "typescript": "~5.6.2", 51 | "vite": "^7.1.5", 52 | "vite-tsconfig-paths": "^5.1.4" 53 | }, 54 | "engines": { 55 | "node": "22.x" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /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-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-slate-300 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-slate-700 text-white shadow-sm hover:bg-slate-800', 13 | destructive: 'bg-red-600 text-white shadow-xs hover:bg-red-500', 14 | outline: 15 | 'border border-slate-200 bg-white shadow-xs hover:bg-slate-100 hover:text-slate-900', 16 | secondary: 'bg-slate-100 text-slate-900 shadow-xs hover:bg-slate-200', 17 | ghost: 'hover:bg-slate-100 hover:text-slate-900', 18 | link: 'hover:underline', 19 | }, 20 | size: { 21 | default: 'h-9 px-4 py-2', 22 | sm: 'h-8 rounded-md px-3 text-xs', 23 | lg: 'h-10 rounded-md px-8', 24 | icon: 'h-9 w-9', 25 | }, 26 | }, 27 | defaultVariants: { 28 | variant: 'default', 29 | size: 'default', 30 | }, 31 | }, 32 | ) 33 | 34 | export interface ButtonProps 35 | extends React.ButtonHTMLAttributes, 36 | VariantProps { 37 | asChild?: boolean 38 | } 39 | 40 | const Button = React.forwardRef( 41 | ({ className, variant, size, asChild = false, ...props }, ref) => { 42 | const Comp = asChild ? Slot : 'button' 43 | return ( 44 | 49 | ) 50 | }, 51 | ) 52 | Button.displayName = 'Button' 53 | 54 | export { Button, buttonVariants } 55 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { cn } from '~/lib/utils' 3 | 4 | const Card = React.forwardRef< 5 | HTMLDivElement, 6 | React.HTMLAttributes 7 | >(({ className, ...props }, ref) => ( 8 |
13 | )) 14 | Card.displayName = 'Card' 15 | 16 | const CardHeader = React.forwardRef< 17 | HTMLDivElement, 18 | React.HTMLAttributes 19 | >(({ className, ...props }, ref) => ( 20 |
25 | )) 26 | CardHeader.displayName = 'CardHeader' 27 | 28 | const CardTitle = React.forwardRef< 29 | HTMLParagraphElement, 30 | React.HTMLAttributes 31 | >(({ className, children, ...props }, ref) => { 32 | if (!children) return null 33 | return ( 34 |

39 | {children} 40 |

41 | ) 42 | }) 43 | CardTitle.displayName = 'CardTitle' 44 | 45 | const CardDescription = React.forwardRef< 46 | HTMLParagraphElement, 47 | React.HTMLAttributes 48 | >(({ className, ...props }, ref) => ( 49 |

54 | )) 55 | CardDescription.displayName = 'CardDescription' 56 | 57 | const CardContent = React.forwardRef< 58 | HTMLDivElement, 59 | React.HTMLAttributes 60 | >(({ className, ...props }, ref) => ( 61 |

62 | )) 63 | CardContent.displayName = 'CardContent' 64 | 65 | const CardFooter = React.forwardRef< 66 | HTMLDivElement, 67 | React.HTMLAttributes 68 | >(({ className, ...props }, ref) => ( 69 |
74 | )) 75 | CardFooter.displayName = 'CardFooter' 76 | 77 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 78 | -------------------------------------------------------------------------------- /convex/_generated/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { 12 | actionGeneric, 13 | httpActionGeneric, 14 | queryGeneric, 15 | mutationGeneric, 16 | internalActionGeneric, 17 | internalMutationGeneric, 18 | internalQueryGeneric, 19 | } from "convex/server"; 20 | 21 | /** 22 | * Define a query in this Convex app's public API. 23 | * 24 | * This function will be allowed to read your Convex database and will be accessible from the client. 25 | * 26 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 27 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 28 | */ 29 | export const query = queryGeneric; 30 | 31 | /** 32 | * Define a query that is only accessible from other Convex functions (but not from the client). 33 | * 34 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 35 | * 36 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 37 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 38 | */ 39 | export const internalQuery = internalQueryGeneric; 40 | 41 | /** 42 | * Define a mutation in this Convex app's public API. 43 | * 44 | * This function will be allowed to modify your Convex database and will be accessible from the client. 45 | * 46 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 47 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 48 | */ 49 | export const mutation = mutationGeneric; 50 | 51 | /** 52 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 53 | * 54 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 55 | * 56 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 57 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 58 | */ 59 | export const internalMutation = internalMutationGeneric; 60 | 61 | /** 62 | * Define an action in this Convex app's public API. 63 | * 64 | * An action is a function which can execute any JavaScript code, including non-deterministic 65 | * code and code with side-effects, like calling third-party services. 66 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 67 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 68 | * 69 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 70 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 71 | */ 72 | export const action = actionGeneric; 73 | 74 | /** 75 | * Define an action that is only accessible from other Convex functions (but not from the client). 76 | * 77 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 78 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 79 | */ 80 | export const internalAction = internalActionGeneric; 81 | 82 | /** 83 | * Define a Convex HTTP action. 84 | * 85 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object 86 | * as its second. 87 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. 88 | */ 89 | export const httpAction = httpActionGeneric; 90 | -------------------------------------------------------------------------------- /src/routes/ssr.tsx: -------------------------------------------------------------------------------- 1 | import { Link, createFileRoute } from '@tanstack/react-router' 2 | import CodeSample from '~/components/CodeSample' 3 | import Chat from '~/components/Chat' 4 | import { Button } from '~/components/ui/button' 5 | import { convexQuery, useConvexMutation } from '@convex-dev/react-query' 6 | import { api } from 'convex/_generated/api' 7 | import { useQuery, useSuspenseQuery } from '@tanstack/react-query' 8 | import { ReloadIcon } from '@radix-ui/react-icons' 9 | 10 | export const Route = createFileRoute('/ssr')({ 11 | component: LiveQueriesSSR, 12 | }) 13 | 14 | export default function LiveQueriesSSR() { 15 | const sendTraffic = useConvexMutation(api.messages.simulateTraffic) 16 | const { data: simulationRunning } = useSuspenseQuery({ 17 | ...convexQuery(api.messages.isSimulatingTraffic, {}), 18 | gcTime: 2000, 19 | }) 20 | 21 | return ( 22 | <> 23 |

Server-Side Rendering and Live Queries

24 |

25 | TanStack Start routes render on the server for the first pageload of a 26 | browsing session. Neither the React Query nor standard Convex{' '} 27 | useQuery() hooks kick off requests for this data during 28 | this initial SSR pass, but the React Query hook{' '} 29 | useSuspenseQuery() does. The React Query client is then 30 | serialized with whatever data was loaded to make it available in the 31 | browser at hydration time. This reduces rendering on the server and 32 | updating on the client from{' '} 33 | 34 | two steps 35 | {' '} 36 | to one step: isomorphic data fetching with a single hook. 37 |

38 |

39 | Try{' '} 40 | { 43 | e.preventDefault() 44 | window.location.reload() 45 | }} 46 | > 47 | reloading 48 | {' '} 49 | this page to see the difference between useSuspenseQuery(){' '} 50 | and useQuery(). 51 |

52 | 53 |
54 | 62 | 70 |
71 | 72 |

73 | On the browser these queries resume their subscriptions which you can 74 | see by{' '} 75 | 85 | . 86 |

87 |

88 | Another way to opt into server-side data loading is to load the query in 89 | a{' '} 90 | 91 | loader 92 | 93 | . 94 |

95 |

Learn More

96 | 97 |

98 | 99 | TanStack Start SSR Guide 100 | 101 |

102 | 103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /src/routes/loaders.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Outlet, createFileRoute } from '@tanstack/react-router' 2 | import { useState } from 'react' 3 | import { Button } from '~/components/ui/button' 4 | 5 | export const Route = createFileRoute('/loaders')({ 6 | component: BlockingAndStreaming, 7 | validateSearch: (search: Record) => { 8 | return { 9 | cacheBust: 10 | typeof search.cacheBust === 'string' ? search.cacheBust : 'abcd', 11 | } 12 | }, 13 | }) 14 | 15 | function rand() { 16 | return `cb${Math.random().toString(16).substring(2, 6)}` 17 | } 18 | 19 | export default function BlockingAndStreaming() { 20 | const { cacheBust: initialCacheBust } = Route.useSearch() 21 | const [cacheBust, setCacheBust] = useState(initialCacheBust) 22 | return ( 23 | <> 24 |

Loaders and Prefetching

25 |
26 |
27 |

28 | TanStack Start routes can have isomorphic loader functions that run 29 | on the server for the initial pageload and on the client for 30 | subsequent client-side navigations. 31 |

32 |

33 | These three links navigate to subpages that show chat messages from 34 | different channels. Notice how client navigations between these 35 | pages work differently. 36 |

37 | 38 |

39 | Awaiting ensureQueryData will block rendering of the 40 | page until that data is available and calling{' '} 41 | prefetchQuery will start the request but not block. 42 | Loaders also run during prefetching, triggered by the cursor mousing 43 | onto a link in TanStack Start by default. 44 |

45 | 46 | 86 | 87 |

When to use a loader

88 |

89 | Loading a query in a loader ejects from the convenience of 90 | component-local reasoning through useSuspenseQuery{' '} 91 | where a component fetching its own data to the cold reality of fast 92 | page loads when you need it. 93 |

94 |
95 |
96 | 97 |
98 |
99 |

Learn More

100 |

101 | Tanner's{' '} 102 | 103 | An Early Glimpse of TanStack Start 104 | {' '} 105 | video 106 |

107 |

108 | 109 | TanStack Router Preloading Guide 110 | 111 |

112 | 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createRootRouteWithContext, 3 | HeadContent, 4 | Scripts, 5 | Outlet, 6 | Link, 7 | } from '@tanstack/react-router' 8 | import * as React from 'react' 9 | import appCss from '~/styles/app.css?url' 10 | import { cn } from '~/lib/utils' 11 | import { QueryClient } from '@tanstack/react-query' 12 | 13 | export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()( 14 | { 15 | head: () => ({ 16 | meta: [ 17 | { 18 | charSet: 'utf-8', 19 | }, 20 | { 21 | name: 'viewport', 22 | content: 'width=device-width, initial-scale=1', 23 | }, 24 | { 25 | title: 'TanStack Start Starter', 26 | }, 27 | ], 28 | links: [{ rel: 'stylesheet', href: appCss }], 29 | }), 30 | component: RootComponent, 31 | }, 32 | ) 33 | 34 | function RootComponent() { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | function RootDocument({ children }: { children: React.ReactNode }) { 43 | return ( 44 | 45 | 46 | 47 | 48 | 49 | {children} 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | export default function RootLayout({ 57 | children, 58 | }: { 59 | children: React.ReactNode 60 | }) { 61 | const baseClasses = 'pb-1 font-medium px-3 py-2 transition-colors rounded-md' 62 | const activeProps = { 63 | className: cn(baseClasses, 'bg-slate-700'), 64 | } as const 65 | const inactiveProps = { 66 | className: cn(baseClasses, 'hover:bg-slate-800'), 67 | } as const 68 | const linkProps = { inactiveProps, activeProps } as const 69 | return ( 70 |
71 |
72 |
73 | 74 |

Convex with TanStack Start

75 | 76 | 122 |
123 |
124 |
125 | {children} 126 |
127 |
128 | ) 129 | } 130 | -------------------------------------------------------------------------------- /src/components/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { 3 | Card, 4 | CardContent, 5 | CardFooter, 6 | CardHeader, 7 | } from '../components/ui/card' 8 | import { Button } from '../components/ui/button' 9 | import { Input } from '../components/ui/input' 10 | import { PaperPlaneIcon } from '@radix-ui/react-icons' 11 | import { useQuery, useSuspenseQuery } from '@tanstack/react-query' 12 | import { convexQuery, useConvexMutation } from '@convex-dev/react-query' 13 | import { api } from 'convex/_generated/api' 14 | import { Skeleton } from './ui/skeleton' 15 | import CodeSample from '~/components/CodeSample' 16 | 17 | function serverTimeFormat(ms: number): string { 18 | return new Intl.DateTimeFormat('en-US', { 19 | timeZone: 'America/Los_Angeles', 20 | dateStyle: 'short', 21 | timeStyle: 'short', 22 | }).format(new Date(ms)) 23 | } 24 | function clientTimeFormat(ms: number): string { 25 | const formatter = new Intl.DateTimeFormat('en-US', { 26 | dateStyle: 'short', 27 | timeStyle: 'short', 28 | timeZone: 'America/New_York', 29 | }) 30 | return formatter.format(new Date(ms)) 31 | } 32 | const Message = ({ 33 | user, 34 | body, 35 | _creationTime, 36 | }: { 37 | user: string 38 | body: string 39 | _creationTime: number 40 | }) => { 41 | const [timestamp, setTimestamp] = useState() 42 | useEffect(() => { 43 | setTimestamp(clientTimeFormat(_creationTime)) 44 | }, [_creationTime]) 45 | return ( 46 |
47 |
48 | {user.toLowerCase().startsWith('user ') 49 | ? user[5] 50 | : user[0].toUpperCase()} 51 |
52 |
53 |
54 | {user} 55 | 58 | {timestamp || serverTimeFormat(_creationTime)} 59 | 60 |
61 |

{body}

62 |
63 |
64 | ) 65 | } 66 | 67 | const MessageSkeleton = () => ( 68 |
69 | 70 |
71 |
72 | 73 | 74 |
75 | 76 |
77 |
78 | ) 79 | 80 | export default function Component({ 81 | useSuspense, 82 | codeToShow, 83 | channel = 'chatty', 84 | gcTime = 10000, 85 | cacheBust, 86 | }: { 87 | useSuspense: boolean 88 | codeToShow?: string 89 | channel?: string 90 | gcTime?: number 91 | cacheBust?: any 92 | }) { 93 | const useWhicheverQuery: typeof useQuery = useSuspense 94 | ? (useSuspenseQuery as typeof useQuery) 95 | : useQuery 96 | 97 | const { data, isPending, error } = useWhicheverQuery({ 98 | ...convexQuery(api.messages.listMessages, { 99 | channel, 100 | ...(cacheBust ? { cacheBust } : {}), 101 | }), 102 | gcTime, 103 | }) 104 | 105 | const [name] = useState(() => 'User ' + Math.floor(Math.random() * 10000)) 106 | const [newMessage, setNewMessage] = useState('') 107 | const sendMessage = useConvexMutation(api.messages.sendMessage) 108 | 109 | const handleSendMessage = async () => { 110 | if (newMessage.trim()) { 111 | await sendMessage({ user: name, body: newMessage, channel }) 112 | setNewMessage('') 113 | } 114 | } 115 | 116 | const code = codeToShow && 117 | 118 | const input = ( 119 |
120 |
121 | setNewMessage(e.target.value)} 126 | onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()} 127 | /> 128 | 131 |
132 | {newMessage !== '' && ( 133 | 134 | As this is a public demo, your message will be replaced with 135 | system-generated text. 136 | 137 | )} 138 |
139 | ) 140 | 141 | return ( 142 | <> 143 | 144 | 145 | {isPending || error ? ( 146 | <> 147 | 148 | 149 | 150 | 151 | ) : ( 152 | data.map((msg) => ( 153 | 159 | )) 160 | )} 161 | 162 | {code ? {input} : null} 163 | {code ? code : input} 164 | 165 | 166 | ) 167 | } 168 | -------------------------------------------------------------------------------- /src/routes/react-query.tsx: -------------------------------------------------------------------------------- 1 | import { Link, createFileRoute } from '@tanstack/react-router' 2 | import CodeSample from '~/components/CodeSample' 3 | import Chat from '~/components/Chat' 4 | import { Button } from '~/components/ui/button' 5 | import { convexQuery, useConvexMutation } from '@convex-dev/react-query' 6 | import { api } from 'convex/_generated/api' 7 | import { useSuspenseQuery } from '@tanstack/react-query' 8 | import { ReloadIcon } from '@radix-ui/react-icons' 9 | 10 | export const Route = createFileRoute('/react-query')({ 11 | component: ReactQuery, 12 | loader: ({ context }) => { 13 | context.queryClient.prefetchQuery({ 14 | ...convexQuery(api.messages.isSimulatingTraffic, {}), 15 | gcTime: 2000, 16 | }) 17 | }, 18 | }) 19 | 20 | export default function ReactQuery() { 21 | const sendTraffic = useConvexMutation(api.messages.simulateTraffic) 22 | const { data: simulationRunning } = useSuspenseQuery({ 23 | ...convexQuery(api.messages.isSimulatingTraffic, {}), 24 | gcTime: 2000, 25 | }) 26 | return ( 27 | <> 28 |

Using React Query hooks for data from Convex

29 |
30 |
31 |

32 | TanStack Start apps can use React Query (TanStack Query for React) 33 | hooks to take advantage of React Query's excellent Start 34 | integration. Convex queries are exposed through{' '} 35 | 36 | query options factories 37 | {' '} 38 | like{' '} 39 | 40 | convexQuery() 41 | 42 | . 43 |

44 |

45 | Instead of React Query's standard interval and activity-based 46 | polling and manual invalidation, updates are streamed down WebSocket 47 | to update the Query Cache directly for live, always-up-to-date 48 | results. 49 |

50 |

51 | Open this page in another tab or on your phone and send a message or{' '} 52 | {' '} 62 | to see these updates pushed live.{' '} 63 |

64 | 65 |
66 |
67 | 90 |
91 |
92 | 93 |

Already used Convex React hooks?

94 |

95 | If you've used Convex React hooks React Query hooks are going to 96 | familiar. Instead of returning the data directly, this{' '} 97 | useQuery() returns an object with a .data{' '} 98 | property along with other metadata. The hook accepts a single object, 99 | which you'll mostly populate with the return value of the{' '} 100 | convexQuery() hook. 101 |

102 |

103 | The same React Query hooks can be used for fetch endpoints and Convex 104 | actions for standard interval or activity-based polling while Convex 105 | queries benefit from the live update behavior. 106 |

107 |

108 | What does this give you? It's not just the often-asked-for{' '} 109 | isLoading and error properties or simple 110 | interop with other server endpoints or introspection from the TanStack 111 | Query devtools. It's React Query's Router integration with the TanStack 112 | Start providing things like{' '} 113 | server-side rendering and live updating queries{' '} 114 | in a single hook. 115 |

116 | 117 |

Learn More

118 |

119 | 120 | TanStack Query (AKA React Query) docs 121 | 122 |

123 |

124 | Using{' '} 125 | 126 | TanStack Query with Convex 127 | 128 |

129 |

130 | The{' '} 131 | 132 | @convex-dev/react-query 133 | {' '} 134 | library links the TanStack Query Client with the Convex client 135 |

136 | 137 | ) 138 | } 139 | -------------------------------------------------------------------------------- /src/routes/gcTime.tsx: -------------------------------------------------------------------------------- 1 | import { Link, createFileRoute } from '@tanstack/react-router' 2 | import CodeSample from '~/components/CodeSample' 3 | import Chat from '~/components/Chat' 4 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools/production' 5 | import { useState } from 'react' 6 | import { Button } from '../components/ui/button' 7 | import { Card, CardContent, CardHeader } from '../components/ui/card' 8 | 9 | export const Route = createFileRoute('/gcTime')({ 10 | component: QueryCaching, 11 | }) 12 | 13 | export default function QueryCaching() { 14 | const [channel, setChannel] = useState('sf') 15 | const [open, setOpen] = useState(false) 16 | if (typeof window !== 'undefined') { 17 | ;(window as any).setOpen = setOpen 18 | } 19 | 20 | return ( 21 | <> 22 |

Maintaining subscriptions to queries

23 |
24 |
25 |

26 | When the last component subscribed to a given Convex query unmounts 27 | that subscription can be dropped. But it's often useful to keep that 28 | subscription around for while. 29 |

30 |

31 | For non-Convex query function the React Query option{' '} 32 | 33 | gcTime 34 | {' '} 35 | is the length of time to hold on to a stale result before dropping 36 | it out of cache. Convex queries use the same parameter to mean how 37 | long to stay subscribed to the query. This way Convex query results 38 | are always consistent, are never stale. 39 |

40 |

41 | {' '} 59 | to see these query subscriptions stick around as you click between 60 | chat channels. These queries use a gcTime of 10 61 | seconds. If you want subscriptions to be dropped immediately use a{' '} 62 | gcTime of 0. 63 |

64 |
65 | 66 | 67 |
68 | 78 | 88 | 98 |
99 |
100 | 101 |
102 | 103 |
104 |
105 |
106 |
107 |

108 | The default gcTime in React Query is five minutes and 109 | this is not currently overridden in the Convex query options 110 | factories like convexQuery(), but this may change in 111 | future versions of the integration. You can always override this 112 | value by spreading the query options into a new options object. 113 |

114 |

115 | Since client-side navigations in TanStack Start preserve the Query 116 | Client, these query subscriptions remain active from previous pages 117 | as well. When debugging why data is loaded or not it's good to keep 118 | this in mind. To get more prescriptive about data being available 119 | ahead of time you might add the query to a{' '} 120 | 121 | loader 122 | 123 | . 124 |

125 |
126 | 127 | 141 |
142 | 143 | 144 | ) 145 | } 146 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link, createFileRoute } from '@tanstack/react-router' 2 | import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card' 3 | import { ExternalLinkIcon } from '@radix-ui/react-icons' 4 | 5 | export const Route = createFileRoute('/')({ 6 | component: LandingPage, 7 | }) 8 | 9 | export default function LandingPage() { 10 | const tools = [ 11 | { 12 | name: 'TanStack Start', 13 | description: 14 | 'A new React framework focused on routing, caching, and type safety.', 15 | href: 'https://tanstack.com/start', 16 | }, 17 | { 18 | name: 'Convex', 19 | description: 20 | 'A typesafe database that live-updates and automatically invalidates queries.', 21 | href: 'https://www.convex.dev/', 22 | }, 23 | { 24 | name: 'TanStack Query', 25 | description: 26 | 'Asynchronous state management for server-side state like queries and mutations. AKA React Query.', 27 | href: 'https://tanstack.com/query', 28 | }, 29 | ] 30 | 31 | const features = [ 32 | { 33 | title: 'Using React Query hooks', 34 | description: 'Supercharging React Query with live updates from Convex.', 35 | link: '/react-query', 36 | }, 37 | { 38 | title: 'Render on the server, update in the browser', 39 | description: 40 | 'Hydrating the Query Client in the browser makes SSR take no extra steps.', 41 | link: '/ssr', 42 | }, 43 | { 44 | title: 'Staying subscribed to queries', 45 | description: "Data not currently rendered doesn't need to be stale.", 46 | link: '/gcTime', 47 | }, 48 | { 49 | title: 'Loaders and prefetching', 50 | description: 51 | 'Adding queries to loaders enables prefetching and can prevent waterfalls.', 52 | link: '/loaders', 53 | }, 54 | ] 55 | 56 | return ( 57 |
58 |
59 |

60 | TanStack Start, TanStack 61 | Query, and{' '} 62 | Convex 63 |

64 |
65 |

66 | TanStack Start is coming. The best way to use Convex with Start is 67 | via React Query's excellent Start integration. This site is{' '} 68 | 69 | written with Start 70 | {' '} 71 | using this setup. 72 |

73 | 74 |

75 | You can jump straight to the quickstart or read more to learn about 76 | using Convex with Start. There's a lot more to TanStack Start so 77 | also check out the{' '} 78 | 79 | official Start guide 80 | 81 | .{' '} 82 |

83 |
84 |
85 |

86 | Try out TanStack Start with Convex: 87 |

88 |

89 | 90 | Convex TanStack Quickstart 91 | 92 |

93 |

Or run:

94 |

95 | 96 | npm create convex@latest -- -t tanstack-start 97 | 98 |

99 |
100 |
101 |
102 | {features.map((feature, index) => ( 103 | 104 | 105 | 106 | 107 | {feature.title} 108 | 109 | 110 | 111 | {feature.description} 112 | 113 | 114 | 115 | ))} 116 |
117 |
118 |

Read more about these projects:

119 | 143 |
144 |
145 | ) 146 | } 147 | -------------------------------------------------------------------------------- /convex/_generated/server.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { 12 | ActionBuilder, 13 | HttpActionBuilder, 14 | MutationBuilder, 15 | QueryBuilder, 16 | GenericActionCtx, 17 | GenericMutationCtx, 18 | GenericQueryCtx, 19 | GenericDatabaseReader, 20 | GenericDatabaseWriter, 21 | } from "convex/server"; 22 | import type { DataModel } from "./dataModel.js"; 23 | 24 | /** 25 | * Define a query in this Convex app's public API. 26 | * 27 | * This function will be allowed to read your Convex database and will be accessible from the client. 28 | * 29 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 30 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 31 | */ 32 | export declare const query: QueryBuilder; 33 | 34 | /** 35 | * Define a query that is only accessible from other Convex functions (but not from the client). 36 | * 37 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 38 | * 39 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 40 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 41 | */ 42 | export declare const internalQuery: QueryBuilder; 43 | 44 | /** 45 | * Define a mutation in this Convex app's public API. 46 | * 47 | * This function will be allowed to modify your Convex database and will be accessible from the client. 48 | * 49 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 50 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 51 | */ 52 | export declare const mutation: MutationBuilder; 53 | 54 | /** 55 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 56 | * 57 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 58 | * 59 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 60 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 61 | */ 62 | export declare const internalMutation: MutationBuilder; 63 | 64 | /** 65 | * Define an action in this Convex app's public API. 66 | * 67 | * An action is a function which can execute any JavaScript code, including non-deterministic 68 | * code and code with side-effects, like calling third-party services. 69 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 70 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 71 | * 72 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 73 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 74 | */ 75 | export declare const action: ActionBuilder; 76 | 77 | /** 78 | * Define an action that is only accessible from other Convex functions (but not from the client). 79 | * 80 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 81 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 82 | */ 83 | export declare const internalAction: ActionBuilder; 84 | 85 | /** 86 | * Define an HTTP action. 87 | * 88 | * This function will be used to respond to HTTP requests received by a Convex 89 | * deployment if the requests matches the path and method where this action 90 | * is routed. Be sure to route your action in `convex/http.js`. 91 | * 92 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 93 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 94 | */ 95 | export declare const httpAction: HttpActionBuilder; 96 | 97 | /** 98 | * A set of services for use within Convex query functions. 99 | * 100 | * The query context is passed as the first argument to any Convex query 101 | * function run on the server. 102 | * 103 | * This differs from the {@link MutationCtx} because all of the services are 104 | * read-only. 105 | */ 106 | export type QueryCtx = GenericQueryCtx; 107 | 108 | /** 109 | * A set of services for use within Convex mutation functions. 110 | * 111 | * The mutation context is passed as the first argument to any Convex mutation 112 | * function run on the server. 113 | */ 114 | export type MutationCtx = GenericMutationCtx; 115 | 116 | /** 117 | * A set of services for use within Convex action functions. 118 | * 119 | * The action context is passed as the first argument to any Convex action 120 | * function run on the server. 121 | */ 122 | export type ActionCtx = GenericActionCtx; 123 | 124 | /** 125 | * An interface to read from the database within Convex query functions. 126 | * 127 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 128 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 129 | * building a query. 130 | */ 131 | export type DatabaseReader = GenericDatabaseReader; 132 | 133 | /** 134 | * An interface to read from and write to the database within Convex mutation 135 | * functions. 136 | * 137 | * Convex guarantees that all writes within a single mutation are 138 | * executed atomically, so you never have to worry about partial writes leaving 139 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 140 | * for the guarantees Convex provides your functions. 141 | */ 142 | export type DatabaseWriter = GenericDatabaseWriter; 143 | -------------------------------------------------------------------------------- /convex/messages.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MutationCtx, 3 | QueryCtx, 4 | action, 5 | internalMutation, 6 | mutation, 7 | } from './_generated/server' 8 | import { query } from './_generated/server' 9 | import { api, internal } from './_generated/api.js' 10 | import { v } from 'convex/values' 11 | 12 | export const list = query(async (ctx, { cacheBust }) => { 13 | const _unused = cacheBust 14 | return await ctx.db.query('messages').collect() 15 | }) 16 | 17 | export const listMessages = query({ 18 | args: { 19 | cacheBust: v.optional(v.any()), 20 | channel: v.optional(v.string()), 21 | }, 22 | handler: async (ctx, args) => { 23 | const _unused = args.cacheBust 24 | const channelName = args.channel || 'chatty' 25 | return await latestMessagesFromChannel(ctx, channelName) 26 | }, 27 | }) 28 | 29 | async function channelByName(ctx: QueryCtx, channelName: string) { 30 | const channel = await ctx.db 31 | .query('channels') 32 | .withIndex('by_name', (q) => q.eq('name', channelName)) 33 | .unique() 34 | if (!channel) throw new Error(`No such channel '${channelName}'`) 35 | return channel 36 | } 37 | 38 | async function latestMessagesFromChannel( 39 | ctx: QueryCtx, 40 | channelName: string, 41 | max = 20, 42 | ) { 43 | const channel = await channelByName(ctx, channelName) 44 | 45 | const messages = await ctx.db 46 | .query('messages') 47 | .withIndex('by_channel', (q) => q.eq('channel', channel._id)) 48 | .order('desc') 49 | .take(max) 50 | const messagesWithAuthor = await Promise.all( 51 | messages.map(async (message) => { 52 | const user = await ctx.db.get(message.user) 53 | // Join the count of likes with the message data 54 | return { ...message, user: user?.name || 'anonymous' } 55 | }), 56 | ) 57 | return messagesWithAuthor 58 | } 59 | 60 | export const count = query( 61 | async ( 62 | ctx, 63 | { cacheBust, channel }: { cacheBust: unknown; channel: string }, 64 | ) => { 65 | const _unused = cacheBust 66 | const channelName = channel || 'chatty' 67 | return (await latestMessagesFromChannel(ctx, channelName, 1000)).length 68 | }, 69 | ) 70 | 71 | export const listUsers = query(async (ctx, { cacheBust }) => { 72 | const _unused = cacheBust 73 | return await ctx.db.query('users').collect() 74 | }) 75 | 76 | export const countUsers = query(async (ctx, { cacheBust }) => { 77 | const _unused = cacheBust 78 | return (await ctx.db.query('users').collect()).length 79 | }) 80 | 81 | function choose(choices: string[]): string { 82 | return choices[Math.floor(Math.random() * choices.length)] 83 | } 84 | 85 | function madlib(strings: TemplateStringsArray, ...choices: any[]): string { 86 | return strings.reduce((result, str, i) => { 87 | return result + str + (choices[i] ? choose(choices[i]) : '') 88 | }, '') 89 | } 90 | 91 | const greetings = ['hi', 'Hi', 'hello', 'hey'] 92 | const names = ['James', 'Jamie', 'Emma', 'Nipunn'] 93 | const punc = ['...', '-', ',', '!', ';'] 94 | const text = [ 95 | 'how was your weekend?', 96 | "how's the weather in SF?", 97 | "what's your favorite ice cream place?", 98 | "I'll be late to make the meeting tomorrow morning", 99 | "Could you let the customer know we've fixed their issue?", 100 | ] 101 | 102 | export const sendGeneratedMessage = internalMutation(async (ctx) => { 103 | const body = madlib`${greetings} ${names}${punc} ${text}` 104 | const user = await ctx.db.insert('users', { 105 | name: 'User ' + Math.floor(Math.random() * 1000), 106 | }) 107 | const channel = (await channelByName(ctx, 'chatty'))._id 108 | await ctx.db.insert('messages', { body, user, channel }) 109 | }) 110 | 111 | // TODO concurrency here 112 | export const sendGeneratedMessages = action({ 113 | args: { num: v.number() }, 114 | handler: async (ctx, { num }: { num: number }) => { 115 | await ctx.runMutation(api.messages.clear) 116 | for (let i = 0; i < num; i++) { 117 | await ctx.runMutation(internal.messages.sendGeneratedMessage) 118 | } 119 | }, 120 | }) 121 | 122 | export const clear = mutation(async (ctx) => { 123 | await Promise.all([ 124 | ...(await ctx.db.query('messages').collect()).map((message) => 125 | ctx.db.delete(message._id), 126 | ), 127 | ...(await ctx.db.query('users').collect()).map((user) => 128 | ctx.db.delete(user._id), 129 | ), 130 | ...(await ctx.db.query('channels').collect()).map((channel) => 131 | ctx.db.delete(channel._id), 132 | ), 133 | ...(await ctx.db.query('channelMembers').collect()).map((membership) => 134 | ctx.db.delete(membership._id), 135 | ), 136 | ]) 137 | }) 138 | 139 | async function ensureChannel(ctx: MutationCtx, name: string) { 140 | const existing = await ctx.db 141 | .query('channels') 142 | .withIndex('by_name', (q) => q.eq('name', name)) 143 | .unique() 144 | if (!existing) { 145 | await ctx.db.insert('channels', { name }) 146 | } 147 | } 148 | 149 | export const seed = internalMutation(async (ctx) => { 150 | await ensureChannel(ctx, 'chatty') 151 | await ensureChannel(ctx, 'sf') 152 | await ensureChannel(ctx, 'nyc') 153 | await ensureChannel(ctx, 'seattle') 154 | }) 155 | 156 | export const sendMessage = mutation( 157 | async ( 158 | ctx, 159 | { 160 | user, 161 | channel = 'chatty', 162 | }: { user: string; body: string; channel: string }, 163 | ) => { 164 | // userId ought to match User /d+ 165 | // until every user gets their own channel, use simulated messages 166 | const cleanBody = madlib`${greetings} ${names}${punc} ${text}` 167 | const existingUser = await ctx.db 168 | .query('users') 169 | .withIndex('by_name') 170 | .filter((q) => q.eq(q.field('name'), user)) 171 | .unique() 172 | let userId = 173 | existingUser?._id || (await ctx.db.insert('users', { name: user })) 174 | const channelId = (await channelByName(ctx, channel))._id 175 | await ctx.db.insert('messages', { 176 | user: userId, 177 | body: cleanBody, 178 | channel: channelId, 179 | }) 180 | }, 181 | ) 182 | 183 | export const simulateTraffic = mutation(async (ctx) => { 184 | const simulation = await ctx.db.query('simulating').unique() 185 | const now = Date.now() 186 | const duration = 5000 187 | if (!simulation) { 188 | await ctx.db.insert('simulating', { 189 | finishingAt: now + duration, 190 | }) 191 | await ctx.scheduler.runAfter(0, internal.messages.runSimulation) 192 | } else { 193 | await ctx.db.replace(simulation._id, { 194 | finishingAt: Math.max(simulation.finishingAt, now + duration), 195 | }) 196 | } 197 | }) 198 | 199 | export const runSimulation = internalMutation(async (ctx) => { 200 | const now = Date.now() 201 | const simulation = await ctx.db.query('simulating').unique() 202 | if (!simulation) { 203 | return 204 | } 205 | if (simulation.finishingAt < now) { 206 | await ctx.db.delete(simulation._id) 207 | return 208 | } 209 | const body = madlib`${greetings} ${names}${punc} ${text}` 210 | const user = await ctx.db.insert('users', { 211 | name: 'User ' + Math.floor(Math.random() * 1000), 212 | }) 213 | const channel = (await channelByName(ctx, 'chatty'))._id 214 | await ctx.db.insert('messages', { body, user: user, channel }) 215 | await ctx.scheduler.runAfter(500, internal.messages.runSimulation) 216 | }) 217 | 218 | export const isSimulatingTraffic = query(async (ctx) => { 219 | return !!(await ctx.db.query('simulating').collect()).length 220 | }) 221 | -------------------------------------------------------------------------------- /src/routeTree.gen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // @ts-nocheck 4 | 5 | // noinspection JSUnusedGlobalSymbols 6 | 7 | // This file was automatically generated by TanStack Router. 8 | // You should NOT make any changes in this file as it will be overwritten. 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 | 11 | import { Route as rootRouteImport } from './routes/__root' 12 | import { Route as SsrRouteImport } from './routes/ssr' 13 | import { Route as ReactQueryRouteImport } from './routes/react-query' 14 | import { Route as LoadersRouteImport } from './routes/loaders' 15 | import { Route as GcTimeRouteImport } from './routes/gcTime' 16 | import { Route as IndexRouteImport } from './routes/index' 17 | import { Route as LoadersPrefetchRouteImport } from './routes/loaders/prefetch' 18 | import { Route as LoadersNoLoaderRouteImport } from './routes/loaders/no-loader' 19 | import { Route as LoadersEnsureRouteImport } from './routes/loaders/ensure' 20 | 21 | const SsrRoute = SsrRouteImport.update({ 22 | id: '/ssr', 23 | path: '/ssr', 24 | getParentRoute: () => rootRouteImport, 25 | } as any) 26 | const ReactQueryRoute = ReactQueryRouteImport.update({ 27 | id: '/react-query', 28 | path: '/react-query', 29 | getParentRoute: () => rootRouteImport, 30 | } as any) 31 | const LoadersRoute = LoadersRouteImport.update({ 32 | id: '/loaders', 33 | path: '/loaders', 34 | getParentRoute: () => rootRouteImport, 35 | } as any) 36 | const GcTimeRoute = GcTimeRouteImport.update({ 37 | id: '/gcTime', 38 | path: '/gcTime', 39 | getParentRoute: () => rootRouteImport, 40 | } as any) 41 | const IndexRoute = IndexRouteImport.update({ 42 | id: '/', 43 | path: '/', 44 | getParentRoute: () => rootRouteImport, 45 | } as any) 46 | const LoadersPrefetchRoute = LoadersPrefetchRouteImport.update({ 47 | id: '/prefetch', 48 | path: '/prefetch', 49 | getParentRoute: () => LoadersRoute, 50 | } as any) 51 | const LoadersNoLoaderRoute = LoadersNoLoaderRouteImport.update({ 52 | id: '/no-loader', 53 | path: '/no-loader', 54 | getParentRoute: () => LoadersRoute, 55 | } as any) 56 | const LoadersEnsureRoute = LoadersEnsureRouteImport.update({ 57 | id: '/ensure', 58 | path: '/ensure', 59 | getParentRoute: () => LoadersRoute, 60 | } as any) 61 | 62 | export interface FileRoutesByFullPath { 63 | '/': typeof IndexRoute 64 | '/gcTime': typeof GcTimeRoute 65 | '/loaders': typeof LoadersRouteWithChildren 66 | '/react-query': typeof ReactQueryRoute 67 | '/ssr': typeof SsrRoute 68 | '/loaders/ensure': typeof LoadersEnsureRoute 69 | '/loaders/no-loader': typeof LoadersNoLoaderRoute 70 | '/loaders/prefetch': typeof LoadersPrefetchRoute 71 | } 72 | export interface FileRoutesByTo { 73 | '/': typeof IndexRoute 74 | '/gcTime': typeof GcTimeRoute 75 | '/loaders': typeof LoadersRouteWithChildren 76 | '/react-query': typeof ReactQueryRoute 77 | '/ssr': typeof SsrRoute 78 | '/loaders/ensure': typeof LoadersEnsureRoute 79 | '/loaders/no-loader': typeof LoadersNoLoaderRoute 80 | '/loaders/prefetch': typeof LoadersPrefetchRoute 81 | } 82 | export interface FileRoutesById { 83 | __root__: typeof rootRouteImport 84 | '/': typeof IndexRoute 85 | '/gcTime': typeof GcTimeRoute 86 | '/loaders': typeof LoadersRouteWithChildren 87 | '/react-query': typeof ReactQueryRoute 88 | '/ssr': typeof SsrRoute 89 | '/loaders/ensure': typeof LoadersEnsureRoute 90 | '/loaders/no-loader': typeof LoadersNoLoaderRoute 91 | '/loaders/prefetch': typeof LoadersPrefetchRoute 92 | } 93 | export interface FileRouteTypes { 94 | fileRoutesByFullPath: FileRoutesByFullPath 95 | fullPaths: 96 | | '/' 97 | | '/gcTime' 98 | | '/loaders' 99 | | '/react-query' 100 | | '/ssr' 101 | | '/loaders/ensure' 102 | | '/loaders/no-loader' 103 | | '/loaders/prefetch' 104 | fileRoutesByTo: FileRoutesByTo 105 | to: 106 | | '/' 107 | | '/gcTime' 108 | | '/loaders' 109 | | '/react-query' 110 | | '/ssr' 111 | | '/loaders/ensure' 112 | | '/loaders/no-loader' 113 | | '/loaders/prefetch' 114 | id: 115 | | '__root__' 116 | | '/' 117 | | '/gcTime' 118 | | '/loaders' 119 | | '/react-query' 120 | | '/ssr' 121 | | '/loaders/ensure' 122 | | '/loaders/no-loader' 123 | | '/loaders/prefetch' 124 | fileRoutesById: FileRoutesById 125 | } 126 | export interface RootRouteChildren { 127 | IndexRoute: typeof IndexRoute 128 | GcTimeRoute: typeof GcTimeRoute 129 | LoadersRoute: typeof LoadersRouteWithChildren 130 | ReactQueryRoute: typeof ReactQueryRoute 131 | SsrRoute: typeof SsrRoute 132 | } 133 | 134 | declare module '@tanstack/react-router' { 135 | interface FileRoutesByPath { 136 | '/ssr': { 137 | id: '/ssr' 138 | path: '/ssr' 139 | fullPath: '/ssr' 140 | preLoaderRoute: typeof SsrRouteImport 141 | parentRoute: typeof rootRouteImport 142 | } 143 | '/react-query': { 144 | id: '/react-query' 145 | path: '/react-query' 146 | fullPath: '/react-query' 147 | preLoaderRoute: typeof ReactQueryRouteImport 148 | parentRoute: typeof rootRouteImport 149 | } 150 | '/loaders': { 151 | id: '/loaders' 152 | path: '/loaders' 153 | fullPath: '/loaders' 154 | preLoaderRoute: typeof LoadersRouteImport 155 | parentRoute: typeof rootRouteImport 156 | } 157 | '/gcTime': { 158 | id: '/gcTime' 159 | path: '/gcTime' 160 | fullPath: '/gcTime' 161 | preLoaderRoute: typeof GcTimeRouteImport 162 | parentRoute: typeof rootRouteImport 163 | } 164 | '/': { 165 | id: '/' 166 | path: '/' 167 | fullPath: '/' 168 | preLoaderRoute: typeof IndexRouteImport 169 | parentRoute: typeof rootRouteImport 170 | } 171 | '/loaders/prefetch': { 172 | id: '/loaders/prefetch' 173 | path: '/prefetch' 174 | fullPath: '/loaders/prefetch' 175 | preLoaderRoute: typeof LoadersPrefetchRouteImport 176 | parentRoute: typeof LoadersRoute 177 | } 178 | '/loaders/no-loader': { 179 | id: '/loaders/no-loader' 180 | path: '/no-loader' 181 | fullPath: '/loaders/no-loader' 182 | preLoaderRoute: typeof LoadersNoLoaderRouteImport 183 | parentRoute: typeof LoadersRoute 184 | } 185 | '/loaders/ensure': { 186 | id: '/loaders/ensure' 187 | path: '/ensure' 188 | fullPath: '/loaders/ensure' 189 | preLoaderRoute: typeof LoadersEnsureRouteImport 190 | parentRoute: typeof LoadersRoute 191 | } 192 | } 193 | } 194 | 195 | interface LoadersRouteChildren { 196 | LoadersEnsureRoute: typeof LoadersEnsureRoute 197 | LoadersNoLoaderRoute: typeof LoadersNoLoaderRoute 198 | LoadersPrefetchRoute: typeof LoadersPrefetchRoute 199 | } 200 | 201 | const LoadersRouteChildren: LoadersRouteChildren = { 202 | LoadersEnsureRoute: LoadersEnsureRoute, 203 | LoadersNoLoaderRoute: LoadersNoLoaderRoute, 204 | LoadersPrefetchRoute: LoadersPrefetchRoute, 205 | } 206 | 207 | const LoadersRouteWithChildren = 208 | LoadersRoute._addFileChildren(LoadersRouteChildren) 209 | 210 | const rootRouteChildren: RootRouteChildren = { 211 | IndexRoute: IndexRoute, 212 | GcTimeRoute: GcTimeRoute, 213 | LoadersRoute: LoadersRouteWithChildren, 214 | ReactQueryRoute: ReactQueryRoute, 215 | SsrRoute: SsrRoute, 216 | } 217 | export const routeTree = rootRouteImport 218 | ._addFileChildren(rootRouteChildren) 219 | ._addFileTypes() 220 | 221 | import type { getRouter } from './router.tsx' 222 | import type { createStart } from '@tanstack/react-start' 223 | declare module '@tanstack/react-start' { 224 | interface Register { 225 | ssr: true 226 | router: Awaited> 227 | } 228 | } 229 | --------------------------------------------------------------------------------