├── .prettierrc ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png └── manifest.json ├── src ├── lib │ ├── auth-client.ts │ ├── trpc-client.ts │ ├── trpc.ts │ ├── trpc │ │ ├── users.ts │ │ ├── todos.ts │ │ └── projects.ts │ ├── auth.ts │ └── collections.ts ├── db │ ├── connection.ts │ ├── out │ │ ├── meta │ │ │ ├── _journal.json │ │ │ └── 0000_snapshot.json │ │ └── 0000_slimy_frank_castle.sql │ ├── auth-schema.ts │ └── schema.ts ├── routes │ ├── api │ │ ├── auth.ts │ │ ├── trpc │ │ │ └── $.ts │ │ ├── users.ts │ │ ├── todos.ts │ │ └── projects.ts │ ├── __root.tsx │ ├── _authenticated │ │ ├── index.tsx │ │ └── project │ │ │ └── $projectId.tsx │ ├── login.tsx │ └── _authenticated.tsx ├── styles.css ├── router.tsx ├── vite-plugin-caddy.ts └── routeTree.gen.ts ├── .gitignore ├── .env.example ├── .vscode └── settings.json ├── Caddyfile ├── .cta.json ├── drizzle.config.ts ├── vite.config.ts ├── tsconfig.json ├── docker-compose.yaml ├── eslint.config.mjs ├── package.json ├── AGENT.md └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleAMathews/tanstack-start-db-electric-starter/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleAMathews/tanstack-start-db-electric-starter/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KyleAMathews/tanstack-start-db-electric-starter/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/lib/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/react" 2 | export const authClient = createAuthClient() 3 | -------------------------------------------------------------------------------- /src/db/connection.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config" 2 | import { drizzle } from "drizzle-orm/node-postgres" 3 | 4 | export const db = drizzle(process.env.DATABASE_URL!, { casing: `snake_case` }) 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | count.txt 7 | .env 8 | .nitro 9 | .tanstack 10 | .output 11 | .vinxi 12 | count.txt 13 | CLAUDE.md 14 | Caddyfile 15 | .claude 16 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Database connection URL for PostgreSQL 2 | # This should match the database configuration in docker-compose.yaml 3 | DATABASE_URL=postgresql://postgres:password@localhost:54321/electric 4 | 5 | # Create a secret for better-auth 6 | BETTER_AUTH_SECRET= 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/routeTree.gen.ts": true 4 | }, 5 | "search.exclude": { 6 | "**/routeTree.gen.ts": true 7 | }, 8 | "files.readonlyInclude": { 9 | "**/routeTree.gen.ts": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | tanstack-start-db-electric-starter.localhost { 2 | reverse_proxy localhost:5173 3 | encode { 4 | gzip 5 | } 6 | } 7 | 8 | # Network access 9 | 192.168.1.238 { 10 | reverse_proxy localhost:5173 11 | encode { 12 | gzip 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/db/out/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1752697558683, 9 | "tag": "0000_slimy_frank_castle", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /.cta.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "tanstack-start-db-electric-starter", 3 | "mode": "file-router", 4 | "typescript": true, 5 | "tailwind": true, 6 | "packageManager": "npm", 7 | "git": true, 8 | "version": 1, 9 | "framework": "react-cra", 10 | "chosenAddOns": ["start"] 11 | } 12 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config" 2 | import { defineConfig } from "drizzle-kit" 3 | 4 | export default defineConfig({ 5 | out: `./src/db/out`, 6 | schema: `./src/db/schema.ts`, 7 | dialect: `postgresql`, 8 | casing: `snake_case`, 9 | dbCredentials: { 10 | url: process.env.DATABASE_URL!, 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /src/lib/trpc-client.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCProxyClient, httpBatchLink } from "@trpc/client" 2 | import type { AppRouter } from "@/routes/api/trpc/$" 3 | 4 | export const trpc = createTRPCProxyClient({ 5 | links: [ 6 | httpBatchLink({ 7 | url: "/api/trpc", 8 | async headers() { 9 | return { 10 | cookie: typeof document !== "undefined" ? document.cookie : "", 11 | } 12 | }, 13 | }), 14 | ], 15 | }) 16 | -------------------------------------------------------------------------------- /src/routes/api/auth.ts: -------------------------------------------------------------------------------- 1 | import { createServerFileRoute } from "@tanstack/react-start/server" 2 | import { auth } from "@/lib/auth" 3 | 4 | const serve = ({ request }: { request: Request }) => { 5 | return auth.handler(request) 6 | } 7 | 8 | export const ServerRoute = createServerFileRoute("/api/auth").methods({ 9 | GET: serve, 10 | POST: serve, 11 | PUT: serve, 12 | DELETE: serve, 13 | PATCH: serve, 14 | OPTIONS: serve, 15 | HEAD: serve, 16 | }) 17 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | body { 4 | @apply m-0; 5 | font-family: 6 | -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", 7 | "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | } 11 | 12 | code { 13 | font-family: 14 | source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; 15 | } 16 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "TanStack App", 3 | "name": "Create TanStack App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/router.tsx: -------------------------------------------------------------------------------- 1 | import { createRouter as createTanstackRouter } from "@tanstack/react-router" 2 | 3 | // Import the generated route tree 4 | import { routeTree } from "./routeTree.gen" 5 | 6 | import "./styles.css" 7 | 8 | // Create a new router instance 9 | export const createRouter = () => { 10 | const router = createTanstackRouter({ 11 | routeTree, 12 | scrollRestoration: true, 13 | defaultPreloadStaleTime: 0, 14 | }) 15 | 16 | return router 17 | } 18 | 19 | // Register the router instance for type safety 20 | declare module "@tanstack/react-router" { 21 | interface Register { 22 | router: ReturnType 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite" 2 | import { tanstackStart } from "@tanstack/react-start/plugin/vite" 3 | import viteTsConfigPaths from "vite-tsconfig-paths" 4 | import tailwindcss from "@tailwindcss/vite" 5 | import { caddyPlugin } from "./src/vite-plugin-caddy" 6 | 7 | const config = defineConfig({ 8 | server: { 9 | host: true, 10 | }, 11 | plugins: [ 12 | // this is the plugin that enables path aliases 13 | viteTsConfigPaths({ 14 | projects: [`./tsconfig.json`], 15 | }), 16 | // Local HTTPS with Caddy 17 | caddyPlugin(), 18 | tailwindcss(), 19 | tanstackStart({ 20 | spa: { 21 | enabled: true, 22 | }, 23 | }), 24 | ], 25 | }) 26 | 27 | export default config 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx"], 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 | "verbatimModuleSyntax": true, 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "skipLibCheck": true, 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true, 23 | "baseUrl": ".", 24 | "paths": { 25 | "@/*": ["./src/*"] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC, TRPCError } from "@trpc/server" 2 | import { auth } from "@/lib/auth" 3 | import { db } from "@/db/connection" 4 | 5 | export type Context = { 6 | session: Awaited> 7 | db: typeof db 8 | } 9 | 10 | const t = initTRPC.context().create() 11 | 12 | export const router = t.router 13 | export const procedure = t.procedure 14 | export const middleware = t.middleware 15 | 16 | export const isAuthed = middleware(async ({ ctx, next }) => { 17 | if (!ctx.session?.user) { 18 | throw new TRPCError({ code: "UNAUTHORIZED" }) 19 | } 20 | return next({ 21 | ctx: { 22 | ...ctx, 23 | session: ctx.session, 24 | }, 25 | }) 26 | }) 27 | 28 | export const authedProcedure = procedure.use(isAuthed) 29 | -------------------------------------------------------------------------------- /src/lib/trpc/users.ts: -------------------------------------------------------------------------------- 1 | import { router, authedProcedure } from "@/lib/trpc" 2 | import { z } from "zod" 3 | import { TRPCError } from "@trpc/server" 4 | 5 | export const usersRouter = router({ 6 | create: authedProcedure.input(z.any()).mutation(async () => { 7 | throw new TRPCError({ 8 | code: "FORBIDDEN", 9 | message: "Can't create new users through API", 10 | }) 11 | }), 12 | 13 | update: authedProcedure 14 | .input(z.object({ id: z.string(), data: z.any() })) 15 | .mutation(async () => { 16 | throw new TRPCError({ 17 | code: "FORBIDDEN", 18 | message: "Can't edit users through API", 19 | }) 20 | }), 21 | 22 | delete: authedProcedure 23 | .input(z.object({ id: z.string() })) 24 | .mutation(async () => { 25 | throw new TRPCError({ 26 | code: "FORBIDDEN", 27 | message: "Can't delete users through API", 28 | }) 29 | }), 30 | }) 31 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | name: "tanstack-start-db-electric-starter" 3 | 4 | services: 5 | postgres: 6 | image: postgres:17-alpine 7 | environment: 8 | POSTGRES_DB: electric 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: password 11 | ports: 12 | - 54321:5432 13 | volumes: 14 | - postgres_data:/var/lib/postgresql/data 15 | tmpfs: 16 | - /tmp 17 | command: 18 | - -c 19 | - listen_addresses=* 20 | - -c 21 | - wal_level=logical 22 | 23 | electric: 24 | image: electricsql/electric:latest 25 | environment: 26 | DATABASE_URL: postgresql://postgres:password@postgres:5432/electric?sslmode=disable 27 | # Not suitable for production. Only use insecure mode in development or if you've otherwise secured the Electric API. 28 | # See https://electric-sql.com/docs/guides/security 29 | ELECTRIC_INSECURE: true 30 | ports: 31 | - "3000:3000" 32 | depends_on: 33 | - postgres 34 | 35 | volumes: 36 | postgres_data: 37 | -------------------------------------------------------------------------------- /src/routes/api/trpc/$.ts: -------------------------------------------------------------------------------- 1 | import { createServerFileRoute } from '@tanstack/react-start/server' 2 | import { fetchRequestHandler } from '@trpc/server/adapters/fetch' 3 | import { router } from '@/lib/trpc' 4 | import { projectsRouter } from '@/lib/trpc/projects' 5 | import { todosRouter } from '@/lib/trpc/todos' 6 | import { usersRouter } from '@/lib/trpc/users' 7 | import { db } from '@/db/connection' 8 | import { auth } from '@/lib/auth' 9 | 10 | export const appRouter = router({ 11 | projects: projectsRouter, 12 | todos: todosRouter, 13 | users: usersRouter, 14 | }) 15 | 16 | export type AppRouter = typeof appRouter 17 | 18 | const serve = ({ request }: { request: Request }) => { 19 | return fetchRequestHandler({ 20 | endpoint: '/api/trpc', 21 | req: request, 22 | router: appRouter, 23 | createContext: async () => ({ 24 | db, 25 | session: await auth.api.getSession({ headers: request.headers }), 26 | }), 27 | }) 28 | } 29 | 30 | export const ServerRoute = createServerFileRoute('/api/trpc/$').methods({ 31 | GET: serve, 32 | POST: serve, 33 | }) -------------------------------------------------------------------------------- /src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Outlet, 3 | HeadContent, 4 | Scripts, 5 | createRootRoute, 6 | } from "@tanstack/react-router" 7 | import { TanStackRouterDevtools } from "@tanstack/react-router-devtools" 8 | 9 | import appCss from "../styles.css?url" 10 | 11 | export const Route = createRootRoute({ 12 | head: () => ({ 13 | meta: [ 14 | { 15 | charSet: `utf-8`, 16 | }, 17 | { 18 | name: `viewport`, 19 | content: `width=device-width, initial-scale=1`, 20 | }, 21 | { 22 | title: `TanStack Start/DB/Electric Starter`, 23 | }, 24 | ], 25 | links: [ 26 | { 27 | rel: `stylesheet`, 28 | href: appCss, 29 | }, 30 | ], 31 | }), 32 | 33 | component: () => ( 34 | 35 | 36 | 37 | 38 | ), 39 | }) 40 | 41 | function RootDocument({ children }: { children: React.ReactNode }) { 42 | return ( 43 | 44 | 45 | 46 | 47 | 48 | {children} 49 | 50 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/routes/_authenticated/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router" 2 | import { useLiveQuery } from "@tanstack/react-db" 3 | import { useEffect } from "react" 4 | import { projectCollection, todoCollection } from "@/lib/collections" 5 | 6 | export const Route = createFileRoute(`/_authenticated/`)({ 7 | component: IndexRedirect, 8 | ssr: false, 9 | loader: async () => { 10 | console.log(1) 11 | await projectCollection.preload() 12 | await todoCollection.preload() 13 | console.log(2) 14 | return null 15 | }, 16 | }) 17 | 18 | function IndexRedirect() { 19 | const navigate = useNavigate() 20 | const { data: projects } = useLiveQuery((q) => q.from({ projectCollection })) 21 | 22 | useEffect(() => { 23 | if (projects && projects.length > 0) { 24 | const firstProject = projects[0] 25 | navigate({ 26 | to: "/project/$projectId", 27 | params: { projectId: firstProject.id.toString() }, 28 | replace: true, 29 | }) 30 | } 31 | }, [projects, navigate]) 32 | 33 | return ( 34 |
35 |
36 |

Loading projects...

37 |
38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/routes/api/users.ts: -------------------------------------------------------------------------------- 1 | import { createServerFileRoute } from "@tanstack/react-start/server" 2 | import { auth } from "@/lib/auth" 3 | 4 | const serve = async ({ request }: { request: Request }) => { 5 | const session = await auth.api.getSession({ headers: request.headers }) 6 | if (!session) { 7 | return new Response(JSON.stringify({ error: "Unauthorized" }), { 8 | status: 401, 9 | headers: { "content-type": "application/json" }, 10 | }) 11 | } 12 | 13 | const url = new URL(request.url) 14 | const originUrl = new URL("http://localhost:3000/v1/shape") 15 | 16 | url.searchParams.forEach((value, key) => { 17 | if (["live", "table", "handle", "offset", "cursor"].includes(key)) { 18 | originUrl.searchParams.set(key, value) 19 | } 20 | }) 21 | 22 | originUrl.searchParams.set("table", "users") 23 | 24 | const response = await fetch(originUrl) 25 | const headers = new Headers(response.headers) 26 | headers.delete("content-encoding") 27 | headers.delete("content-length") 28 | 29 | return new Response(response.body, { 30 | status: response.status, 31 | statusText: response.statusText, 32 | headers, 33 | }) 34 | } 35 | 36 | export const ServerRoute = createServerFileRoute("/api/users").methods({ 37 | GET: serve, 38 | }) 39 | -------------------------------------------------------------------------------- /src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from "better-auth" 2 | import { drizzleAdapter } from "better-auth/adapters/drizzle" 3 | import { db } from "@/db/connection" // your drizzle instance 4 | import * as schema from "@/db/auth-schema" 5 | import { networkInterfaces } from "os" 6 | 7 | // Get network IP for trusted origins 8 | const nets = networkInterfaces() 9 | let networkIP = "192.168.1.1" // fallback 10 | 11 | for (const name of Object.keys(nets)) { 12 | const netInterfaces = nets[name] 13 | if (netInterfaces) { 14 | for (const net of netInterfaces) { 15 | if (net.family === "IPv4" && !net.internal) { 16 | networkIP = net.address 17 | break 18 | } 19 | } 20 | } 21 | } 22 | 23 | export const auth = betterAuth({ 24 | database: drizzleAdapter(db, { 25 | provider: "pg", 26 | usePlural: true, 27 | schema, 28 | // debugLogs: true, 29 | }), 30 | emailAndPassword: { 31 | enabled: true, 32 | // Disable signup in production, allow in dev 33 | disableSignUp: process.env.NODE_ENV === "production", 34 | minPasswordLength: process.env.NODE_ENV === "production" ? 8 : 1, 35 | }, 36 | trustedOrigins: [ 37 | "https://tanstack-start-db-electric-starter.localhost", 38 | `https://${networkIP}`, 39 | "http://localhost:5173", // fallback for direct Vite access 40 | ], 41 | }) 42 | -------------------------------------------------------------------------------- /src/routes/api/todos.ts: -------------------------------------------------------------------------------- 1 | import { createServerFileRoute } from "@tanstack/react-start/server" 2 | import { auth } from "@/lib/auth" 3 | 4 | const serve = async ({ request }: { request: Request }) => { 5 | const session = await auth.api.getSession({ headers: request.headers }) 6 | if (!session) { 7 | return new Response(JSON.stringify({ error: "Unauthorized" }), { 8 | status: 401, 9 | headers: { "content-type": "application/json" }, 10 | }) 11 | } 12 | 13 | const url = new URL(request.url) 14 | const originUrl = new URL("http://localhost:3000/v1/shape") 15 | 16 | url.searchParams.forEach((value, key) => { 17 | if (["live", "table", "handle", "offset", "cursor"].includes(key)) { 18 | originUrl.searchParams.set(key, value) 19 | } 20 | }) 21 | 22 | originUrl.searchParams.set("table", "todos") 23 | const filter = `'${session.user.id}' = ANY(user_ids)` 24 | originUrl.searchParams.set("where", filter) 25 | 26 | const response = await fetch(originUrl) 27 | const headers = new Headers(response.headers) 28 | headers.delete("content-encoding") 29 | headers.delete("content-length") 30 | 31 | return new Response(response.body, { 32 | status: response.status, 33 | statusText: response.statusText, 34 | headers, 35 | }) 36 | } 37 | 38 | export const ServerRoute = createServerFileRoute("/api/todos").methods({ 39 | GET: serve, 40 | }) 41 | -------------------------------------------------------------------------------- /src/routes/api/projects.ts: -------------------------------------------------------------------------------- 1 | import { createServerFileRoute } from "@tanstack/react-start/server" 2 | import { auth } from "@/lib/auth" 3 | 4 | const serve = async ({ request }: { request: Request }) => { 5 | const session = await auth.api.getSession({ headers: request.headers }) 6 | if (!session) { 7 | return new Response(JSON.stringify({ error: "Unauthorized" }), { 8 | status: 401, 9 | headers: { "content-type": "application/json" }, 10 | }) 11 | } 12 | 13 | const url = new URL(request.url) 14 | const originUrl = new URL("http://localhost:3000/v1/shape") 15 | 16 | url.searchParams.forEach((value, key) => { 17 | if (["live", "table", "handle", "offset", "cursor"].includes(key)) { 18 | originUrl.searchParams.set(key, value) 19 | } 20 | }) 21 | 22 | originUrl.searchParams.set("table", "projects") 23 | const filter = `owner_id = '${session.user.id}' OR '${session.user.id}' = ANY(shared_user_ids)` 24 | originUrl.searchParams.set("where", filter) 25 | 26 | const response = await fetch(originUrl) 27 | const headers = new Headers(response.headers) 28 | headers.delete("content-encoding") 29 | headers.delete("content-length") 30 | 31 | return new Response(response.body, { 32 | status: response.status, 33 | statusText: response.statusText, 34 | headers, 35 | }) 36 | } 37 | 38 | export const ServerRoute = createServerFileRoute("/api/projects").methods({ 39 | GET: serve, 40 | }) 41 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js" 2 | import tsParser from "@typescript-eslint/parser" 3 | import tsPlugin from "@typescript-eslint/eslint-plugin" 4 | import reactPlugin from "eslint-plugin-react" 5 | import prettierPlugin from "eslint-plugin-prettier" 6 | import prettierConfig from "eslint-config-prettier" 7 | import globals from "globals" 8 | import { includeIgnoreFile } from "@eslint/compat" 9 | import { fileURLToPath } from "url" 10 | 11 | const gitignorePath = fileURLToPath(new URL(".gitignore", import.meta.url)) 12 | 13 | export default [ 14 | includeIgnoreFile(gitignorePath, "Imported .gitignore patterns"), 15 | { 16 | files: ["src/**/*.{js,jsx,ts,tsx,mjs}"], 17 | languageOptions: { 18 | ecmaVersion: 2022, 19 | sourceType: `module`, 20 | parser: tsParser, 21 | parserOptions: { 22 | ecmaFeatures: { 23 | jsx: true, 24 | }, 25 | }, 26 | globals: { 27 | ...globals.browser, 28 | ...globals.es2021, 29 | ...globals.node, 30 | }, 31 | }, 32 | settings: { 33 | react: { 34 | version: `detect`, 35 | }, 36 | }, 37 | plugins: { 38 | "@typescript-eslint": tsPlugin, 39 | react: reactPlugin, 40 | prettier: prettierPlugin, 41 | }, 42 | rules: { 43 | ...js.configs.recommended.rules, 44 | ...tsPlugin.configs.recommended.rules, 45 | ...reactPlugin.configs.recommended.rules, 46 | ...prettierConfig.rules, 47 | "prettier/prettier": `error`, 48 | "react/react-in-jsx-scope": `off`, 49 | "react/jsx-uses-react": `off`, 50 | "no-undef": `off`, 51 | "@typescript-eslint/no-undef": "off", 52 | "@typescript-eslint/no-unused-vars": [ 53 | `error`, 54 | { 55 | argsIgnorePattern: `^_`, 56 | varsIgnorePattern: `^_`, 57 | destructuredArrayIgnorePattern: `^_`, 58 | caughtErrorsIgnorePattern: `^_`, 59 | }, 60 | ], 61 | }, 62 | }, 63 | ] 64 | -------------------------------------------------------------------------------- /src/db/auth-schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core" 2 | 3 | export const users = pgTable("users", { 4 | id: text("id").primaryKey(), 5 | name: text("name").notNull(), 6 | email: text("email").notNull().unique(), 7 | emailVerified: boolean("email_verified") 8 | .$defaultFn(() => false) 9 | .notNull(), 10 | image: text("image"), 11 | createdAt: timestamp("created_at") 12 | .$defaultFn(() => /* @__PURE__ */ new Date()) 13 | .notNull(), 14 | updatedAt: timestamp("updated_at") 15 | .$defaultFn(() => /* @__PURE__ */ new Date()) 16 | .notNull(), 17 | }) 18 | 19 | export const sessions = pgTable("sessions", { 20 | id: text("id").primaryKey(), 21 | expiresAt: timestamp("expires_at").notNull(), 22 | token: text("token").notNull().unique(), 23 | createdAt: timestamp("created_at").notNull(), 24 | updatedAt: timestamp("updated_at").notNull(), 25 | ipAddress: text("ip_address"), 26 | userAgent: text("user_agent"), 27 | userId: text("user_id") 28 | .notNull() 29 | .references(() => users.id, { onDelete: "cascade" }), 30 | }) 31 | 32 | export const accounts = pgTable("accounts", { 33 | id: text("id").primaryKey(), 34 | accountId: text("account_id").notNull(), 35 | providerId: text("provider_id").notNull(), 36 | userId: text("user_id") 37 | .notNull() 38 | .references(() => users.id, { onDelete: "cascade" }), 39 | accessToken: text("access_token"), 40 | refreshToken: text("refresh_token"), 41 | idToken: text("id_token"), 42 | accessTokenExpiresAt: timestamp("access_token_expires_at"), 43 | refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), 44 | scope: text("scope"), 45 | password: text("password"), 46 | createdAt: timestamp("created_at").notNull(), 47 | updatedAt: timestamp("updated_at").notNull(), 48 | }) 49 | 50 | export const verifications = pgTable("verifications", { 51 | id: text("id").primaryKey(), 52 | identifier: text("identifier").notNull(), 53 | value: text("value").notNull(), 54 | expiresAt: timestamp("expires_at").notNull(), 55 | createdAt: timestamp("created_at").$defaultFn( 56 | () => /* @__PURE__ */ new Date() 57 | ), 58 | updatedAt: timestamp("updated_at").$defaultFn( 59 | () => /* @__PURE__ */ new Date() 60 | ), 61 | }) 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tanstack-start-db-electric-starter", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "dev": "docker compose up -d && vite dev", 7 | "start": "node .output/server/index.mjs", 8 | "build": "vite build", 9 | "migrate": "drizzle-kit migrate", 10 | "serve": "vite preview", 11 | "test": "vitest run", 12 | "lint:check": "eslint .", 13 | "lint": "eslint . --fix", 14 | "format": "prettier --write .", 15 | "format:check": "prettier --check ." 16 | }, 17 | "dependencies": { 18 | "@electric-sql/client": "^1.0.7", 19 | "@tailwindcss/vite": "^4.0.6", 20 | "@tanstack/electric-db-collection": "^0.0.15", 21 | "@tanstack/react-db": "^0.0.33", 22 | "@tanstack/react-router": "^1.130.2", 23 | "@tanstack/react-router-devtools": "^1.130.2", 24 | "@tanstack/react-router-with-query": "^1.130.2", 25 | "@tanstack/react-start": "^1.130.3", 26 | "@tanstack/router-plugin": "^1.130.2", 27 | "@trpc/client": "^11.4.3", 28 | "@trpc/server": "^11.4.3", 29 | "better-auth": "^1.3.4", 30 | "dotenv": "^17.2.1", 31 | "drizzle-orm": "^0.44.3", 32 | "drizzle-zod": "^0.7.1", 33 | "pg": "^8.16.3", 34 | "react": "^19.1.1", 35 | "react-dom": "^19.1.1", 36 | "tailwindcss": "^4.0.6", 37 | "vite": "^6.3.3", 38 | "vite-tsconfig-paths": "^5.1.4", 39 | "zod": "^3.25.76" 40 | }, 41 | "devDependencies": { 42 | "@eslint/compat": "^1.3.1", 43 | "@eslint/js": "^9.32.0", 44 | "@testing-library/dom": "^10.4.1", 45 | "@testing-library/react": "^16.2.0", 46 | "@types/pg": "^8.15.4", 47 | "@types/react": "^19.0.8", 48 | "@types/react-dom": "^19.0.3", 49 | "@typescript-eslint/eslint-plugin": "^8.38.0", 50 | "@typescript-eslint/parser": "^8.38.0", 51 | "@vitejs/plugin-react": "^4.7.0", 52 | "concurrently": "^9.2.0", 53 | "drizzle-kit": "^0.31.4", 54 | "eslint": "^9.32.0", 55 | "eslint-config-prettier": "^10.1.8", 56 | "eslint-plugin-prettier": "^5.5.3", 57 | "eslint-plugin-react": "^7.37.5", 58 | "globals": "^16.3.0", 59 | "jsdom": "^26.0.0", 60 | "prettier": "^3.6.2", 61 | "tsx": "^4.20.3", 62 | "typescript": "^5.7.2", 63 | "vite": "^6.1.0", 64 | "vitest": "^3.0.5", 65 | "web-vitals": "^5.0.3" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | boolean, 3 | integer, 4 | pgTable, 5 | timestamp, 6 | varchar, 7 | text, 8 | } from "drizzle-orm/pg-core" 9 | import { createSchemaFactory } from "drizzle-zod" 10 | import { z } from "zod" 11 | export * from "./auth-schema" 12 | import { users } from "./auth-schema" 13 | 14 | const { createInsertSchema, createSelectSchema, createUpdateSchema } = 15 | createSchemaFactory({ zodInstance: z }) 16 | 17 | export const projectsTable = pgTable(`projects`, { 18 | id: integer().primaryKey().generatedAlwaysAsIdentity(), 19 | name: varchar({ length: 255 }).notNull(), 20 | description: text(), 21 | shared_user_ids: text("shared_user_ids").array().notNull().default([]), 22 | created_at: timestamp({ withTimezone: true }).notNull().defaultNow(), 23 | owner_id: text("owner_id") 24 | .notNull() 25 | .references(() => users.id, { onDelete: "cascade" }), 26 | }) 27 | 28 | export const todosTable = pgTable(`todos`, { 29 | id: integer().primaryKey().generatedAlwaysAsIdentity(), 30 | text: varchar({ length: 500 }).notNull(), 31 | completed: boolean().notNull().default(false), 32 | created_at: timestamp({ withTimezone: true }).notNull().defaultNow(), 33 | user_id: text("user_id") 34 | .notNull() 35 | .references(() => users.id, { onDelete: "cascade" }), 36 | project_id: integer("project_id") 37 | .notNull() 38 | .references(() => projectsTable.id, { onDelete: "cascade" }), 39 | user_ids: text("user_ids").array().notNull().default([]), 40 | }) 41 | 42 | export const selectProjectSchema = createSelectSchema(projectsTable) 43 | export const createProjectSchema = createInsertSchema(projectsTable).omit({ 44 | created_at: true, 45 | }) 46 | export const updateProjectSchema = createUpdateSchema(projectsTable) 47 | 48 | export const selectTodoSchema = createSelectSchema(todosTable) 49 | export const createTodoSchema = createInsertSchema(todosTable).omit({ 50 | created_at: true, 51 | }) 52 | export const updateTodoSchema = createUpdateSchema(todosTable) 53 | 54 | export type Project = z.infer 55 | export type UpdateProject = z.infer 56 | export type Todo = z.infer 57 | export type UpdateTodo = z.infer 58 | 59 | export const selectUsersSchema = createSelectSchema(users) 60 | -------------------------------------------------------------------------------- /AGENT.md: -------------------------------------------------------------------------------- 1 | # TanStack Start + DB + Electric Starter 2 | 3 | This is a TanStack Start project with tRPC v10 for mutations and Electric sync for reads, running on Start's server functions so it's easily deployable to many hosting platforms. 4 | 5 | All reads from the Postgres database are done via the Electric sync engine. All mutations (create, update, delete) are done via tRPC with full end-to-end type safety. 6 | 7 | We sync normalized data from tables into TanStack DB collections in the client & then write client-side queries for displaying data in components. 8 | 9 | ## Initial setup 10 | 11 | Before you started, all necessary package install is done via `pnpm install` and a dev server is started with `pnpm dev`. 12 | 13 | ## Linting and formatting 14 | 15 | Human devs have IDEs that autoformat code on every file save. After you edit files, you must do the equivalent by running `pnpm lint`. 16 | 17 | This command will also report linter errors that were not automatically fixable. Use your judgement as to which of the linter violations should be fixed. 18 | 19 | ## Build/Test Commands 20 | 21 | - `pnpm run dev` - Start development server with Docker services 22 | - `pnpm run build` - Build for production 23 | - `pnpm run test` - Run all Vitest tests 24 | - `vitest run ` - Run single test file 25 | - `pnpm run start` - Start production server 26 | 27 | ## Architecture 28 | 29 | - **Frontend**: TanStack Start (SSR framework for React and other frameworks) with file-based routing in `src/routes/` 30 | - **Database**: PostgreSQL with Drizzle ORM, schema in `src/db/schema.ts` 31 | - **Electric**: Real-time sync service on port 3000 32 | - **Services**: Docker Compose setup (Postgres on 54321, Electric on 3000) 33 | - **Styling**: Tailwind CSS v4 34 | - **Authentication**: better-auth 35 | - **API**: tRPC v10 for mutations with full e2e type safety, Electric shapes for real-time reads 36 | 37 | ## API Routing 38 | 39 | - **tRPC** (`/api/trpc/*`) - All mutations (create, update, delete) with full type safety 40 | - **better-auth** (`/api/auth/*`) - Authentication endpoints 41 | - **Electric shapes** (`/api/projects`, `/api/todos`, `/api/users`) - Real-time sync endpoints for reads 42 | 43 | ## Code Style 44 | 45 | - **TypeScript**: Strict mode, ES2022 target, bundler module resolution 46 | - **Imports**: Use `@/*` path aliases for `src/` directory imports 47 | - **Components**: React 19 with JSX transform, functional components preferred 48 | - **Server DB**: Drizzle ORM with PostgreSQL dialect, schema-first approach 49 | - **Client DB**: TanStack DB with Electric Sync Collections 50 | - **Routing**: File-based with TanStack Router, use `Link` component for navigation 51 | - **Testing**: Vitest with @testing-library/react for component tests 52 | - **file names** should always use kebab-case 53 | 54 | ## tRPC Integration 55 | 56 | - tRPC routers are defined in `src/lib/trpc/` directory 57 | - Client is configured in `src/lib/trpc-client.ts` 58 | - Collection hooks use tRPC client for mutations in `src/lib/collections.ts` 59 | - Transaction IDs are generated using `pg_current_xact_id()::xid::text` for Electric sync compatibility 60 | -------------------------------------------------------------------------------- /src/lib/trpc/todos.ts: -------------------------------------------------------------------------------- 1 | import { router, authedProcedure } from "@/lib/trpc" 2 | import { z } from "zod" 3 | import { TRPCError } from "@trpc/server" 4 | import { eq, and, sql, arrayContains } from "drizzle-orm" 5 | import { todosTable, createTodoSchema, updateTodoSchema } from "@/db/schema" 6 | 7 | async function generateTxId( 8 | tx: Parameters< 9 | Parameters[0] 10 | >[0] 11 | ): Promise { 12 | // The ::xid cast strips off the epoch, giving you the raw 32-bit value 13 | // that matches what PostgreSQL sends in logical replication streams 14 | // (and then exposed through Electric which we'll match against 15 | // in the client). 16 | const result = await tx.execute(sql`SELECT pg_current_xact_id()::xid::text as txid`) 17 | const txid = result.rows[0]?.txid 18 | 19 | if (txid === undefined) { 20 | throw new Error(`Failed to get transaction ID`) 21 | } 22 | 23 | return parseInt(txid as string, 10) 24 | } 25 | 26 | export const todosRouter = router({ 27 | create: authedProcedure 28 | .input(createTodoSchema) 29 | .mutation(async ({ ctx, input }) => { 30 | const result = await ctx.db.transaction(async (tx) => { 31 | const txid = await generateTxId(tx) 32 | const [newItem] = await tx.insert(todosTable).values(input).returning() 33 | return { item: newItem, txid } 34 | }) 35 | 36 | return result 37 | }), 38 | 39 | update: authedProcedure 40 | .input( 41 | z.object({ 42 | id: z.number(), 43 | data: updateTodoSchema, 44 | }) 45 | ) 46 | .mutation(async ({ ctx, input }) => { 47 | const result = await ctx.db.transaction(async (tx) => { 48 | const txid = await generateTxId(tx) 49 | const [updatedItem] = await tx 50 | .update(todosTable) 51 | .set(input.data) 52 | .where( 53 | and( 54 | eq(todosTable.id, input.id), 55 | arrayContains(todosTable.user_ids, [ctx.session.user.id]) 56 | ) 57 | ) 58 | .returning() 59 | 60 | if (!updatedItem) { 61 | throw new TRPCError({ 62 | code: "NOT_FOUND", 63 | message: 64 | "Todo not found or you do not have permission to update it", 65 | }) 66 | } 67 | 68 | return { item: updatedItem, txid } 69 | }) 70 | 71 | return result 72 | }), 73 | 74 | delete: authedProcedure 75 | .input(z.object({ id: z.number() })) 76 | .mutation(async ({ ctx, input }) => { 77 | const result = await ctx.db.transaction(async (tx) => { 78 | const txid = await generateTxId(tx) 79 | const [deletedItem] = await tx 80 | .delete(todosTable) 81 | .where( 82 | and( 83 | eq(todosTable.id, input.id), 84 | arrayContains(todosTable.user_ids, [ctx.session.user.id]) 85 | ) 86 | ) 87 | .returning() 88 | 89 | if (!deletedItem) { 90 | throw new TRPCError({ 91 | code: "NOT_FOUND", 92 | message: 93 | "Todo not found or you do not have permission to delete it", 94 | }) 95 | } 96 | 97 | return { item: deletedItem, txid } 98 | }) 99 | 100 | return result 101 | }), 102 | }) 103 | -------------------------------------------------------------------------------- /src/lib/trpc/projects.ts: -------------------------------------------------------------------------------- 1 | import { router, authedProcedure } from "@/lib/trpc" 2 | import { z } from "zod" 3 | import { TRPCError } from "@trpc/server" 4 | import { eq, and, sql } from "drizzle-orm" 5 | import { 6 | projectsTable, 7 | createProjectSchema, 8 | updateProjectSchema, 9 | } from "@/db/schema" 10 | 11 | async function generateTxId( 12 | tx: Parameters< 13 | Parameters[0] 14 | >[0] 15 | ): Promise { 16 | // The ::xid cast strips off the epoch, giving you the raw 32-bit value 17 | // that matches what PostgreSQL sends in logical replication streams 18 | // (and then exposed through Electric which we'll match against 19 | // in the client). 20 | const result = await tx.execute( 21 | sql`SELECT pg_current_xact_id()::xid::text as txid` 22 | ) 23 | const txid = result.rows[0]?.txid 24 | 25 | if (txid === undefined) { 26 | throw new Error(`Failed to get transaction ID`) 27 | } 28 | 29 | return parseInt(txid as string, 10) 30 | } 31 | 32 | export const projectsRouter = router({ 33 | create: authedProcedure 34 | .input(createProjectSchema) 35 | .mutation(async ({ ctx, input }) => { 36 | if (input.owner_id !== ctx.session.user.id) { 37 | throw new TRPCError({ 38 | code: "FORBIDDEN", 39 | message: "You can only create projects you own", 40 | }) 41 | } 42 | 43 | const result = await ctx.db.transaction(async (tx) => { 44 | const txid = await generateTxId(tx) 45 | const [newItem] = await tx 46 | .insert(projectsTable) 47 | .values(input) 48 | .returning() 49 | return { item: newItem, txid } 50 | }) 51 | 52 | return result 53 | }), 54 | 55 | update: authedProcedure 56 | .input( 57 | z.object({ 58 | id: z.number(), 59 | data: updateProjectSchema, 60 | }) 61 | ) 62 | .mutation(async ({ ctx, input }) => { 63 | const result = await ctx.db.transaction(async (tx) => { 64 | const txid = await generateTxId(tx) 65 | const [updatedItem] = await tx 66 | .update(projectsTable) 67 | .set(input.data) 68 | .where( 69 | and( 70 | eq(projectsTable.id, input.id), 71 | eq(projectsTable.owner_id, ctx.session.user.id) 72 | ) 73 | ) 74 | .returning() 75 | 76 | if (!updatedItem) { 77 | throw new TRPCError({ 78 | code: "NOT_FOUND", 79 | message: 80 | "Project not found or you do not have permission to update it", 81 | }) 82 | } 83 | 84 | return { item: updatedItem, txid } 85 | }) 86 | 87 | return result 88 | }), 89 | 90 | delete: authedProcedure 91 | .input(z.object({ id: z.number() })) 92 | .mutation(async ({ ctx, input }) => { 93 | const result = await ctx.db.transaction(async (tx) => { 94 | const txid = await generateTxId(tx) 95 | const [deletedItem] = await tx 96 | .delete(projectsTable) 97 | .where( 98 | and( 99 | eq(projectsTable.id, input.id), 100 | eq(projectsTable.owner_id, ctx.session.user.id) 101 | ) 102 | ) 103 | .returning() 104 | 105 | if (!deletedItem) { 106 | throw new TRPCError({ 107 | code: "NOT_FOUND", 108 | message: 109 | "Project not found or you do not have permission to delete it", 110 | }) 111 | } 112 | 113 | return { item: deletedItem, txid } 114 | }) 115 | 116 | return result 117 | }), 118 | }) 119 | -------------------------------------------------------------------------------- /src/db/out/0000_slimy_frank_castle.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "projects" ( 2 | "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "projects_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), 3 | "name" varchar(255) NOT NULL, 4 | "description" text, 5 | "shared_user_ids" text[] DEFAULT '{}' NOT NULL, 6 | "created_at" timestamp with time zone DEFAULT now() NOT NULL, 7 | "owner_id" text NOT NULL 8 | ); 9 | --> statement-breakpoint 10 | CREATE TABLE "todos" ( 11 | "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "todos_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), 12 | "text" varchar(500) NOT NULL, 13 | "completed" boolean DEFAULT false NOT NULL, 14 | "created_at" timestamp with time zone DEFAULT now() NOT NULL, 15 | "user_id" text NOT NULL, 16 | "project_id" integer NOT NULL, 17 | "user_ids" text[] DEFAULT '{}' NOT NULL 18 | ); 19 | --> statement-breakpoint 20 | CREATE TABLE "accounts" ( 21 | "id" text PRIMARY KEY NOT NULL, 22 | "account_id" text NOT NULL, 23 | "provider_id" text NOT NULL, 24 | "user_id" text NOT NULL, 25 | "access_token" text, 26 | "refresh_token" text, 27 | "id_token" text, 28 | "access_token_expires_at" timestamp, 29 | "refresh_token_expires_at" timestamp, 30 | "scope" text, 31 | "password" text, 32 | "created_at" timestamp NOT NULL, 33 | "updated_at" timestamp NOT NULL 34 | ); 35 | --> statement-breakpoint 36 | CREATE TABLE "sessions" ( 37 | "id" text PRIMARY KEY NOT NULL, 38 | "expires_at" timestamp NOT NULL, 39 | "token" text NOT NULL, 40 | "created_at" timestamp NOT NULL, 41 | "updated_at" timestamp NOT NULL, 42 | "ip_address" text, 43 | "user_agent" text, 44 | "user_id" text NOT NULL, 45 | CONSTRAINT "sessions_token_unique" UNIQUE("token") 46 | ); 47 | --> statement-breakpoint 48 | CREATE TABLE "users" ( 49 | "id" text PRIMARY KEY NOT NULL, 50 | "name" text NOT NULL, 51 | "email" text NOT NULL, 52 | "email_verified" boolean NOT NULL, 53 | "image" text, 54 | "created_at" timestamp NOT NULL, 55 | "updated_at" timestamp NOT NULL, 56 | CONSTRAINT "users_email_unique" UNIQUE("email") 57 | ); 58 | --> statement-breakpoint 59 | CREATE TABLE "verifications" ( 60 | "id" text PRIMARY KEY NOT NULL, 61 | "identifier" text NOT NULL, 62 | "value" text NOT NULL, 63 | "expires_at" timestamp NOT NULL, 64 | "created_at" timestamp, 65 | "updated_at" timestamp 66 | ); 67 | --> statement-breakpoint 68 | ALTER TABLE "projects" ADD CONSTRAINT "projects_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 69 | ALTER TABLE "todos" ADD CONSTRAINT "todos_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 70 | ALTER TABLE "todos" ADD CONSTRAINT "todos_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 71 | ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 72 | ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 73 | 74 | -- Function to sync user_ids in todos when project shared_user_ids changes 75 | CREATE OR REPLACE FUNCTION sync_todo_user_ids() 76 | RETURNS TRIGGER AS $$ 77 | BEGIN 78 | -- Update all todos in the project with the new user_ids 79 | UPDATE todos 80 | SET user_ids = ARRAY(SELECT DISTINCT unnest(ARRAY[NEW.owner_id] || NEW.shared_user_ids)) 81 | WHERE project_id = NEW.id; 82 | 83 | RETURN NEW; 84 | END; 85 | $$ LANGUAGE plpgsql; 86 | 87 | -- Function to populate user_ids when a todo is inserted 88 | CREATE OR REPLACE FUNCTION populate_todo_user_ids() 89 | RETURNS TRIGGER AS $$ 90 | BEGIN 91 | -- Get the project's owner_id and shared_user_ids 92 | SELECT ARRAY(SELECT DISTINCT unnest(ARRAY[owner_id] || shared_user_ids)) 93 | INTO NEW.user_ids 94 | FROM projects 95 | WHERE id = NEW.project_id; 96 | 97 | RETURN NEW; 98 | END; 99 | $$ LANGUAGE plpgsql; 100 | 101 | -- Trigger to sync user_ids when project shared_user_ids changes 102 | CREATE TRIGGER sync_todo_user_ids_trigger 103 | AFTER UPDATE OF shared_user_ids, owner_id ON projects 104 | FOR EACH ROW 105 | EXECUTE FUNCTION sync_todo_user_ids(); 106 | 107 | -- Trigger to populate user_ids when todo is inserted 108 | CREATE TRIGGER populate_todo_user_ids_trigger 109 | BEFORE INSERT ON todos 110 | FOR EACH ROW 111 | EXECUTE FUNCTION populate_todo_user_ids(); -------------------------------------------------------------------------------- /src/lib/collections.ts: -------------------------------------------------------------------------------- 1 | import { createCollection } from "@tanstack/react-db" 2 | import { electricCollectionOptions } from "@tanstack/electric-db-collection" 3 | import { authClient } from "@/lib/auth-client" 4 | import { 5 | selectTodoSchema, 6 | selectProjectSchema, 7 | selectUsersSchema, 8 | } from "@/db/schema" 9 | import { trpc } from "@/lib/trpc-client" 10 | 11 | export const usersCollection = createCollection( 12 | electricCollectionOptions({ 13 | id: "users", 14 | shapeOptions: { 15 | url: new URL( 16 | `/api/users`, 17 | typeof window !== `undefined` 18 | ? window.location.origin 19 | : `http://localhost:5173` 20 | ).toString(), 21 | params: { 22 | table: "users", 23 | user_id: async () => 24 | authClient 25 | .getSession() 26 | .then((session) => session.data?.user.id ?? ``), 27 | }, 28 | parser: { 29 | timestamptz: (date: string) => { 30 | return new Date(date) 31 | }, 32 | }, 33 | }, 34 | schema: selectUsersSchema, 35 | getKey: (item) => item.id, 36 | }) 37 | ) 38 | export const projectCollection = createCollection( 39 | electricCollectionOptions({ 40 | id: "projects", 41 | shapeOptions: { 42 | url: new URL( 43 | `/api/projects`, 44 | typeof window !== `undefined` 45 | ? window.location.origin 46 | : `http://localhost:5173` 47 | ).toString(), 48 | params: { 49 | table: "projects", 50 | user_id: async () => 51 | authClient 52 | .getSession() 53 | .then((session) => session.data?.user.id ?? ``), 54 | }, 55 | parser: { 56 | timestamptz: (date: string) => { 57 | return new Date(date) 58 | }, 59 | }, 60 | }, 61 | schema: selectProjectSchema, 62 | getKey: (item) => item.id, 63 | onInsert: async ({ transaction }) => { 64 | const { modified: newProject } = transaction.mutations[0] 65 | const result = await trpc.projects.create.mutate({ 66 | name: newProject.name, 67 | description: newProject.description, 68 | owner_id: newProject.owner_id, 69 | shared_user_ids: newProject.shared_user_ids, 70 | }) 71 | 72 | return { txid: result.txid } 73 | }, 74 | onUpdate: async ({ transaction }) => { 75 | const { modified: updatedProject } = transaction.mutations[0] 76 | const result = await trpc.projects.update.mutate({ 77 | id: updatedProject.id, 78 | data: { 79 | name: updatedProject.name, 80 | description: updatedProject.description, 81 | shared_user_ids: updatedProject.shared_user_ids, 82 | }, 83 | }) 84 | 85 | return { txid: result.txid } 86 | }, 87 | onDelete: async ({ transaction }) => { 88 | const { original: deletedProject } = transaction.mutations[0] 89 | const result = await trpc.projects.delete.mutate({ 90 | id: deletedProject.id, 91 | }) 92 | 93 | return { txid: result.txid } 94 | }, 95 | }) 96 | ) 97 | 98 | export const todoCollection = createCollection( 99 | electricCollectionOptions({ 100 | id: "todos", 101 | shapeOptions: { 102 | url: new URL( 103 | `/api/todos`, 104 | typeof window !== `undefined` 105 | ? window.location.origin 106 | : `http://localhost:5173` 107 | ).toString(), 108 | params: { 109 | table: "todos", 110 | // Set the user_id as a param as a cache buster for when 111 | // you log in and out to test different accounts. 112 | user_id: async (): Promise => 113 | authClient.getSession().then((session) => session.data!.user.id)!, 114 | }, 115 | parser: { 116 | // Parse timestamp columns into JavaScript Date objects 117 | timestamptz: (date: string) => { 118 | return new Date(date) 119 | }, 120 | }, 121 | }, 122 | schema: selectTodoSchema, 123 | getKey: (item) => item.id, 124 | onInsert: async ({ transaction }) => { 125 | const { modified: newTodo } = transaction.mutations[0] 126 | const result = await trpc.todos.create.mutate({ 127 | user_id: newTodo.user_id, 128 | text: newTodo.text, 129 | completed: newTodo.completed, 130 | project_id: newTodo.project_id, 131 | user_ids: newTodo.user_ids, 132 | }) 133 | 134 | return { txid: result.txid } 135 | }, 136 | onUpdate: async ({ transaction }) => { 137 | const { modified: updatedTodo } = transaction.mutations[0] 138 | const result = await trpc.todos.update.mutate({ 139 | id: updatedTodo.id, 140 | data: { 141 | text: updatedTodo.text, 142 | completed: updatedTodo.completed, 143 | }, 144 | }) 145 | 146 | return { txid: result.txid } 147 | }, 148 | onDelete: async ({ transaction }) => { 149 | const { original: deletedTodo } = transaction.mutations[0] 150 | const result = await trpc.todos.delete.mutate({ 151 | id: deletedTodo.id, 152 | }) 153 | 154 | return { txid: result.txid } 155 | }, 156 | }) 157 | ) 158 | -------------------------------------------------------------------------------- /src/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router" 2 | import { authClient } from "@/lib/auth-client" 3 | import { useState } from "react" 4 | 5 | export const Route = createFileRoute(`/login`)({ 6 | component: Layout, 7 | ssr: false, 8 | }) 9 | 10 | function Layout() { 11 | const [email, setEmail] = useState("") 12 | const [password, setPassword] = useState("") 13 | const [isLoading, setIsLoading] = useState(false) 14 | const [error, setError] = useState("") 15 | 16 | const handleSubmit = async (e: React.FormEvent) => { 17 | e.preventDefault() 18 | setIsLoading(true) 19 | setError("") 20 | 21 | try { 22 | let { data, error } = await authClient.signUp.email( 23 | { 24 | email, 25 | password, 26 | name: email, 27 | }, 28 | { 29 | onSuccess: () => { 30 | console.log(`signed up, navigating`) 31 | window.location.href = "/" 32 | }, 33 | } 34 | ) 35 | 36 | if (error?.code === `USER_ALREADY_EXISTS`) { 37 | console.log(`user exists, logging in`) 38 | const result = await authClient.signIn.email( 39 | { 40 | email, 41 | password, 42 | }, 43 | { 44 | onSuccess: async () => { 45 | console.log(`signed in, navigating`) 46 | const { data: session, error } = await authClient.getSession() 47 | console.log({ session, error }) 48 | window.location.href = "/" 49 | }, 50 | } 51 | ) 52 | 53 | data = result.data 54 | error = result.error 55 | } 56 | 57 | console.log({ data, error }) 58 | 59 | if (error) { 60 | console.log(`set error`) 61 | setError(JSON.stringify(error, null, 4)) 62 | } 63 | } catch (_) { 64 | setError("An unexpected error occurred") 65 | } finally { 66 | setIsLoading(false) 67 | } 68 | } 69 | 70 | return ( 71 |
72 |
73 |
74 |

75 | Sign in to your account 76 |

77 |
78 |

79 | Development Mode: Any email/password combination 80 | will work for testing. Also new accounts will be automatically 81 | created when you try signing in with a new combo. 82 |

83 |
84 |
85 |
86 |
87 |
88 | 91 | setEmail(e.target.value)} 99 | className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm" 100 | placeholder="Email address" 101 | /> 102 |
103 |
104 | 107 | setPassword(e.target.value)} 115 | className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm" 116 | placeholder="Password" 117 | /> 118 |
119 |
120 | 121 | {error && ( 122 |
123 |
{error}
124 |
125 | )} 126 | 127 |
128 | 135 |
136 |
137 |
138 |
139 | ) 140 | } 141 | -------------------------------------------------------------------------------- /src/vite-plugin-caddy.ts: -------------------------------------------------------------------------------- 1 | import { spawn, type ChildProcess } from "child_process" 2 | import { writeFileSync } from "fs" 3 | import { readFileSync } from "fs" 4 | import { networkInterfaces } from "os" 5 | import type { Plugin } from "vite" 6 | 7 | interface CaddyPluginOptions { 8 | host?: string 9 | encoding?: boolean 10 | autoStart?: boolean 11 | configPath?: string 12 | } 13 | 14 | export function caddyPlugin(options: CaddyPluginOptions = {}): Plugin { 15 | const { 16 | host = "localhost", 17 | encoding = true, 18 | autoStart = true, 19 | configPath = "Caddyfile", 20 | } = options 21 | 22 | let caddyProcess: ChildProcess | null = null 23 | let vitePort: number | undefined 24 | let caddyStarted = false 25 | 26 | const generateCaddyfile = (projectName: string, vitePort: number) => { 27 | // Get network IP for network access 28 | const nets = networkInterfaces() 29 | let networkIP = "192.168.1.1" // fallback 30 | 31 | for (const name of Object.keys(nets)) { 32 | const netInterfaces = nets[name] 33 | if (netInterfaces) { 34 | for (const net of netInterfaces) { 35 | if (net.family === "IPv4" && !net.internal) { 36 | networkIP = net.address 37 | break 38 | } 39 | } 40 | } 41 | } 42 | 43 | const config = `${projectName}.localhost { 44 | reverse_proxy ${host}:${vitePort}${ 45 | encoding 46 | ? ` 47 | encode { 48 | gzip 49 | }` 50 | : "" 51 | } 52 | } 53 | 54 | # Network access 55 | ${networkIP} { 56 | reverse_proxy ${host}:${vitePort}${ 57 | encoding 58 | ? ` 59 | encode { 60 | gzip 61 | }` 62 | : "" 63 | } 64 | } 65 | ` 66 | return config 67 | } 68 | 69 | const startCaddy = (configPath: string) => { 70 | if (caddyProcess) { 71 | return 72 | } 73 | 74 | caddyProcess = spawn("caddy", ["run", "--config", configPath], { 75 | // stdio: "inherit", 76 | // shell: true, 77 | }) 78 | 79 | caddyProcess.on("error", (error) => { 80 | console.error("Failed to start Caddy:", error.message) 81 | }) 82 | 83 | caddyProcess.on("exit", (code) => { 84 | if (code !== 0 && code !== null) { 85 | console.error(`Caddy exited with code ${code}`) 86 | } 87 | caddyProcess = null 88 | }) 89 | 90 | // Handle process cleanup 91 | const cleanup = () => { 92 | if (caddyProcess && !caddyProcess.killed) { 93 | caddyProcess.kill("SIGTERM") 94 | // Force kill if it doesn't terminate gracefully 95 | setTimeout(() => { 96 | if (caddyProcess && !caddyProcess.killed) { 97 | caddyProcess.kill("SIGKILL") 98 | process.exit() 99 | } else { 100 | process.exit() 101 | } 102 | }, 1000) 103 | } 104 | } 105 | 106 | process.on("SIGINT", cleanup) 107 | process.on("SIGTERM", cleanup) 108 | process.on("exit", cleanup) 109 | } 110 | 111 | const stopCaddy = () => { 112 | if (caddyProcess && !caddyProcess.killed) { 113 | caddyProcess.kill("SIGTERM") 114 | // Force kill if it doesn't terminate gracefully 115 | setTimeout(() => { 116 | if (caddyProcess && !caddyProcess.killed) { 117 | caddyProcess.kill("SIGKILL") 118 | } 119 | }, 3000) 120 | caddyProcess = null 121 | } 122 | } 123 | 124 | const startCaddyIfReady = (projectName: string) => { 125 | if (autoStart && vitePort && !caddyStarted) { 126 | caddyStarted = true 127 | // Generate Caddyfile 128 | const caddyConfig = generateCaddyfile(projectName, vitePort) 129 | writeFileSync(configPath, caddyConfig) 130 | // Start Caddy 131 | startCaddy(configPath) 132 | } 133 | } 134 | 135 | return { 136 | name: "vite-plugin-caddy", 137 | configureServer(server) { 138 | let projectName = "app" 139 | 140 | // Get project name from package.json 141 | try { 142 | const packageJsonContent = readFileSync( 143 | process.cwd() + "/package.json", 144 | "utf8" 145 | ) 146 | const packageJson = JSON.parse(packageJsonContent) 147 | projectName = packageJson.name || "app" 148 | } catch (_error) { 149 | console.warn( 150 | 'Could not read package.json for project name, using "app"' 151 | ) 152 | } 153 | 154 | // Override Vite's printUrls function 155 | server.printUrls = function () { 156 | // Get network IP 157 | const nets = networkInterfaces() 158 | let networkIP = "192.168.1.1" // fallback 159 | 160 | for (const name of Object.keys(nets)) { 161 | const netInterfaces = nets[name] 162 | if (netInterfaces) { 163 | for (const net of netInterfaces) { 164 | if (net.family === "IPv4" && !net.internal) { 165 | networkIP = net.address 166 | break 167 | } 168 | } 169 | } 170 | } 171 | 172 | console.log() 173 | console.log(` ➜ Local: https://${projectName}.localhost/`) 174 | console.log(` ➜ Network: https://${networkIP}/`) 175 | console.log(` ➜ press h + enter to show help`) 176 | console.log() 177 | } 178 | 179 | server.middlewares.use((_req, _res, next) => { 180 | if (!vitePort && server.config.server.port) { 181 | vitePort = server.config.server.port 182 | startCaddyIfReady(projectName) 183 | } 184 | next() 185 | }) 186 | 187 | const originalListen = server.listen 188 | server.listen = function (port?: number, ...args: unknown[]) { 189 | if (port) { 190 | vitePort = port 191 | } 192 | 193 | const result = originalListen.call(this, port, ...args) 194 | 195 | // Try to start Caddy after server is listening 196 | if (result && typeof result.then === "function") { 197 | result.then(() => { 198 | // Check if we now have a port from the server 199 | if (!vitePort && server.config.server.port) { 200 | vitePort = server.config.server.port 201 | } 202 | startCaddyIfReady(projectName) 203 | }) 204 | } else { 205 | startCaddyIfReady(projectName) 206 | } 207 | 208 | return result 209 | } 210 | }, 211 | buildEnd() { 212 | stopCaddy() 213 | }, 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/routes/_authenticated.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, useNavigate, Link } from "@tanstack/react-router" 2 | import { useEffect, useState } from "react" 3 | import { Outlet } from "@tanstack/react-router" 4 | import { authClient } from "@/lib/auth-client" 5 | import { 6 | useLiveQuery, 7 | createCollection, 8 | liveQueryCollectionOptions, 9 | createLiveQueryCollection, 10 | not, 11 | like, 12 | count, 13 | } from "@tanstack/react-db" 14 | import { projectCollection } from "@/lib/collections" 15 | 16 | export const Route = createFileRoute("/_authenticated")({ 17 | component: AuthenticatedLayout, 18 | ssr: false, 19 | }) 20 | 21 | function AuthenticatedLayout() { 22 | const { data: session, isPending } = authClient.useSession() 23 | console.log({ session, isPending }) 24 | const navigate = useNavigate() 25 | const [showNewProjectForm, setShowNewProjectForm] = useState(false) 26 | const [newProjectName, setNewProjectName] = useState("") 27 | 28 | const countQuery = createLiveQueryCollection({ 29 | query: (q) => 30 | q.from({ projects: projectCollection }).select(({ projects }) => ({ 31 | count: count(projects.id), 32 | })), 33 | }) 34 | const newQuery = createCollection( 35 | liveQueryCollectionOptions({ 36 | query: (q) => 37 | q 38 | .from({ projects: projectCollection }) 39 | .where(({ projects }) => not(like(projects.name, `Default`))), 40 | }) 41 | ) 42 | 43 | const { data: notDefault } = useLiveQuery(newQuery) 44 | const { data: countData } = useLiveQuery(countQuery) 45 | console.log({ notDefault, countData }) 46 | const { data: projects, isLoading } = useLiveQuery((q) => 47 | q.from({ projectCollection }) 48 | ) 49 | 50 | useEffect(() => { 51 | if (!isPending && !session) { 52 | navigate({ 53 | href: "/login", 54 | }) 55 | } 56 | }, [session, isPending, navigate]) 57 | 58 | useEffect(() => { 59 | if (session && projects && !isLoading) { 60 | const hasProject = projects.length > 0 61 | if (!hasProject) { 62 | projectCollection.insert({ 63 | id: Math.floor(Math.random() * 100000), 64 | name: "Default", 65 | description: "Default project", 66 | owner_id: session.user.id, 67 | shared_user_ids: [], 68 | created_at: new Date(), 69 | }) 70 | } 71 | } 72 | }, [session, projects, isLoading]) 73 | 74 | const handleLogout = async () => { 75 | await authClient.signOut() 76 | navigate({ to: "/login" }) 77 | } 78 | 79 | const handleCreateProject = () => { 80 | if (newProjectName.trim() && session) { 81 | projectCollection.insert({ 82 | id: Math.floor(Math.random() * 100000), 83 | name: newProjectName.trim(), 84 | description: "", 85 | owner_id: session.user.id, 86 | shared_user_ids: [], 87 | created_at: new Date(), 88 | }) 89 | setNewProjectName("") 90 | setShowNewProjectForm(false) 91 | } 92 | } 93 | 94 | if (isPending) { 95 | return null 96 | } 97 | 98 | if (!session) { 99 | return null 100 | } 101 | 102 | return ( 103 |
104 |
105 |
106 |
107 |
108 |

109 | TanStack DB / Electric Starter 110 |

111 |
112 |
113 | 114 | {session.user.email} 115 | 116 | 122 |
123 |
124 |
125 |
126 |
127 | 192 |
193 | 194 |
195 |
196 |
197 | ) 198 | } 199 | -------------------------------------------------------------------------------- /src/routes/_authenticated/project/$projectId.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router" 2 | import { useLiveQuery, eq } from "@tanstack/react-db" 3 | import { useState } from "react" 4 | import { authClient } from "@/lib/auth-client" 5 | import { 6 | todoCollection, 7 | projectCollection, 8 | usersCollection, 9 | } from "@/lib/collections" 10 | import { type Todo } from "@/db/schema" 11 | 12 | export const Route = createFileRoute("/_authenticated/project/$projectId")({ 13 | component: ProjectPage, 14 | ssr: false, 15 | loader: async () => { 16 | await projectCollection.preload() 17 | await todoCollection.preload() 18 | return null 19 | }, 20 | }) 21 | 22 | function ProjectPage() { 23 | const { projectId } = Route.useParams() 24 | const { data: session } = authClient.useSession() 25 | const [newTodoText, setNewTodoText] = useState("") 26 | 27 | const { data: todos } = useLiveQuery( 28 | (q) => 29 | q 30 | .from({ todoCollection }) 31 | .where(({ todoCollection }) => 32 | eq(todoCollection.project_id, parseInt(projectId, 10)) 33 | ) 34 | .orderBy(({ todoCollection }) => todoCollection.created_at), 35 | [projectId] 36 | ) 37 | 38 | const { data: users } = useLiveQuery((q) => 39 | q.from({ users: usersCollection }) 40 | ) 41 | const { data: usersInProjects } = useLiveQuery( 42 | (q) => 43 | q 44 | .from({ projects: projectCollection }) 45 | .where(({ projects }) => eq(projects.id, parseInt(projectId, 10))) 46 | .fn.select(({ projects }) => ({ 47 | users: projects.shared_user_ids.concat(projects.owner_id), 48 | owner: projects.owner_id, 49 | })), 50 | [projectId] 51 | ) 52 | const usersInProject = usersInProjects?.[0] 53 | console.log({ usersInProject, users }) 54 | 55 | const { data: projects } = useLiveQuery( 56 | (q) => 57 | q 58 | .from({ projectCollection }) 59 | .where(({ projectCollection }) => 60 | eq(projectCollection.id, parseInt(projectId, 10)) 61 | ), 62 | [projectId] 63 | ) 64 | const project = projects[0] 65 | 66 | const addTodo = () => { 67 | if (newTodoText.trim() && session) { 68 | todoCollection.insert({ 69 | user_id: session.user.id, 70 | id: Math.floor(Math.random() * 100000), 71 | text: newTodoText.trim(), 72 | completed: false, 73 | project_id: parseInt(projectId), 74 | user_ids: [], 75 | created_at: new Date(), 76 | }) 77 | setNewTodoText("") 78 | } 79 | } 80 | 81 | const toggleTodo = (todo: Todo) => { 82 | todoCollection.update(todo.id, (draft) => { 83 | draft.completed = !draft.completed 84 | }) 85 | } 86 | 87 | const deleteTodo = (id: number) => { 88 | todoCollection.delete(id) 89 | } 90 | 91 | if (!project) { 92 | return
Project not found
93 | } 94 | 95 | return ( 96 |
97 |
98 |

{ 101 | const newName = prompt("Edit project name:", project.name) 102 | if (newName && newName !== project.name) { 103 | projectCollection.update(project.id, (draft) => { 104 | draft.name = newName 105 | }) 106 | } 107 | }} 108 | > 109 | {project.name} 110 |

111 | 112 |

{ 115 | const newDescription = prompt( 116 | "Edit project description:", 117 | project.description || "" 118 | ) 119 | if (newDescription !== null) { 120 | projectCollection.update(project.id, (draft) => { 121 | draft.description = newDescription 122 | }) 123 | } 124 | }} 125 | > 126 | {project.description || "Click to add description..."} 127 |

128 | 129 |
130 | setNewTodoText(e.target.value)} 134 | onKeyDown={(e) => e.key === "Enter" && addTodo()} 135 | placeholder="Add a new todo..." 136 | className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 137 | /> 138 | 144 |
145 | 146 |
    147 | {todos?.map((todo) => ( 148 |
  • 152 | toggleTodo(todo)} 156 | className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" 157 | /> 158 | 165 | {todo.text} 166 | 167 | 173 |
  • 174 | ))} 175 |
176 | 177 | {(!todos || todos.length === 0) && ( 178 |
179 |

No todos yet. Add one above!

180 |
181 | )} 182 | 183 |
184 | 185 |
186 |

187 | Project Members 188 |

189 |
190 | {(session?.user.id === project.owner_id 191 | ? users 192 | : users?.filter((user) => usersInProject?.users.includes(user.id)) 193 | )?.map((user) => { 194 | const isInProject = usersInProject?.users.includes(user.id) 195 | const isOwner = user.id === usersInProject?.owner 196 | const canEditMembership = session?.user.id === project.owner_id 197 | return ( 198 |
202 | {canEditMembership && ( 203 | { 207 | console.log(`onChange`, { isInProject, isOwner }) 208 | if (isInProject && !isOwner) { 209 | projectCollection.update(project.id, (draft) => { 210 | draft.shared_user_ids = 211 | draft.shared_user_ids.filter( 212 | (id) => id !== user.id 213 | ) 214 | }) 215 | } else if (!isInProject) { 216 | projectCollection.update(project.id, (draft) => { 217 | draft.shared_user_ids.push(user.id) 218 | }) 219 | } 220 | }} 221 | disabled={isOwner} 222 | className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 disabled:opacity-50" 223 | /> 224 | )} 225 | {user.name} 226 | {isOwner && ( 227 | 228 | Owner 229 | 230 | )} 231 |
232 | ) 233 | })} 234 |
235 |
236 |
237 |
238 | ) 239 | } 240 | -------------------------------------------------------------------------------- /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 { createServerRootRoute } from '@tanstack/react-start/server' 12 | 13 | import { Route as rootRouteImport } from './routes/__root' 14 | import { Route as LoginRouteImport } from './routes/login' 15 | import { Route as AuthenticatedRouteImport } from './routes/_authenticated' 16 | import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' 17 | import { Route as AuthenticatedProjectProjectIdRouteImport } from './routes/_authenticated/project/$projectId' 18 | import { ServerRoute as ApiUsersServerRouteImport } from './routes/api/users' 19 | import { ServerRoute as ApiTodosServerRouteImport } from './routes/api/todos' 20 | import { ServerRoute as ApiProjectsServerRouteImport } from './routes/api/projects' 21 | import { ServerRoute as ApiAuthServerRouteImport } from './routes/api/auth' 22 | import { ServerRoute as ApiTrpcSplatServerRouteImport } from './routes/api/trpc/$' 23 | 24 | const rootServerRouteImport = createServerRootRoute() 25 | 26 | const LoginRoute = LoginRouteImport.update({ 27 | id: '/login', 28 | path: '/login', 29 | getParentRoute: () => rootRouteImport, 30 | } as any) 31 | const AuthenticatedRoute = AuthenticatedRouteImport.update({ 32 | id: '/_authenticated', 33 | getParentRoute: () => rootRouteImport, 34 | } as any) 35 | const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({ 36 | id: '/', 37 | path: '/', 38 | getParentRoute: () => AuthenticatedRoute, 39 | } as any) 40 | const AuthenticatedProjectProjectIdRoute = 41 | AuthenticatedProjectProjectIdRouteImport.update({ 42 | id: '/project/$projectId', 43 | path: '/project/$projectId', 44 | getParentRoute: () => AuthenticatedRoute, 45 | } as any) 46 | const ApiUsersServerRoute = ApiUsersServerRouteImport.update({ 47 | id: '/api/users', 48 | path: '/api/users', 49 | getParentRoute: () => rootServerRouteImport, 50 | } as any) 51 | const ApiTodosServerRoute = ApiTodosServerRouteImport.update({ 52 | id: '/api/todos', 53 | path: '/api/todos', 54 | getParentRoute: () => rootServerRouteImport, 55 | } as any) 56 | const ApiProjectsServerRoute = ApiProjectsServerRouteImport.update({ 57 | id: '/api/projects', 58 | path: '/api/projects', 59 | getParentRoute: () => rootServerRouteImport, 60 | } as any) 61 | const ApiAuthServerRoute = ApiAuthServerRouteImport.update({ 62 | id: '/api/auth', 63 | path: '/api/auth', 64 | getParentRoute: () => rootServerRouteImport, 65 | } as any) 66 | const ApiTrpcSplatServerRoute = ApiTrpcSplatServerRouteImport.update({ 67 | id: '/api/trpc/$', 68 | path: '/api/trpc/$', 69 | getParentRoute: () => rootServerRouteImport, 70 | } as any) 71 | 72 | export interface FileRoutesByFullPath { 73 | '/login': typeof LoginRoute 74 | '/': typeof AuthenticatedIndexRoute 75 | '/project/$projectId': typeof AuthenticatedProjectProjectIdRoute 76 | } 77 | export interface FileRoutesByTo { 78 | '/login': typeof LoginRoute 79 | '/': typeof AuthenticatedIndexRoute 80 | '/project/$projectId': typeof AuthenticatedProjectProjectIdRoute 81 | } 82 | export interface FileRoutesById { 83 | __root__: typeof rootRouteImport 84 | '/_authenticated': typeof AuthenticatedRouteWithChildren 85 | '/login': typeof LoginRoute 86 | '/_authenticated/': typeof AuthenticatedIndexRoute 87 | '/_authenticated/project/$projectId': typeof AuthenticatedProjectProjectIdRoute 88 | } 89 | export interface FileRouteTypes { 90 | fileRoutesByFullPath: FileRoutesByFullPath 91 | fullPaths: '/login' | '/' | '/project/$projectId' 92 | fileRoutesByTo: FileRoutesByTo 93 | to: '/login' | '/' | '/project/$projectId' 94 | id: 95 | | '__root__' 96 | | '/_authenticated' 97 | | '/login' 98 | | '/_authenticated/' 99 | | '/_authenticated/project/$projectId' 100 | fileRoutesById: FileRoutesById 101 | } 102 | export interface RootRouteChildren { 103 | AuthenticatedRoute: typeof AuthenticatedRouteWithChildren 104 | LoginRoute: typeof LoginRoute 105 | } 106 | export interface FileServerRoutesByFullPath { 107 | '/api/auth': typeof ApiAuthServerRoute 108 | '/api/projects': typeof ApiProjectsServerRoute 109 | '/api/todos': typeof ApiTodosServerRoute 110 | '/api/users': typeof ApiUsersServerRoute 111 | '/api/trpc/$': typeof ApiTrpcSplatServerRoute 112 | } 113 | export interface FileServerRoutesByTo { 114 | '/api/auth': typeof ApiAuthServerRoute 115 | '/api/projects': typeof ApiProjectsServerRoute 116 | '/api/todos': typeof ApiTodosServerRoute 117 | '/api/users': typeof ApiUsersServerRoute 118 | '/api/trpc/$': typeof ApiTrpcSplatServerRoute 119 | } 120 | export interface FileServerRoutesById { 121 | __root__: typeof rootServerRouteImport 122 | '/api/auth': typeof ApiAuthServerRoute 123 | '/api/projects': typeof ApiProjectsServerRoute 124 | '/api/todos': typeof ApiTodosServerRoute 125 | '/api/users': typeof ApiUsersServerRoute 126 | '/api/trpc/$': typeof ApiTrpcSplatServerRoute 127 | } 128 | export interface FileServerRouteTypes { 129 | fileServerRoutesByFullPath: FileServerRoutesByFullPath 130 | fullPaths: 131 | | '/api/auth' 132 | | '/api/projects' 133 | | '/api/todos' 134 | | '/api/users' 135 | | '/api/trpc/$' 136 | fileServerRoutesByTo: FileServerRoutesByTo 137 | to: 138 | | '/api/auth' 139 | | '/api/projects' 140 | | '/api/todos' 141 | | '/api/users' 142 | | '/api/trpc/$' 143 | id: 144 | | '__root__' 145 | | '/api/auth' 146 | | '/api/projects' 147 | | '/api/todos' 148 | | '/api/users' 149 | | '/api/trpc/$' 150 | fileServerRoutesById: FileServerRoutesById 151 | } 152 | export interface RootServerRouteChildren { 153 | ApiAuthServerRoute: typeof ApiAuthServerRoute 154 | ApiProjectsServerRoute: typeof ApiProjectsServerRoute 155 | ApiTodosServerRoute: typeof ApiTodosServerRoute 156 | ApiUsersServerRoute: typeof ApiUsersServerRoute 157 | ApiTrpcSplatServerRoute: typeof ApiTrpcSplatServerRoute 158 | } 159 | 160 | declare module '@tanstack/react-router' { 161 | interface FileRoutesByPath { 162 | '/login': { 163 | id: '/login' 164 | path: '/login' 165 | fullPath: '/login' 166 | preLoaderRoute: typeof LoginRouteImport 167 | parentRoute: typeof rootRouteImport 168 | } 169 | '/_authenticated': { 170 | id: '/_authenticated' 171 | path: '' 172 | fullPath: '' 173 | preLoaderRoute: typeof AuthenticatedRouteImport 174 | parentRoute: typeof rootRouteImport 175 | } 176 | '/_authenticated/': { 177 | id: '/_authenticated/' 178 | path: '/' 179 | fullPath: '/' 180 | preLoaderRoute: typeof AuthenticatedIndexRouteImport 181 | parentRoute: typeof AuthenticatedRoute 182 | } 183 | '/_authenticated/project/$projectId': { 184 | id: '/_authenticated/project/$projectId' 185 | path: '/project/$projectId' 186 | fullPath: '/project/$projectId' 187 | preLoaderRoute: typeof AuthenticatedProjectProjectIdRouteImport 188 | parentRoute: typeof AuthenticatedRoute 189 | } 190 | } 191 | } 192 | declare module '@tanstack/react-start/server' { 193 | interface ServerFileRoutesByPath { 194 | '/api/users': { 195 | id: '/api/users' 196 | path: '/api/users' 197 | fullPath: '/api/users' 198 | preLoaderRoute: typeof ApiUsersServerRouteImport 199 | parentRoute: typeof rootServerRouteImport 200 | } 201 | '/api/todos': { 202 | id: '/api/todos' 203 | path: '/api/todos' 204 | fullPath: '/api/todos' 205 | preLoaderRoute: typeof ApiTodosServerRouteImport 206 | parentRoute: typeof rootServerRouteImport 207 | } 208 | '/api/projects': { 209 | id: '/api/projects' 210 | path: '/api/projects' 211 | fullPath: '/api/projects' 212 | preLoaderRoute: typeof ApiProjectsServerRouteImport 213 | parentRoute: typeof rootServerRouteImport 214 | } 215 | '/api/auth': { 216 | id: '/api/auth' 217 | path: '/api/auth' 218 | fullPath: '/api/auth' 219 | preLoaderRoute: typeof ApiAuthServerRouteImport 220 | parentRoute: typeof rootServerRouteImport 221 | } 222 | '/api/trpc/$': { 223 | id: '/api/trpc/$' 224 | path: '/api/trpc/$' 225 | fullPath: '/api/trpc/$' 226 | preLoaderRoute: typeof ApiTrpcSplatServerRouteImport 227 | parentRoute: typeof rootServerRouteImport 228 | } 229 | } 230 | } 231 | 232 | interface AuthenticatedRouteChildren { 233 | AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute 234 | AuthenticatedProjectProjectIdRoute: typeof AuthenticatedProjectProjectIdRoute 235 | } 236 | 237 | const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { 238 | AuthenticatedIndexRoute: AuthenticatedIndexRoute, 239 | AuthenticatedProjectProjectIdRoute: AuthenticatedProjectProjectIdRoute, 240 | } 241 | 242 | const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( 243 | AuthenticatedRouteChildren, 244 | ) 245 | 246 | const rootRouteChildren: RootRouteChildren = { 247 | AuthenticatedRoute: AuthenticatedRouteWithChildren, 248 | LoginRoute: LoginRoute, 249 | } 250 | export const routeTree = rootRouteImport 251 | ._addFileChildren(rootRouteChildren) 252 | ._addFileTypes() 253 | const rootServerRouteChildren: RootServerRouteChildren = { 254 | ApiAuthServerRoute: ApiAuthServerRoute, 255 | ApiProjectsServerRoute: ApiProjectsServerRoute, 256 | ApiTodosServerRoute: ApiTodosServerRoute, 257 | ApiUsersServerRoute: ApiUsersServerRoute, 258 | ApiTrpcSplatServerRoute: ApiTrpcSplatServerRoute, 259 | } 260 | export const serverRouteTree = rootServerRouteImport 261 | ._addFileChildren(rootServerRouteChildren) 262 | ._addFileTypes() 263 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚠️ DEPRECATED - This repository has been moved! 2 | 3 | It now lives in the Electric monorepo at https://github.com/electric-sql/electric/tree/main/examples/tanstack-db-web-starter 4 | 5 | --- 6 | 7 | Welcome to your new TanStack [Start](https://tanstack.com/start/latest)/[DB](https://tanstack.com/db/latest) + [Electric](https://electric-sql.com/) app! 8 | 9 | # Getting Started 10 | 11 | ## Prerequisites 12 | 13 | This project uses [Caddy](https://caddyserver.com/) for local HTTPS development: 14 | 15 | 1. **Install Caddy** for your OS — https://caddyserver.com/docs/install 16 | 2. **Run `caddy trust`** so Caddy can install its certificate into your OS. This is necessary for http/2 to Just Work™ without SSL warnings/errors in the browser — https://caddyserver.com/docs/command-line#caddy-trust 17 | 18 | ## Running the Application 19 | 20 | To run this application: 21 | 22 | ```bash 23 | pnpm install 24 | pnpm run dev 25 | pnpm run migrate 26 | ``` 27 | 28 | # Building For Production 29 | 30 | To build this application for production: 31 | 32 | ```bash 33 | pnpm run build 34 | ``` 35 | 36 | ## Testing 37 | 38 | This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with: 39 | 40 | ```bash 41 | pnpm run test 42 | ``` 43 | 44 | ## AI 45 | 46 | The starter includes an `AGENT.md`. Depending on which AI coding tool you use, you may need to copy/move it to the right file name e.g. `.cursor/rules`. 47 | 48 | ## Styling 49 | 50 | This project uses [Tailwind CSS](https://tailwindcss.com/) for styling. 51 | 52 | ## Routing 53 | 54 | This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`. 55 | 56 | ### Adding A Route 57 | 58 | To add a new route to your application just add another a new file in the `./src/routes` directory. 59 | 60 | TanStack will automatically generate the content of the route file for you. 61 | 62 | Now that you have two routes you can use a `Link` component to navigate between them. 63 | 64 | ### Adding Links 65 | 66 | To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`. 67 | 68 | ```tsx 69 | import { Link } from "@tanstack/react-router" 70 | ``` 71 | 72 | Then anywhere in your JSX you can use it like so: 73 | 74 | ```tsx 75 | About 76 | ``` 77 | 78 | This will create a link that will navigate to the `/about` route. 79 | 80 | More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent). 81 | 82 | ### Using A Layout 83 | 84 | In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `` component. 85 | 86 | Here is an example layout that includes a header: 87 | 88 | ```tsx 89 | import { Outlet, createRootRoute } from "@tanstack/react-router" 90 | import { TanStackRouterDevtools } from "@tanstack/react-router-devtools" 91 | 92 | import { Link } from "@tanstack/react-router" 93 | 94 | export const Route = createRootRoute({ 95 | component: () => ( 96 | <> 97 |
98 | 102 |
103 | 104 | 105 | 106 | ), 107 | }) 108 | ``` 109 | 110 | The `` component is not required so you can remove it if you don't want it in your layout. 111 | 112 | More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts). 113 | 114 | ## Data Fetching 115 | 116 | There are multiple ways to fetch data in your application. You can use TanStack DB to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered. 117 | 118 | For example: 119 | 120 | ```tsx 121 | const peopleRoute = createRoute({ 122 | getParentRoute: () => rootRoute, 123 | path: "/people", 124 | loader: async () => { 125 | const response = await fetch("https://swapi.dev/api/people") 126 | return response.json() as Promise<{ 127 | results: { 128 | name: string 129 | }[] 130 | }> 131 | }, 132 | component: () => { 133 | const data = peopleRoute.useLoaderData() 134 | return ( 135 |
    136 | {data.results.map((person) => ( 137 |
  • {person.name}
  • 138 | ))} 139 |
140 | ) 141 | }, 142 | }) 143 | ``` 144 | 145 | Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters). 146 | 147 | ### TanStack DB & Electric 148 | 149 | TanStack DB gives you robust support for real-time sync, live queries and local writes. With no stale data, super fast re-rendering and sub-millisecond cross-collection queries — even for large complex apps. 150 | 151 | [Electric](https://electric-sql.com/) is a Postgres sync engine. It solves the hard problems of sync for you, including [partial replication](https://electric-sql.com/docs/guides/shapes), [fan-out](https://electric-sql.com/docs/api/http#caching), and [data delivery](https://electric-sql.com/docs/api/http). 152 | 153 | Built on a TypeScript implementation of differential dataflow, TanStack DB provides: 154 | 155 | - 🔥 **Blazing fast query engine** - sub-millisecond live queries, even for complex queries with joins and aggregates 156 | - 🎯 **Fine-grained reactivity** - minimize component re-rendering 157 | - 💪 **Robust transaction primitives** - easy optimistic mutations with sync and lifecycle support 158 | - 🌟 **Normalized data** - keep your backend simple 159 | 160 | #### Core Concepts 161 | 162 | **Collections** - Typed sets of objects that can mirror a backend table or be populated with filtered views like `pendingTodos` or `decemberNewTodos`. Collections are just JavaScript data that you can load on demand. 163 | 164 | **Live Queries** - Run reactively against and across collections with support for joins, filters and aggregates. Powered by differential dataflow, query results update incrementally without re-running the whole query. 165 | 166 | **Transactional Optimistic Mutations** - Batch and stage local changes across collections with immediate application of local optimistic updates. Sync transactions to the backend with automatic rollbacks and management of optimistic state. 167 | 168 | #### Usage with ElectricSQL 169 | 170 | This starter uses ElectricSQL for a fully local-first experience with real-time sync: 171 | 172 | ```tsx 173 | import { createCollection } from "@tanstack/react-db" 174 | import { electricCollectionOptions } from "@tanstack/electric-db-collection" 175 | 176 | export const todoCollection = createCollection( 177 | electricCollectionOptions({ 178 | id: "todos", 179 | schema: todoSchema, 180 | // Electric syncs data using "shapes" - filtered views on database tables 181 | shapeOptions: { 182 | url: "https://api.electric-sql.cloud/v1/shape", 183 | params: { 184 | table: "todos", 185 | }, 186 | }, 187 | getKey: (item) => item.id, 188 | onInsert: async ({ transaction }) => { 189 | const { modified: newTodo } = transaction.mutations[0] 190 | const result = await trpc.todos.create.mutate({ 191 | text: newTodo.text, 192 | completed: newTodo.completed, 193 | // ... other fields 194 | }) 195 | return { txid: result.txid } 196 | }, 197 | // You can also implement onUpdate, onDelete as needed 198 | }) 199 | ) 200 | ``` 201 | 202 | Apply mutations with local optimistic state that automatically syncs: 203 | 204 | ```tsx 205 | const AddTodo = () => { 206 | return ( 207 |