create-edhor-stack 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/STACK.md +1086 -0
  4. package/dist/index.js +3181 -0
  5. package/package.json +44 -0
  6. package/templates/apps/api-elysia/package.json +21 -0
  7. package/templates/apps/api-elysia/src/index.ts +59 -0
  8. package/templates/apps/api-elysia/src/lib/eden.ts +25 -0
  9. package/templates/apps/api-elysia/src/lib/env.ts +18 -0
  10. package/templates/apps/api-elysia/src/routes/health.ts +13 -0
  11. package/templates/apps/api-elysia/src/routes/users.ts +117 -0
  12. package/templates/apps/api-elysia/tsconfig.json +15 -0
  13. package/templates/apps/api-hono/package.json +20 -0
  14. package/templates/apps/api-hono/src/index.ts +66 -0
  15. package/templates/apps/api-hono/src/lib/env.ts +18 -0
  16. package/templates/apps/api-hono/src/routes/health.ts +20 -0
  17. package/templates/apps/api-hono/src/routes/users.ts +110 -0
  18. package/templates/apps/api-hono/tsconfig.json +15 -0
  19. package/templates/apps/mobile/.env.example +9 -0
  20. package/templates/apps/mobile/app/_layout.tsx +16 -0
  21. package/templates/apps/mobile/app/index.tsx +39 -0
  22. package/templates/apps/mobile/app.json +37 -0
  23. package/templates/apps/mobile/assets/adaptive-icon.png +0 -0
  24. package/templates/apps/mobile/assets/favicon.png +0 -0
  25. package/templates/apps/mobile/assets/icon.png +0 -0
  26. package/templates/apps/mobile/assets/splash-icon.png +0 -0
  27. package/templates/apps/mobile/package.json +39 -0
  28. package/templates/apps/mobile/src/api/client.ts +51 -0
  29. package/templates/apps/mobile/src/api/index.ts +3 -0
  30. package/templates/apps/mobile/src/api/queries.ts +24 -0
  31. package/templates/apps/mobile/src/api/schemas.ts +32 -0
  32. package/templates/apps/mobile/src/lib/env.ts +40 -0
  33. package/templates/apps/mobile/src/lib/query-client.ts +28 -0
  34. package/templates/apps/mobile/src/lib/result.ts +45 -0
  35. package/templates/apps/mobile/src/lib/store.ts +63 -0
  36. package/templates/apps/mobile/tsconfig.json +10 -0
  37. package/templates/apps/web/.env.example +11 -0
  38. package/templates/apps/web/package.json +29 -0
  39. package/templates/apps/web/src/lib/env.ts +52 -0
  40. package/templates/apps/web/src/lib/queries.ts +27 -0
  41. package/templates/apps/web/src/lib/query-client.ts +11 -0
  42. package/templates/apps/web/src/router.tsx +17 -0
  43. package/templates/apps/web/src/routes/__root.tsx +32 -0
  44. package/templates/apps/web/src/routes/index.tsx +16 -0
  45. package/templates/apps/web/src/styles.css +26 -0
  46. package/templates/apps/web/tsconfig.json +10 -0
  47. package/templates/apps/web/vite.config.ts +21 -0
  48. package/templates/base/.claude/settings.json +33 -0
  49. package/templates/base/.claude/skills/add-api-endpoint.md +137 -0
  50. package/templates/base/.claude/skills/add-component.md +79 -0
  51. package/templates/base/.claude/skills/add-route.md +134 -0
  52. package/templates/base/.claude/skills/add-store.md +158 -0
  53. package/templates/base/.husky/pre-commit +1 -0
  54. package/templates/base/.lintstagedrc +4 -0
  55. package/templates/base/.node-version +1 -0
  56. package/templates/base/AGENTS.md +135 -0
  57. package/templates/base/CLAUDE.md.hbs +139 -0
  58. package/templates/base/Dockerfile +32 -0
  59. package/templates/base/biome.json +52 -0
  60. package/templates/base/fly.toml.hbs +20 -0
  61. package/templates/base/gitignore +36 -0
  62. package/templates/base/package.json.hbs +22 -0
  63. package/templates/base/tsconfig.json +14 -0
  64. package/templates/base/turbo.json +22 -0
  65. package/templates/packages/shared/package.json +17 -0
  66. package/templates/packages/shared/src/index.ts +4 -0
  67. package/templates/packages/shared/src/schemas.ts +50 -0
  68. package/templates/packages/shared/src/types.ts +47 -0
  69. package/templates/packages/shared/src/utils.ts +87 -0
  70. package/templates/packages/shared/tsconfig.json +14 -0
  71. package/templates/packages/stripe/package.json +18 -0
  72. package/templates/packages/stripe/src/client.ts +110 -0
  73. package/templates/packages/stripe/src/index.ts +3 -0
  74. package/templates/packages/stripe/src/schemas.ts +65 -0
  75. package/templates/packages/stripe/src/webhooks.ts +91 -0
  76. package/templates/packages/stripe/tsconfig.json +14 -0
  77. package/templates/packages/ui/components.json +19 -0
  78. package/templates/packages/ui/package.json +29 -0
  79. package/templates/packages/ui/src/components/button.tsx +58 -0
  80. package/templates/packages/ui/src/index.ts +5 -0
  81. package/templates/packages/ui/src/lib/utils.ts +6 -0
  82. package/templates/packages/ui/src/styles.css +120 -0
  83. package/templates/packages/ui/tsconfig.json +10 -0
package/STACK.md ADDED
@@ -0,0 +1,1086 @@
1
+ # STACK.md - Edhor Stack Best Practices
2
+
3
+ > This document captures the actual patterns used in Edhor production applications. It serves as context for AI assistants and developers working on projects scaffolded with create-edhor-stack.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ 1. [Core Stack](#core-stack)
10
+ 2. [Environment Variables](#environment-variables)
11
+ 3. [Expo Mobile Patterns](#expo-mobile-patterns)
12
+ 4. [TanStack Start Web Patterns](#tanstack-start-web-patterns)
13
+ 5. [Styling Patterns](#styling-patterns)
14
+ 6. [Database Options](#database-options)
15
+ 7. [Authentication](#authentication)
16
+ 8. [Code Organization](#code-organization)
17
+ 9. [UI/Accessibility Guidelines](#uiaccessibility-guidelines)
18
+
19
+ ---
20
+
21
+ ## Core Stack
22
+
23
+ ### Always Included
24
+
25
+ | Tool | Purpose | Version |
26
+ |------|---------|---------|
27
+ | Bun | Package manager & runtime | 1.3+ |
28
+ | Turborepo | Monorepo build orchestration | 2.5+ |
29
+ | TypeScript | Type safety (strict mode) | 5.8+ |
30
+ | Biome | Linting & formatting (replaces ESLint/Prettier) | 2.3+ |
31
+ | Husky | Git hooks | 9.1+ |
32
+ | TanStack Query | Server state management | 5.x |
33
+ | t3-env | Type-safe environment variables | 0.12+ |
34
+
35
+ ### Biome Configuration
36
+
37
+ ```json
38
+ {
39
+ "linter": {
40
+ "rules": {
41
+ "correctness": {
42
+ "noUnusedImports": "error",
43
+ "noUnusedVariables": "warn",
44
+ "useHookAtTopLevel": "error"
45
+ },
46
+ "style": {
47
+ "useImportType": "error"
48
+ }
49
+ }
50
+ },
51
+ "formatter": {
52
+ "indentStyle": "space",
53
+ "indentWidth": 2,
54
+ "lineWidth": 100
55
+ },
56
+ "javascript": {
57
+ "formatter": {
58
+ "quoteStyle": "single",
59
+ "trailingCommas": "es5",
60
+ "semicolons": "always"
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Environment Variables
69
+
70
+ Use [t3-env](https://env.t3.gg/) for type-safe environment variables with Zod validation.
71
+
72
+ ### Web (TanStack Start)
73
+
74
+ ```typescript
75
+ // src/lib/env.ts
76
+ import { createEnv } from '@t3-oss/env-core';
77
+ import { z } from 'zod';
78
+
79
+ export const env = createEnv({
80
+ server: {
81
+ DATABASE_URL: z.string().url(),
82
+ API_SECRET: z.string().min(32),
83
+ },
84
+ clientPrefix: 'VITE_',
85
+ client: {
86
+ VITE_APP_URL: z.string().url(),
87
+ VITE_PUBLIC_API_URL: z.string().url(),
88
+ },
89
+ runtimeEnv: {
90
+ DATABASE_URL: process.env.DATABASE_URL,
91
+ API_SECRET: process.env.API_SECRET,
92
+ VITE_APP_URL: process.env.VITE_APP_URL,
93
+ VITE_PUBLIC_API_URL: process.env.VITE_PUBLIC_API_URL,
94
+ },
95
+ emptyStringAsUndefined: true,
96
+ });
97
+ ```
98
+
99
+ ### Mobile (Expo)
100
+
101
+ ```typescript
102
+ // src/lib/env.ts
103
+ import { createEnv } from '@t3-oss/env-core';
104
+ import { z } from 'zod';
105
+
106
+ export const env = createEnv({
107
+ clientPrefix: 'EXPO_PUBLIC_',
108
+ client: {
109
+ EXPO_PUBLIC_API_URL: z.string().url(),
110
+ EXPO_PUBLIC_SENTRY_DSN: z.string().url().optional(),
111
+ },
112
+ runtimeEnv: {
113
+ EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL,
114
+ EXPO_PUBLIC_SENTRY_DSN: process.env.EXPO_PUBLIC_SENTRY_DSN,
115
+ },
116
+ emptyStringAsUndefined: true,
117
+ });
118
+ ```
119
+
120
+ ### Usage
121
+
122
+ ```typescript
123
+ // Always import from env.ts, never use process.env directly
124
+ import { env } from '@/lib/env';
125
+
126
+ // Type-safe access with autocomplete
127
+ const apiUrl = env.EXPO_PUBLIC_API_URL;
128
+
129
+ // Server variables throw if accessed on client
130
+ const secret = env.API_SECRET; // Error on client!
131
+ ```
132
+
133
+ ### .env Files
134
+
135
+ ```bash
136
+ # .env.local (never commit)
137
+ DATABASE_URL=postgresql://...
138
+ API_SECRET=your-secret-key
139
+
140
+ # Client variables (prefixed)
141
+ VITE_APP_URL=http://localhost:3000 # Web
142
+ EXPO_PUBLIC_API_URL=https://api.com # Mobile
143
+ ```
144
+
145
+ **Key rules:**
146
+ - Never use `process.env` directly - always use `env` object
147
+ - Server variables throw if accessed on client
148
+ - Client variables must be prefixed (`VITE_` or `EXPO_PUBLIC_`)
149
+ - All variables must be in `runtimeEnv` for bundler compatibility
150
+
151
+ ---
152
+
153
+ ## Expo Mobile Patterns
154
+
155
+ ### State Management: Zustand with Persistence
156
+
157
+ **Multiple specialized stores, not one giant store:**
158
+
159
+ ```typescript
160
+ // lib/store.ts
161
+ import AsyncStorage from '@react-native-async-storage/async-storage';
162
+ import { create } from 'zustand';
163
+ import { persist, createJSONStorage } from 'zustand/middleware';
164
+
165
+ // App settings store
166
+ interface AppState {
167
+ fontSize: 'small' | 'medium' | 'large';
168
+ theme: 'light' | 'dark' | 'system';
169
+ setFontSize: (size: AppState['fontSize']) => void;
170
+ setTheme: (theme: AppState['theme']) => void;
171
+ }
172
+
173
+ export const useAppStore = create<AppState>()(
174
+ persist(
175
+ (set) => ({
176
+ fontSize: 'medium',
177
+ theme: 'system',
178
+ setFontSize: (fontSize) => set({ fontSize }),
179
+ setTheme: (theme) => set({ theme }),
180
+ }),
181
+ {
182
+ name: 'app-storage',
183
+ storage: createJSONStorage(() => AsyncStorage),
184
+ }
185
+ )
186
+ );
187
+
188
+ // Search store (separate concern)
189
+ interface SearchState {
190
+ query: string;
191
+ recentSearches: string[];
192
+ setQuery: (query: string) => void;
193
+ addRecentSearch: (search: string) => void;
194
+ }
195
+
196
+ export const useSearchStore = create<SearchState>()(
197
+ persist(
198
+ (set) => ({
199
+ query: '',
200
+ recentSearches: [],
201
+ setQuery: (query) => set({ query }),
202
+ addRecentSearch: (search) =>
203
+ set((state) => ({
204
+ recentSearches: [search, ...state.recentSearches.filter((s) => s !== search)].slice(0, 10),
205
+ })),
206
+ }),
207
+ {
208
+ name: 'search-storage',
209
+ storage: createJSONStorage(() => AsyncStorage),
210
+ }
211
+ )
212
+ );
213
+ ```
214
+
215
+ **Always use selectors to prevent re-renders:**
216
+
217
+ ```typescript
218
+ // Good - only re-renders when fontSize changes
219
+ const fontSize = useAppStore((state) => state.fontSize);
220
+
221
+ // Bad - re-renders on ANY store change
222
+ const { fontSize } = useAppStore();
223
+ ```
224
+
225
+ ### API Layer: Zod Validation with fetchValidated
226
+
227
+ **Every API call validates responses with Zod:**
228
+
229
+ ```typescript
230
+ // api/client.ts
231
+ import { z } from 'zod';
232
+
233
+ export async function fetchValidated<T>(
234
+ url: string,
235
+ schema: z.ZodType<T>,
236
+ options?: RequestInit
237
+ ): Promise<T> {
238
+ const response = await fetch(url, options);
239
+
240
+ if (!response.ok) {
241
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
242
+ }
243
+
244
+ const data = await response.json();
245
+ return schema.parse(data);
246
+ }
247
+
248
+ // api/schemas.ts
249
+ export const ArticleSchema = z.object({
250
+ id: z.string(),
251
+ title: z.string(),
252
+ content: z.string(),
253
+ publishedAt: z.string().datetime(),
254
+ author: z.object({
255
+ name: z.string(),
256
+ avatar: z.string().url().optional(),
257
+ }),
258
+ });
259
+
260
+ export const ArticlesResponseSchema = z.object({
261
+ articles: z.array(ArticleSchema),
262
+ nextCursor: z.string().nullable(),
263
+ });
264
+
265
+ export type Article = z.infer<typeof ArticleSchema>;
266
+
267
+ // api/queries.ts
268
+ import { queryOptions } from '@tanstack/react-query';
269
+
270
+ export const articlesQueryOptions = (cursor?: string) =>
271
+ queryOptions({
272
+ queryKey: ['articles', { cursor }],
273
+ queryFn: () =>
274
+ fetchValidated(
275
+ `https://api.example.com/articles?cursor=${cursor ?? ''}`,
276
+ ArticlesResponseSchema
277
+ ),
278
+ });
279
+ ```
280
+
281
+ ### TanStack Query: Offline-First Configuration
282
+
283
+ ```typescript
284
+ // lib/query-client.ts
285
+ import { QueryClient } from '@tanstack/react-query';
286
+ import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
287
+ import AsyncStorage from '@react-native-async-storage/async-storage';
288
+
289
+ export const queryClient = new QueryClient({
290
+ defaultOptions: {
291
+ queries: {
292
+ // Keep cached data for 7 days (offline support)
293
+ gcTime: 1000 * 60 * 60 * 24 * 7,
294
+ // Data considered fresh for 5 minutes
295
+ staleTime: 1000 * 60 * 5,
296
+ // Try cache first, then network
297
+ networkMode: 'offlineFirst',
298
+ // Retry with exponential backoff
299
+ retry: 3,
300
+ retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
301
+ },
302
+ mutations: {
303
+ networkMode: 'offlineFirst',
304
+ },
305
+ },
306
+ });
307
+
308
+ // Persist to AsyncStorage for true offline support
309
+ export const persister = createAsyncStoragePersister({
310
+ storage: AsyncStorage,
311
+ key: 'REACT_QUERY_OFFLINE_CACHE',
312
+ });
313
+ ```
314
+
315
+ **App entry with persistence:**
316
+
317
+ ```typescript
318
+ // app/_layout.tsx
319
+ import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
320
+ import { queryClient, persister } from '@/lib/query-client';
321
+
322
+ export default function RootLayout() {
323
+ return (
324
+ <PersistQueryClientProvider client={queryClient} persistOptions={{ persister }}>
325
+ <Stack />
326
+ </PersistQueryClientProvider>
327
+ );
328
+ }
329
+ ```
330
+
331
+ ### Error Handling: Result Pattern
332
+
333
+ **Explicit error handling without try-catch everywhere:**
334
+
335
+ ```typescript
336
+ // lib/result.ts
337
+ export type Result<T, E = Error> =
338
+ | { ok: true; value: T }
339
+ | { ok: false; error: E };
340
+
341
+ export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
342
+ export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
343
+
344
+ // Usage in API calls
345
+ export async function fetchArticle(id: string): Promise<Result<Article, string>> {
346
+ try {
347
+ const article = await fetchValidated(`/api/articles/${id}`, ArticleSchema);
348
+ return ok(article);
349
+ } catch (e) {
350
+ if (e instanceof z.ZodError) {
351
+ return err('Invalid response format');
352
+ }
353
+ return err(e instanceof Error ? e.message : 'Unknown error');
354
+ }
355
+ }
356
+
357
+ // Usage in component
358
+ const result = await fetchArticle(id);
359
+ if (!result.ok) {
360
+ showToast(result.error);
361
+ return;
362
+ }
363
+ const article = result.value;
364
+ ```
365
+
366
+ ### Custom Hooks
367
+
368
+ ```typescript
369
+ // hooks/useNetworkStatus.ts
370
+ import NetInfo from '@react-native-community/netinfo';
371
+ import { useEffect, useState } from 'react';
372
+
373
+ export function useNetworkStatus() {
374
+ const [isConnected, setIsConnected] = useState<boolean | null>(null);
375
+
376
+ useEffect(() => {
377
+ return NetInfo.addEventListener((state) => {
378
+ setIsConnected(state.isConnected);
379
+ });
380
+ }, []);
381
+
382
+ return isConnected;
383
+ }
384
+
385
+ // hooks/useDebouncedState.ts
386
+ import { useState, useEffect } from 'react';
387
+
388
+ export function useDebouncedState<T>(initialValue: T, delay: number = 300) {
389
+ const [value, setValue] = useState(initialValue);
390
+ const [debouncedValue, setDebouncedValue] = useState(initialValue);
391
+
392
+ useEffect(() => {
393
+ const timer = setTimeout(() => setDebouncedValue(value), delay);
394
+ return () => clearTimeout(timer);
395
+ }, [value, delay]);
396
+
397
+ return [debouncedValue, setValue, value] as const;
398
+ }
399
+ ```
400
+
401
+ ### Virtualized Lists: FlashList
402
+
403
+ ```typescript
404
+ import { FlashList } from '@shopify/flash-list';
405
+
406
+ function ArticleList({ articles }: { articles: Article[] }) {
407
+ return (
408
+ <FlashList
409
+ data={articles}
410
+ renderItem={({ item }) => <ArticleCard article={item} />}
411
+ estimatedItemSize={120}
412
+ keyExtractor={(item) => item.id}
413
+ />
414
+ );
415
+ }
416
+ ```
417
+
418
+ ### Navigation: Expo Router
419
+
420
+ ```typescript
421
+ // app/(tabs)/_layout.tsx
422
+ import { Tabs } from 'expo-router';
423
+ import { Home, Search, Settings } from 'lucide-react-native';
424
+
425
+ export default function TabLayout() {
426
+ return (
427
+ <Tabs screenOptions={{ headerShown: false }}>
428
+ <Tabs.Screen
429
+ name="index"
430
+ options={{
431
+ title: 'Home',
432
+ tabBarIcon: ({ color, size }) => <Home color={color} size={size} />,
433
+ }}
434
+ />
435
+ <Tabs.Screen
436
+ name="search"
437
+ options={{
438
+ title: 'Search',
439
+ tabBarIcon: ({ color, size }) => <Search color={color} size={size} />,
440
+ }}
441
+ />
442
+ <Tabs.Screen
443
+ name="settings"
444
+ options={{
445
+ title: 'Settings',
446
+ tabBarIcon: ({ color, size }) => <Settings color={color} size={size} />,
447
+ }}
448
+ />
449
+ </Tabs>
450
+ );
451
+ }
452
+
453
+ // app/article/[slug].tsx - Dynamic route
454
+ import { useLocalSearchParams } from 'expo-router';
455
+
456
+ export default function ArticleScreen() {
457
+ const { slug } = useLocalSearchParams<{ slug: string }>();
458
+ const { data: article } = useQuery(articleQueryOptions(slug));
459
+ // ...
460
+ }
461
+ ```
462
+
463
+ ---
464
+
465
+ ## TanStack Start Web Patterns
466
+
467
+ > **Note**: TanStack Start uses Vite 6+ as its build tool. Configuration lives in `vite.config.ts` using the `@tanstack/react-start/plugin/vite` plugin.
468
+
469
+ ### Vite Configuration
470
+
471
+ ```typescript
472
+ // vite.config.ts
473
+ import { tanstackStart } from '@tanstack/react-start/plugin/vite';
474
+ import tailwindcss from '@tailwindcss/vite';
475
+ import viteReact from '@vitejs/plugin-react';
476
+ import { defineConfig } from 'vite';
477
+ import tsConfigPaths from 'vite-tsconfig-paths';
478
+
479
+ export default defineConfig({
480
+ server: {
481
+ port: 3000,
482
+ },
483
+ plugins: [
484
+ tailwindcss(),
485
+ tsConfigPaths({
486
+ projects: ['./tsconfig.json'],
487
+ }),
488
+ tanstackStart({
489
+ srcDirectory: 'src',
490
+ }),
491
+ viteReact(),
492
+ ],
493
+ });
494
+ ```
495
+
496
+ ### Router Configuration
497
+
498
+ ```typescript
499
+ // src/router.tsx
500
+ import { createRouter } from '@tanstack/react-router';
501
+ import { routeTree } from './routeTree.gen';
502
+
503
+ export function getRouter() {
504
+ const router = createRouter({
505
+ routeTree,
506
+ defaultPreload: 'intent',
507
+ scrollRestoration: true,
508
+ });
509
+ return router;
510
+ }
511
+
512
+ declare module '@tanstack/react-router' {
513
+ interface Register {
514
+ router: ReturnType<typeof getRouter>;
515
+ }
516
+ }
517
+ ```
518
+
519
+ ### Data Fetching: TanStack Query
520
+
521
+ **Query options factories for consistency:**
522
+
523
+ ```typescript
524
+ // lib/queries.ts
525
+ import { queryOptions } from '@tanstack/react-query';
526
+
527
+ export const projectsQueryOptions = queryOptions({
528
+ queryKey: ['projects'],
529
+ queryFn: async () => {
530
+ const response = await fetch('/api/projects');
531
+ return response.json();
532
+ },
533
+ staleTime: 1000 * 60 * 5,
534
+ });
535
+
536
+ export const projectQueryOptions = (id: string) =>
537
+ queryOptions({
538
+ queryKey: ['projects', id],
539
+ queryFn: async () => {
540
+ const response = await fetch(`/api/projects/${id}`);
541
+ return response.json();
542
+ },
543
+ });
544
+ ```
545
+
546
+ **Route loaders with React Query:**
547
+
548
+ ```typescript
549
+ // routes/projects.tsx
550
+ import { createFileRoute } from '@tanstack/react-router';
551
+ import { projectsQueryOptions } from '@/lib/queries';
552
+
553
+ export const Route = createFileRoute('/projects')({
554
+ loader: ({ context }) => context.queryClient.ensureQueryData(projectsQueryOptions),
555
+ component: ProjectsPage,
556
+ });
557
+
558
+ function ProjectsPage() {
559
+ const { data: projects } = useSuspenseQuery(projectsQueryOptions);
560
+ return <ProjectList projects={projects} />;
561
+ }
562
+ ```
563
+
564
+ ### Real-time Data: Convex
565
+
566
+ ```typescript
567
+ // convex/messages.ts
568
+ import { query, mutation } from './_generated/server';
569
+ import { v } from 'convex/values';
570
+
571
+ export const list = query({
572
+ args: { channelId: v.id('channels') },
573
+ handler: async (ctx, args) => {
574
+ return await ctx.db
575
+ .query('messages')
576
+ .withIndex('by_channel', (q) => q.eq('channelId', args.channelId))
577
+ .order('desc')
578
+ .take(50);
579
+ },
580
+ });
581
+
582
+ export const send = mutation({
583
+ args: {
584
+ channelId: v.id('channels'),
585
+ content: v.string(),
586
+ },
587
+ handler: async (ctx, args) => {
588
+ const identity = await ctx.auth.getUserIdentity();
589
+ if (!identity) throw new Error('Unauthorized');
590
+
591
+ return await ctx.db.insert('messages', {
592
+ channelId: args.channelId,
593
+ content: args.content,
594
+ authorId: identity.subject,
595
+ createdAt: Date.now(),
596
+ });
597
+ },
598
+ });
599
+ ```
600
+
601
+ ```typescript
602
+ // Component usage
603
+ import { useQuery, useMutation } from 'convex/react';
604
+ import { api } from '@/convex/_generated/api';
605
+
606
+ function Chat({ channelId }: { channelId: Id<'channels'> }) {
607
+ const messages = useQuery(api.messages.list, { channelId });
608
+ const sendMessage = useMutation(api.messages.send);
609
+
610
+ // messages automatically updates when database changes
611
+ }
612
+ ```
613
+
614
+ ### Tables: TanStack Table
615
+
616
+ ```typescript
617
+ // components/data-table.tsx
618
+ import {
619
+ useReactTable,
620
+ getCoreRowModel,
621
+ getSortedRowModel,
622
+ getFilteredRowModel,
623
+ getPaginationRowModel,
624
+ flexRender,
625
+ type ColumnDef,
626
+ type SortingState,
627
+ type ColumnFiltersState,
628
+ } from '@tanstack/react-table';
629
+
630
+ interface DataTableProps<T> {
631
+ data: T[];
632
+ columns: ColumnDef<T>[];
633
+ }
634
+
635
+ export function DataTable<T>({ data, columns }: DataTableProps<T>) {
636
+ const [sorting, setSorting] = useState<SortingState>([]);
637
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
638
+
639
+ const table = useReactTable({
640
+ data,
641
+ columns,
642
+ getCoreRowModel: getCoreRowModel(),
643
+ getSortedRowModel: getSortedRowModel(),
644
+ getFilteredRowModel: getFilteredRowModel(),
645
+ getPaginationRowModel: getPaginationRowModel(),
646
+ onSortingChange: setSorting,
647
+ onColumnFiltersChange: setColumnFilters,
648
+ state: { sorting, columnFilters },
649
+ });
650
+
651
+ return (
652
+ <div>
653
+ {/* Filter input */}
654
+ <input
655
+ placeholder="Filter..."
656
+ value={(table.getColumn('name')?.getFilterValue() as string) ?? ''}
657
+ onChange={(e) => table.getColumn('name')?.setFilterValue(e.target.value)}
658
+ />
659
+
660
+ {/* Table */}
661
+ <table>
662
+ <thead>
663
+ {table.getHeaderGroups().map((headerGroup) => (
664
+ <tr key={headerGroup.id}>
665
+ {headerGroup.headers.map((header) => (
666
+ <th key={header.id} onClick={header.column.getToggleSortingHandler()}>
667
+ {flexRender(header.column.columnDef.header, header.getContext())}
668
+ {{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? null}
669
+ </th>
670
+ ))}
671
+ </tr>
672
+ ))}
673
+ </thead>
674
+ <tbody>
675
+ {table.getRowModel().rows.map((row) => (
676
+ <tr key={row.id}>
677
+ {row.getVisibleCells().map((cell) => (
678
+ <td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
679
+ ))}
680
+ </tr>
681
+ ))}
682
+ </tbody>
683
+ </table>
684
+
685
+ {/* Pagination */}
686
+ <div>
687
+ <button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
688
+ Previous
689
+ </button>
690
+ <span>
691
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
692
+ </span>
693
+ <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
694
+ Next
695
+ </button>
696
+ </div>
697
+ </div>
698
+ );
699
+ }
700
+ ```
701
+
702
+ ### Server Functions
703
+
704
+ ```typescript
705
+ // routes/api/projects.ts
706
+ import { createServerFn } from '@tanstack/start';
707
+ import { db } from '@/lib/db';
708
+ import { projects } from '@/lib/schema';
709
+
710
+ export const getProjects = createServerFn('GET', async () => {
711
+ return await db.select().from(projects);
712
+ });
713
+
714
+ export const createProject = createServerFn('POST', async (data: { name: string; description?: string }) => {
715
+ const [project] = await db.insert(projects).values(data).returning();
716
+ return project;
717
+ });
718
+ ```
719
+
720
+ ---
721
+
722
+ ## Styling Patterns
723
+
724
+ ### Tailwind CSS v4
725
+
726
+ ```css
727
+ /* app.css */
728
+ @import "tailwindcss";
729
+
730
+ @theme {
731
+ --color-primary: oklch(0.7 0.15 200);
732
+ --color-secondary: oklch(0.6 0.1 250);
733
+ --font-sans: "Inter", system-ui, sans-serif;
734
+ }
735
+ ```
736
+
737
+ ### shadcn/ui with cn() Helper
738
+
739
+ ```typescript
740
+ // lib/utils.ts
741
+ import { clsx, type ClassValue } from 'clsx';
742
+ import { twMerge } from 'tailwind-merge';
743
+
744
+ export function cn(...inputs: ClassValue[]) {
745
+ return twMerge(clsx(inputs));
746
+ }
747
+ ```
748
+
749
+ ### Icons: Lucide
750
+
751
+ ```tsx
752
+ import { Search, Menu, X, ChevronRight } from 'lucide-react';
753
+
754
+ // Consistent sizing with size-* utility
755
+ <Search className="size-4" />
756
+ <Menu className="size-5" />
757
+
758
+ // React Native
759
+ import { Search, Menu } from 'lucide-react-native';
760
+ <Search color={colors.gray[500]} size={20} />
761
+ ```
762
+
763
+ ---
764
+
765
+ ## Database Options
766
+
767
+ ### Drizzle + PostgreSQL
768
+
769
+ **Schema with custom types (pgvector example):**
770
+
771
+ ```typescript
772
+ // lib/schema.ts
773
+ import { pgTable, text, timestamp, uuid, customType } from 'drizzle-orm/pg-core';
774
+
775
+ // Custom pgvector type
776
+ const vector = customType<{ data: number[]; driverData: string }>({
777
+ dataType() {
778
+ return 'vector(1536)';
779
+ },
780
+ toDriver(value: number[]): string {
781
+ return `[${value.join(',')}]`;
782
+ },
783
+ fromDriver(value: string): number[] {
784
+ return JSON.parse(value.replace('[', '[').replace(']', ']'));
785
+ },
786
+ });
787
+
788
+ export const documents = pgTable('documents', {
789
+ id: uuid('id').primaryKey().defaultRandom(),
790
+ content: text('content').notNull(),
791
+ embedding: vector('embedding'),
792
+ createdAt: timestamp('created_at').defaultNow().notNull(),
793
+ });
794
+
795
+ export const users = pgTable('users', {
796
+ id: uuid('id').primaryKey().defaultRandom(),
797
+ email: text('email').notNull().unique(),
798
+ name: text('name').notNull(),
799
+ createdAt: timestamp('created_at').defaultNow().notNull(),
800
+ });
801
+ ```
802
+
803
+ **Environment-aware client:**
804
+
805
+ ```typescript
806
+ // lib/db.ts
807
+ import { drizzle } from 'drizzle-orm/node-postgres';
808
+ import { Pool } from 'pg';
809
+ import * as schema from './schema';
810
+
811
+ const pool = new Pool({
812
+ connectionString: process.env.DATABASE_URL,
813
+ ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
814
+ });
815
+
816
+ export const db = drizzle(pool, { schema });
817
+ ```
818
+
819
+ ### Convex Schema
820
+
821
+ ```typescript
822
+ // convex/schema.ts
823
+ import { defineSchema, defineTable } from 'convex/server';
824
+ import { v } from 'convex/values';
825
+
826
+ export default defineSchema({
827
+ users: defineTable({
828
+ email: v.string(),
829
+ name: v.string(),
830
+ image: v.optional(v.string()),
831
+ }).index('by_email', ['email']),
832
+
833
+ projects: defineTable({
834
+ name: v.string(),
835
+ ownerId: v.id('users'),
836
+ createdAt: v.number(),
837
+ }).index('by_owner', ['ownerId']),
838
+
839
+ tasks: defineTable({
840
+ projectId: v.id('projects'),
841
+ title: v.string(),
842
+ completed: v.boolean(),
843
+ order: v.number(),
844
+ })
845
+ .index('by_project', ['projectId'])
846
+ .index('by_project_order', ['projectId', 'order']),
847
+ });
848
+ ```
849
+
850
+ ---
851
+
852
+ ## Authentication
853
+
854
+ ### Better Auth with Drizzle
855
+
856
+ ```typescript
857
+ // lib/auth.ts
858
+ import { betterAuth } from 'better-auth';
859
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
860
+ import { db } from './db';
861
+
862
+ export const auth = betterAuth({
863
+ database: drizzleAdapter(db, { provider: 'pg' }),
864
+ emailAndPassword: { enabled: true },
865
+ socialProviders: {
866
+ google: {
867
+ clientId: process.env.GOOGLE_CLIENT_ID!,
868
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
869
+ },
870
+ },
871
+ });
872
+ ```
873
+
874
+ ```typescript
875
+ // lib/auth-client.ts
876
+ import { createAuthClient } from 'better-auth/react';
877
+
878
+ export const authClient = createAuthClient({
879
+ baseURL: process.env.VITE_APP_URL,
880
+ });
881
+
882
+ export const { useSession, signIn, signOut } = authClient;
883
+ ```
884
+
885
+ ### Better Auth with Convex
886
+
887
+ ```typescript
888
+ // convex/auth.config.ts
889
+ import { convexAuth } from '@convex-dev/auth/server';
890
+ import Google from '@auth/core/providers/google';
891
+
892
+ export const { auth, signIn, signOut, store } = convexAuth({
893
+ providers: [Google],
894
+ });
895
+ ```
896
+
897
+ ### Protected Routes (TanStack Start)
898
+
899
+ ```typescript
900
+ // routes/dashboard.tsx
901
+ import { createFileRoute, redirect } from '@tanstack/react-router';
902
+ import { auth } from '@/lib/auth';
903
+
904
+ export const Route = createFileRoute('/dashboard')({
905
+ beforeLoad: async ({ context }) => {
906
+ const session = await auth.api.getSession({
907
+ headers: context.request.headers,
908
+ });
909
+ if (!session) {
910
+ throw redirect({ to: '/login' });
911
+ }
912
+ return { session };
913
+ },
914
+ component: Dashboard,
915
+ });
916
+ ```
917
+
918
+ ---
919
+
920
+ ## Code Organization
921
+
922
+ ### Monorepo Structure
923
+
924
+ ```
925
+ project/
926
+ ├── apps/
927
+ │ ├── web/ # TanStack Start
928
+ │ │ ├── src/
929
+ │ │ │ ├── routes/ # File-based routing
930
+ │ │ │ ├── components/ # App components
931
+ │ │ │ └── lib/ # Utilities, queries
932
+ │ │ └── package.json
933
+ │ └── mobile/ # Expo
934
+ │ ├── app/ # Expo Router
935
+ │ ├── src/
936
+ │ │ ├── api/ # API client, schemas
937
+ │ │ ├── components/
938
+ │ │ ├── hooks/
939
+ │ │ └── lib/ # Store, utils
940
+ │ └── package.json
941
+ ├── packages/
942
+ │ ├── ui/ # shadcn/ui components
943
+ │ └── database/ # Drizzle schema (if using)
944
+ ├── convex/ # Convex functions (if using)
945
+ ├── turbo.json
946
+ ├── biome.json
947
+ └── package.json
948
+ ```
949
+
950
+ ### Import Alias
951
+
952
+ All imports use `@/` prefix:
953
+
954
+ ```typescript
955
+ import { useAppStore } from '@/lib/store';
956
+ import { ArticleCard } from '@/components/article-card';
957
+ import { fetchValidated } from '@/api/client';
958
+ ```
959
+
960
+ ### Section Comments
961
+
962
+ Use this format for organizing large files:
963
+
964
+ ```typescript
965
+ // ============================================================================
966
+ // TYPES
967
+ // ============================================================================
968
+
969
+ interface User {
970
+ // ...
971
+ }
972
+
973
+ // ============================================================================
974
+ // STORE
975
+ // ============================================================================
976
+
977
+ export const useUserStore = create<UserState>()(...);
978
+
979
+ // ============================================================================
980
+ // HOOKS
981
+ // ============================================================================
982
+
983
+ export function useCurrentUser() {
984
+ // ...
985
+ }
986
+ ```
987
+
988
+ ---
989
+
990
+ ## UI/Accessibility Guidelines
991
+
992
+ ### Keyboard & Focus
993
+
994
+ - Full keyboard support per WAI-ARIA APG patterns
995
+ - Visible focus rings (`:focus-visible`)
996
+ - Focus management in modals/dialogs
997
+ - Never `outline: none` without replacement
998
+
999
+ ### Touch Targets
1000
+
1001
+ - Minimum 44x44px on mobile
1002
+ - `touch-action: manipulation` to prevent double-tap zoom
1003
+ - Input font-size >= 16px to prevent iOS zoom
1004
+
1005
+ ### Forms
1006
+
1007
+ - Keep submit enabled until request starts
1008
+ - Show spinner with original label during loading
1009
+ - Inline errors next to fields
1010
+ - Focus first error on submit
1011
+ - Warn on unsaved changes before navigation
1012
+
1013
+ ### Performance
1014
+
1015
+ - Virtualize lists > 50 items (FlashList for RN)
1016
+ - Preload above-fold images, lazy-load rest
1017
+ - Profile with CPU/network throttling
1018
+ - Mutations target < 500ms
1019
+
1020
+ ### Dark Mode
1021
+
1022
+ ```typescript
1023
+ // Set color-scheme on html element
1024
+ document.documentElement.style.colorScheme = theme;
1025
+
1026
+ // Use CSS variables for theming
1027
+ :root {
1028
+ --background: oklch(1 0 0);
1029
+ --foreground: oklch(0.1 0 0);
1030
+ }
1031
+
1032
+ .dark {
1033
+ --background: oklch(0.1 0 0);
1034
+ --foreground: oklch(0.95 0 0);
1035
+ }
1036
+ ```
1037
+
1038
+ ---
1039
+
1040
+ ## Quick Reference
1041
+
1042
+ ### Commands
1043
+
1044
+ ```bash
1045
+ # Development
1046
+ bun dev # Start all apps
1047
+ bun dev --filter=web # Start web only
1048
+ bun dev --filter=mobile # Start mobile only
1049
+
1050
+ # Building
1051
+ bun build # Build all
1052
+ turbo build --filter=web # Build specific
1053
+
1054
+ # Code quality
1055
+ bun lint # Lint all
1056
+ bun check # Lint + format with auto-fix
1057
+
1058
+ # Database (Drizzle)
1059
+ bun db:generate # Generate migration
1060
+ bun db:migrate # Apply migrations
1061
+ bun db:studio # Open Drizzle Studio
1062
+
1063
+ # Convex
1064
+ npx convex dev # Start Convex dev
1065
+ npx convex deploy # Deploy to production
1066
+
1067
+ # Mobile
1068
+ bun ios # Run on iOS simulator
1069
+ bun android # Run on Android emulator
1070
+ eas build --platform ios # Build for TestFlight
1071
+ ```
1072
+
1073
+ ### Key Dependencies
1074
+
1075
+ | Category | Web | Mobile |
1076
+ |----------|-----|--------|
1077
+ | Framework | TanStack Start | Expo SDK 54 |
1078
+ | Routing | TanStack Router | Expo Router |
1079
+ | State | TanStack Query | Zustand + TanStack Query |
1080
+ | Forms | Plain useState | Plain useState |
1081
+ | Tables | TanStack Table | - |
1082
+ | Lists | - | FlashList |
1083
+ | Auth | Better Auth | Better Auth |
1084
+ | Styling | Tailwind + shadcn | React Native StyleSheet |
1085
+
1086
+ ---