@kood/claude-code 0.6.6 → 0.7.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.
- package/dist/index.js +7 -1
- package/package.json +1 -1
- package/templates/.claude/agents/analyst.md +5 -0
- package/templates/.claude/agents/architect.md +5 -0
- package/templates/.claude/agents/build-fixer.md +1 -0
- package/templates/.claude/agents/code-reviewer.md +1 -0
- package/templates/.claude/agents/critic.md +4 -0
- package/templates/.claude/agents/deep-executor.md +1 -0
- package/templates/.claude/agents/dependency-manager.md +2 -0
- package/templates/.claude/agents/deployment-validator.md +2 -0
- package/templates/.claude/agents/designer.md +2 -0
- package/templates/.claude/agents/document-writer.md +3 -0
- package/templates/.claude/agents/explore.md +1 -0
- package/templates/.claude/agents/git-operator.md +2 -0
- package/templates/.claude/agents/implementation-executor.md +2 -0
- package/templates/.claude/agents/ko-to-en-translator.md +3 -0
- package/templates/.claude/agents/lint-fixer.md +2 -0
- package/templates/.claude/agents/planner.md +3 -0
- package/templates/.claude/agents/pm.md +349 -0
- package/templates/.claude/agents/qa-tester.md +1 -0
- package/templates/.claude/agents/refactor-advisor.md +4 -0
- package/templates/.claude/agents/researcher.md +9 -1
- package/templates/.claude/agents/scientist.md +1 -0
- package/templates/.claude/agents/security-reviewer.md +1 -0
- package/templates/.claude/agents/tdd-guide.md +1 -0
- package/templates/.claude/agents/vision.md +1 -0
- package/templates/.claude/instructions/agent-patterns/agent-teams-usage.md +376 -0
- package/templates/.claude/instructions/sourcing/reliable-search.md +49 -2
- package/templates/.claude/scripts/agent-teams/check-availability.sh +238 -0
- package/templates/.claude/scripts/agent-teams/setup-tmux.sh +125 -0
- package/templates/.claude/skills/agent-teams-setup/SKILL.md +460 -0
- package/templates/.claude/skills/brainstorm/SKILL.md +1 -0
- package/templates/.claude/skills/bug-fix/SKILL.md +1 -0
- package/templates/.claude/skills/crawler/SKILL.md +2 -0
- package/templates/.claude/skills/docs-creator/SKILL.md +1 -0
- package/templates/.claude/skills/docs-fetch/SKILL.md +6 -4
- package/templates/.claude/skills/docs-refactor/SKILL.md +1 -0
- package/templates/.claude/skills/elon-musk/SKILL.md +1 -0
- package/templates/.claude/skills/execute/SKILL.md +1 -0
- package/templates/.claude/skills/feedback/SKILL.md +1 -0
- package/templates/.claude/skills/figma-to-code/SKILL.md +1 -0
- package/templates/.claude/skills/genius-thinking/SKILL.md +1 -0
- package/templates/.claude/skills/global-uiux-design/SKILL.md +1 -0
- package/templates/.claude/skills/korea-uiux-design/SKILL.md +1 -0
- package/templates/.claude/skills/nextjs-react-best-practices/SKILL.md +1 -0
- package/templates/.claude/skills/plan/SKILL.md +1 -0
- package/templates/.claude/skills/prd/SKILL.md +1 -0
- package/templates/.claude/skills/project-optimizer/AGENTS.md +275 -0
- package/templates/.claude/skills/project-optimizer/SKILL.md +375 -0
- package/templates/.claude/skills/project-optimizer/rules/arch-config-centralize.md +66 -0
- package/templates/.claude/skills/project-optimizer/rules/arch-hot-path.md +35 -0
- package/templates/.claude/skills/project-optimizer/rules/arch-interface-segregation.md +51 -0
- package/templates/.claude/skills/project-optimizer/rules/arch-module-boundary.md +42 -0
- package/templates/.claude/skills/project-optimizer/rules/build-cache.md +57 -0
- package/templates/.claude/skills/project-optimizer/rules/build-code-split.md +56 -0
- package/templates/.claude/skills/project-optimizer/rules/build-incremental.md +65 -0
- package/templates/.claude/skills/project-optimizer/rules/build-minify.md +61 -0
- package/templates/.claude/skills/project-optimizer/rules/build-tree-shake.md +60 -0
- package/templates/.claude/skills/project-optimizer/rules/code-complexity.md +65 -0
- package/templates/.claude/skills/project-optimizer/rules/code-dead-elimination.md +32 -0
- package/templates/.claude/skills/project-optimizer/rules/code-duplication.md +54 -0
- package/templates/.claude/skills/project-optimizer/rules/code-error-handling.md +75 -0
- package/templates/.claude/skills/project-optimizer/rules/code-naming.md +52 -0
- package/templates/.claude/skills/project-optimizer/rules/concurrency-defer-await.md +54 -0
- package/templates/.claude/skills/project-optimizer/rules/concurrency-parallel.md +90 -0
- package/templates/.claude/skills/project-optimizer/rules/concurrency-pipeline.md +68 -0
- package/templates/.claude/skills/project-optimizer/rules/concurrency-pool.md +68 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-lightweight-alt.md +37 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-peer-align.md +44 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-security-audit.md +45 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-unused-removal.md +25 -0
- package/templates/.claude/skills/project-optimizer/rules/deps-version-pin.md +40 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-ci-speed.md +47 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-dev-server.md +35 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-lint-config.md +36 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-test-coverage.md +34 -0
- package/templates/.claude/skills/project-optimizer/rules/dx-type-safety.md +49 -0
- package/templates/.claude/skills/project-optimizer/rules/io-batch-queries.md +67 -0
- package/templates/.claude/skills/project-optimizer/rules/io-cache-layer.md +67 -0
- package/templates/.claude/skills/project-optimizer/rules/io-connection-reuse.md +67 -0
- package/templates/.claude/skills/project-optimizer/rules/io-serialize-minimal.md +61 -0
- package/templates/.claude/skills/project-optimizer/rules/io-stream.md +75 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-bounded-cache.md +65 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-large-data.md +64 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-lazy-init.md +78 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-leak-prevention.md +79 -0
- package/templates/.claude/skills/project-optimizer/rules/memory-pool-reuse.md +70 -0
- package/templates/.claude/skills/ralph/SKILL.md +1 -0
- package/templates/.claude/skills/refactor/SKILL.md +1 -0
- package/templates/.claude/skills/research/SKILL.md +1 -0
- package/templates/.claude/skills/sql-optimizer/SKILL.md +438 -0
- package/templates/.claude/skills/sql-optimizer/orm-patterns.md +218 -0
- package/templates/.claude/skills/startup-validator/SKILL.md +1 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/AGENTS.md +53 -14
- package/templates/.claude/skills/tanstack-start-react-best-practices/SKILL.md +94 -27
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/bundle-defer-third-party.md +42 -19
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/client-optimistic-updates.md +109 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/client-suspense-query.md +74 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/client-use-hook.md +81 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/rerender-react-compiler.md +81 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-beforeload-auth.md +121 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-file-conventions.md +104 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-link-navigation.md +119 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-nested-layouts.md +155 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-path-params.md +89 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-pending-component.md +110 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-preload-strategy.md +91 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-router-context.md +120 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/routing-search-params.md +114 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-deferred-data.md +1 -1
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-error-boundaries.md +79 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-middleware.md +85 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-serialization.md +56 -21
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-streaming.md +84 -0
- package/templates/.claude/skills/tanstack-start-react-best-practices/rules/server-validator.md +71 -0
- package/templates/.claude/skills/tauri-react-best-practices/AGENTS.md +527 -0
- package/templates/.claude/skills/tauri-react-best-practices/SKILL.md +571 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-barrel-imports.md +140 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-cargo-profile.md +96 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-frontend-treeshake.md +242 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-lazy-components.md +255 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/bundle-remove-unused-commands.md +160 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/deploy-ci-pipeline.md +269 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/deploy-signing.md +207 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/deploy-updater.md +226 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-async-commands.md +172 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-batch-commands.md +133 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-binary-response.md +198 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-channel-streaming.md +186 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-error-handling.md +250 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/ipc-type-safe.md +227 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/perf-derived-state.md +231 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/perf-functional-setstate.md +191 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/perf-index-maps.md +276 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/perf-lazy-state-init.md +196 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/plugin-lifecycle.md +265 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/plugin-mobile-compat.md +199 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/plugin-permission-scope.md +193 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-error-boundary.md +239 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-event-listener.md +151 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-file-src.md +155 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-invoke-hook.md +139 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/react-optimistic-update.md +211 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-capability-split.md +205 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-csp.md +207 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-least-privilege.md +106 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-no-wildcard.md +253 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/security-scope-paths.md +160 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/state-async-mutex.md +270 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/state-mutex-pattern.md +265 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/state-react-sync.md +375 -0
- package/templates/.claude/skills/tauri-react-best-practices/rules/state-single-container.md +275 -0
- package/templates/tanstack-start/docs/architecture.md +238 -167
- package/templates/tanstack-start/docs/library/tanstack-router/error-handling.md +777 -38
- package/templates/tanstack-start/docs/library/tanstack-router/hooks.md +549 -37
- package/templates/tanstack-start/docs/library/tanstack-router/index.md +895 -111
- package/templates/tanstack-start/docs/library/tanstack-router/navigation.md +641 -43
- package/templates/tanstack-start/docs/library/tanstack-router/route-context.md +889 -38
- package/templates/tanstack-start/docs/library/tanstack-router/search-params.md +891 -29
- package/templates/tanstack-start/docs/library/tanstack-start/auth-patterns.md +972 -36
- package/templates/tanstack-start/docs/library/tanstack-start/index.md +1525 -881
- package/templates/tanstack-start/docs/library/tanstack-start/middleware.md +1099 -20
- package/templates/tanstack-start/docs/library/tanstack-start/routing.md +796 -30
- package/templates/tanstack-start/docs/library/tanstack-start/server-functions.md +953 -35
- package/templates/tanstack-start/docs/library/tanstack-start/setup.md +371 -15
- package/templates/tauri/CLAUDE.md +189 -0
- package/templates/tauri/docs/guides/distribution.md +261 -0
- package/templates/tauri/docs/guides/getting-started.md +302 -0
- package/templates/tauri/docs/guides/mobile.md +288 -0
- package/templates/tauri/docs/library/tauri/index.md +510 -0
|
@@ -1,29 +1,34 @@
|
|
|
1
|
-
# TanStack Start
|
|
2
|
-
|
|
3
|
-
> v1 | Full-stack React Framework
|
|
4
|
-
|
|
5
|
-
---
|
|
1
|
+
# TanStack Start Documentation
|
|
6
2
|
|
|
7
3
|
<context>
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
**Scope:**
|
|
12
|
-
- Server Functions (타입 안전 API)
|
|
13
|
-
- File-based Routing (TanStack Router)
|
|
14
|
-
- Middleware 체계
|
|
15
|
-
- SSR + Streaming
|
|
16
|
-
- TanStack Query 통합
|
|
5
|
+
**목적**: TanStack Start 애플리케이션 개발을 위한 AI Agent 최적화 문서
|
|
17
6
|
|
|
18
|
-
|
|
19
|
-
- Type-safe Server Functions
|
|
20
|
-
- Zero-config file-based routing
|
|
21
|
-
- Built-in middleware system
|
|
22
|
-
- First-class SSR support
|
|
23
|
-
- Seamless TanStack Query integration
|
|
24
|
-
- Multiple deployment targets (Vercel, Cloudflare, Nitro)
|
|
7
|
+
**버전**: v1.x (RC, ~v1.159.x)
|
|
25
8
|
|
|
26
|
-
|
|
9
|
+
**핵심 특징**:
|
|
10
|
+
- Type-safe, full-stack React framework
|
|
11
|
+
- File-based routing with Vite-powered build
|
|
12
|
+
- Server Functions로 type-safe API 구축
|
|
13
|
+
- Built-in SWR caching (staleTime, gcTime, shouldReload)
|
|
14
|
+
- Streaming SSR 지원
|
|
15
|
+
- TanStack Query 통합
|
|
16
|
+
- Multiple deployment targets (Cloudflare, Netlify, Vercel, Railway, Node, Bun)
|
|
17
|
+
- React Server Components 지원 예정 (non-breaking v1.x addition)
|
|
18
|
+
|
|
19
|
+
**주요 패키지 변경사항**:
|
|
20
|
+
- `@tanstack/start` → `@tanstack/react-start` (v1.121.0+)
|
|
21
|
+
- Build system: Vinxi → Vite
|
|
22
|
+
- `.validator()` → `.inputValidator()` (deprecated)
|
|
23
|
+
- `createAPIFileRoute()` → `createServerFileRoute()` with `.methods()`
|
|
24
|
+
|
|
25
|
+
**런타임 요구사항**:
|
|
26
|
+
| 패키지 | 버전 |
|
|
27
|
+
|--------|------|
|
|
28
|
+
| Node.js | >=22.12.0 |
|
|
29
|
+
| React | 18+ or 19+ |
|
|
30
|
+
| Vite | 7+ |
|
|
31
|
+
| TypeScript | 5.x |
|
|
27
32
|
|
|
28
33
|
</context>
|
|
29
34
|
|
|
@@ -31,17 +36,20 @@
|
|
|
31
36
|
|
|
32
37
|
<forbidden>
|
|
33
38
|
|
|
34
|
-
|
|
|
35
|
-
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
|
39
|
+
| 패턴 | 이유 |
|
|
40
|
+
|------|------|
|
|
41
|
+
| `@tanstack/start` 패키지 사용 | v1.121.0+부터 `@tanstack/react-start`로 변경됨 |
|
|
42
|
+
| `.validator()` 사용 | Deprecated. `.inputValidator()` 사용 필수 |
|
|
43
|
+
| `createAPIFileRoute()` 사용 | Deprecated. `createServerFileRoute().methods()` 사용 |
|
|
44
|
+
| Vinxi config 참조 | Vite로 마이그레이션됨 |
|
|
45
|
+
| Server Function 내 `import.meta.env` | 클라이언트 전용. `process.env` 사용 |
|
|
46
|
+
| Client에서 `process.env` | 서버 전용. `import.meta.env.VITE_*` 사용 |
|
|
47
|
+
| `tsconfig.json`에 `verbatimModuleSyntax: true` | TanStack Start와 호환성 문제 발생 |
|
|
48
|
+
| Server Function에 민감 정보 직접 노출 | 클라이언트 번들에 포함됨. Helper function 분리 필수 |
|
|
49
|
+
| `any` 타입 사용 | Type safety 상실. `unknown` 또는 명시적 타입 사용 |
|
|
50
|
+
| Middleware 없이 인증 로직 | 보안 취약. `createMiddleware` 사용 필수 |
|
|
51
|
+
| Route 파일에 비즈니스 로직 | 관심사 분리. `-functions/` 디렉토리 사용 |
|
|
52
|
+
| `gcTime: 0` 남발 | 캐시 무효화. 필요한 경우만 사용 |
|
|
45
53
|
|
|
46
54
|
</forbidden>
|
|
47
55
|
|
|
@@ -49,16 +57,20 @@
|
|
|
49
57
|
|
|
50
58
|
<required>
|
|
51
59
|
|
|
52
|
-
|
|
|
53
|
-
|
|
54
|
-
|
|
|
55
|
-
|
|
|
56
|
-
|
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
|
|
|
61
|
-
|
|
|
60
|
+
| 패턴 | 세부사항 |
|
|
61
|
+
|------|----------|
|
|
62
|
+
| `@tanstack/react-start` 패키지 | v1.121.0+ 필수 패키지명 |
|
|
63
|
+
| `.inputValidator()` | POST/PUT/PATCH/DELETE Server Function에 필수 |
|
|
64
|
+
| Zod schema 검증 | 모든 입력값 검증 (`z.email()`, `z.url()` 등) |
|
|
65
|
+
| Middleware로 인증 처리 | `createMiddleware`로 auth 로직 중앙화 |
|
|
66
|
+
| Helper functions 분리 | Server Function 외부에 민감 로직 격리 |
|
|
67
|
+
| 명시적 return type | 모든 함수에 타입 명시 |
|
|
68
|
+
| `strictNullChecks: true` | TypeScript config에 권장 |
|
|
69
|
+
| Server Routes는 `createServerFileRoute()` | API 엔드포인트용 새 API |
|
|
70
|
+
| Environment variables 분리 | Server: `process.env`, Client: `import.meta.env.VITE_*` |
|
|
71
|
+
| TanStack Query로 Server Function 호출 | `useSuspenseQuery`, `useMutation` 사용 |
|
|
72
|
+
| `-components/`, `-hooks/`, `-functions/` 컨벤션 | 파일 구조화 규칙 |
|
|
73
|
+
| `beforeLoad`로 인증 가드 | Route-level 접근 제어 |
|
|
62
74
|
|
|
63
75
|
</required>
|
|
64
76
|
|
|
@@ -69,52 +81,118 @@
|
|
|
69
81
|
## 설치
|
|
70
82
|
|
|
71
83
|
```bash
|
|
72
|
-
|
|
73
|
-
|
|
84
|
+
# New project
|
|
85
|
+
npm create @tanstack/start@latest
|
|
86
|
+
|
|
87
|
+
# Dependencies
|
|
88
|
+
npm install @tanstack/react-start@latest @tanstack/react-router@latest
|
|
89
|
+
npm install -D @tanstack/router-plugin vite typescript
|
|
74
90
|
```
|
|
75
91
|
|
|
76
|
-
##
|
|
92
|
+
## vite.config.ts
|
|
77
93
|
|
|
78
94
|
```typescript
|
|
79
|
-
// vite.config.ts
|
|
80
95
|
import { defineConfig } from 'vite'
|
|
81
|
-
import
|
|
82
|
-
import {
|
|
83
|
-
import viteReact from '@vitejs/plugin-react'
|
|
96
|
+
import react from '@vitejs/plugin-react'
|
|
97
|
+
import { tanstackRouterPlugin } from '@tanstack/router-plugin/vite'
|
|
84
98
|
|
|
85
99
|
export default defineConfig({
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
plugins: [
|
|
101
|
+
tanstackRouterPlugin(),
|
|
102
|
+
react(),
|
|
103
|
+
],
|
|
104
|
+
server: {
|
|
105
|
+
port: 3000,
|
|
106
|
+
},
|
|
88
107
|
})
|
|
89
108
|
```
|
|
90
109
|
|
|
110
|
+
## tsconfig.json
|
|
111
|
+
|
|
91
112
|
```json
|
|
92
|
-
// tsconfig.json
|
|
93
113
|
{
|
|
94
114
|
"compilerOptions": {
|
|
95
115
|
"target": "ES2022",
|
|
116
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
96
117
|
"module": "ESNext",
|
|
97
|
-
"moduleResolution": "
|
|
98
|
-
"strict": true,
|
|
118
|
+
"moduleResolution": "Bundler",
|
|
99
119
|
"jsx": "react-jsx",
|
|
100
|
-
"
|
|
120
|
+
"strict": false,
|
|
121
|
+
"strictNullChecks": true,
|
|
122
|
+
"esModuleInterop": true,
|
|
123
|
+
"skipLibCheck": true,
|
|
124
|
+
"resolveJsonModule": true,
|
|
125
|
+
"isolatedModules": true,
|
|
126
|
+
"noEmit": true
|
|
127
|
+
},
|
|
128
|
+
"include": ["app/**/*"],
|
|
129
|
+
"exclude": ["node_modules"]
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**주의**: `verbatimModuleSyntax: true` 사용 금지 (호환성 문제)
|
|
134
|
+
|
|
135
|
+
## Environment Variables
|
|
136
|
+
|
|
137
|
+
### Server-only (.env)
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
# Server-only (process.env)
|
|
141
|
+
DATABASE_URL="postgresql://..."
|
|
142
|
+
AUTH_SECRET="secret-key"
|
|
143
|
+
STRIPE_SECRET_KEY="sk_test_..."
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Client-exposed (.env)
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# Client-exposed (import.meta.env.VITE_*)
|
|
150
|
+
VITE_API_URL="https://api.example.com"
|
|
151
|
+
VITE_PUBLIC_KEY="pk_test_..."
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 사용 예시
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
// ✅ Server Function
|
|
158
|
+
export const getSecretData = createServerFn({ method: 'GET' }).handler(
|
|
159
|
+
async () => {
|
|
160
|
+
const secret = process.env.AUTH_SECRET // OK
|
|
161
|
+
return { data: 'protected' }
|
|
101
162
|
}
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
// ✅ Client Component
|
|
166
|
+
function App() {
|
|
167
|
+
const apiUrl = import.meta.env.VITE_API_URL // OK
|
|
168
|
+
return <div>{apiUrl}</div>
|
|
102
169
|
}
|
|
170
|
+
|
|
171
|
+
// ❌ Wrong
|
|
172
|
+
export const wrongFn = createServerFn({ method: 'GET' }).handler(async () => {
|
|
173
|
+
const key = import.meta.env.VITE_SECRET // ❌ 서버에서 import.meta.env 사용 금지
|
|
174
|
+
return key
|
|
175
|
+
})
|
|
103
176
|
```
|
|
104
177
|
|
|
105
|
-
|
|
178
|
+
### Validation (app/env.ts)
|
|
106
179
|
|
|
107
180
|
```typescript
|
|
108
|
-
// lib/env.ts
|
|
109
181
|
import { z } from 'zod'
|
|
110
182
|
|
|
111
|
-
const
|
|
112
|
-
NODE_ENV: z.enum(['development', 'production', 'test']),
|
|
183
|
+
const serverEnvSchema = z.object({
|
|
113
184
|
DATABASE_URL: z.string().url(),
|
|
114
|
-
|
|
185
|
+
AUTH_SECRET: z.string().min(32),
|
|
186
|
+
NODE_ENV: z.enum(['development', 'production', 'test']),
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const clientEnvSchema = z.object({
|
|
190
|
+
VITE_API_URL: z.string().url(),
|
|
115
191
|
})
|
|
116
192
|
|
|
117
|
-
export const
|
|
193
|
+
export const serverEnv = serverEnvSchema.parse(process.env)
|
|
194
|
+
|
|
195
|
+
export const clientEnv = clientEnvSchema.parse(import.meta.env)
|
|
118
196
|
```
|
|
119
197
|
|
|
120
198
|
</setup>
|
|
@@ -123,184 +201,164 @@ export const env = envSchema.parse(process.env)
|
|
|
123
201
|
|
|
124
202
|
<server_functions>
|
|
125
203
|
|
|
126
|
-
## Server Functions
|
|
204
|
+
## Server Functions 개요
|
|
127
205
|
|
|
128
|
-
|
|
206
|
+
Server Functions는 type-safe한 API 엔드포인트를 생성하는 TanStack Start의 핵심 기능입니다.
|
|
129
207
|
|
|
130
|
-
|
|
208
|
+
**주요 특징**:
|
|
209
|
+
- Type-safe client-server communication
|
|
210
|
+
- Automatic serialization/deserialization
|
|
211
|
+
- Middleware support
|
|
212
|
+
- Input validation with Zod
|
|
213
|
+
- Method-based routing (GET, POST, PUT, PATCH, DELETE)
|
|
131
214
|
|
|
132
|
-
|
|
133
|
-
|--------|----------|---------------|-----------|
|
|
134
|
-
| **GET** | 데이터 조회 | ❌ 선택 | ✅ 인증 시 필수 |
|
|
135
|
-
| **POST** | 데이터 생성 | ✅ 필수 | ✅ 인증 시 필수 |
|
|
136
|
-
| **PUT** | 전체 수정 | ✅ 필수 | ✅ 인증 시 필수 |
|
|
137
|
-
| **PATCH** | 부분 수정 | ✅ 필수 | ✅ 인증 시 필수 |
|
|
138
|
-
| **DELETE** | 삭제 | ❌ 선택 | ✅ 인증 시 필수 |
|
|
215
|
+
## createServerFn 패턴
|
|
139
216
|
|
|
140
|
-
### GET
|
|
217
|
+
### GET (Query)
|
|
141
218
|
|
|
142
219
|
```typescript
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return prisma.user.findMany()
|
|
147
|
-
})
|
|
220
|
+
// app/functions/users.ts
|
|
221
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
222
|
+
import { z } from 'zod'
|
|
148
223
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
.handler(async ({ context }) => {
|
|
153
|
-
return prisma.user.findUnique({
|
|
154
|
-
where: { id: context.user.id },
|
|
155
|
-
})
|
|
156
|
-
})
|
|
224
|
+
const getUserSchema = z.object({
|
|
225
|
+
id: z.string().uuid(),
|
|
226
|
+
})
|
|
157
227
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
.inputValidator(z.object({ id: z.string() }))
|
|
228
|
+
export const getUser = createServerFn({ method: 'GET' })
|
|
229
|
+
.inputValidator(getUserSchema)
|
|
161
230
|
.handler(async ({ data }) => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
231
|
+
const user = await db.user.findUnique({ where: { id: data.id } })
|
|
232
|
+
if (!user) throw new Error('User not found')
|
|
233
|
+
return user
|
|
165
234
|
})
|
|
166
235
|
```
|
|
167
236
|
|
|
168
|
-
### POST
|
|
237
|
+
### POST (Create)
|
|
169
238
|
|
|
170
239
|
```typescript
|
|
171
|
-
//
|
|
240
|
+
// app/functions/users.ts
|
|
241
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
242
|
+
import { z } from 'zod'
|
|
243
|
+
|
|
172
244
|
const createUserSchema = z.object({
|
|
173
245
|
email: z.email(),
|
|
174
|
-
name: z.string().min(
|
|
175
|
-
|
|
246
|
+
name: z.string().min(2),
|
|
247
|
+
website: z.url().optional(),
|
|
176
248
|
})
|
|
177
249
|
|
|
178
250
|
export const createUser = createServerFn({ method: 'POST' })
|
|
179
251
|
.inputValidator(createUserSchema)
|
|
180
|
-
.handler(async ({ data }) => {
|
|
181
|
-
// data는 자동으로 검증됨
|
|
182
|
-
return prisma.user.create({ data })
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
// ✅ POST + inputValidator + 인증
|
|
186
|
-
export const createPost = createServerFn({ method: 'POST' })
|
|
187
252
|
.middleware([authMiddleware])
|
|
188
|
-
.inputValidator(z.object({
|
|
189
|
-
title: z.string().min(1).max(200),
|
|
190
|
-
content: z.string().min(1),
|
|
191
|
-
}))
|
|
192
253
|
.handler(async ({ data, context }) => {
|
|
193
|
-
|
|
254
|
+
const user = await db.user.create({
|
|
194
255
|
data: {
|
|
195
256
|
...data,
|
|
196
|
-
|
|
257
|
+
createdBy: context.user.id,
|
|
197
258
|
},
|
|
198
259
|
})
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
// ❌ inputValidator 없이 POST (금지)
|
|
202
|
-
export const badCreate = createServerFn({ method: 'POST' })
|
|
203
|
-
.handler(async ({ data }) => {
|
|
204
|
-
// data 타입 불안전, 검증 없음
|
|
205
|
-
return prisma.user.create({ data })
|
|
260
|
+
return user
|
|
206
261
|
})
|
|
207
262
|
```
|
|
208
263
|
|
|
209
|
-
### PUT/PATCH
|
|
264
|
+
### PUT/PATCH (Update)
|
|
210
265
|
|
|
211
266
|
```typescript
|
|
212
|
-
//
|
|
267
|
+
// app/functions/users.ts
|
|
213
268
|
const updateUserSchema = z.object({
|
|
214
|
-
id: z.string(),
|
|
215
|
-
email: z.email(),
|
|
216
|
-
name: z.string().min(
|
|
269
|
+
id: z.string().uuid(),
|
|
270
|
+
email: z.email().optional(),
|
|
271
|
+
name: z.string().min(2).optional(),
|
|
217
272
|
})
|
|
218
273
|
|
|
219
274
|
export const updateUser = createServerFn({ method: 'PUT' })
|
|
220
|
-
.middleware([authMiddleware])
|
|
221
275
|
.inputValidator(updateUserSchema)
|
|
222
|
-
.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
data: { email: data.email, name: data.name },
|
|
226
|
-
})
|
|
227
|
-
})
|
|
276
|
+
.middleware([authMiddleware])
|
|
277
|
+
.handler(async ({ data, context }) => {
|
|
278
|
+
const { id, ...updates } = data
|
|
228
279
|
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
age: z.number().int().min(0).optional(),
|
|
234
|
-
})
|
|
280
|
+
// Authorization check
|
|
281
|
+
if (context.user.id !== id && context.user.role !== 'admin') {
|
|
282
|
+
throw new Error('Unauthorized')
|
|
283
|
+
}
|
|
235
284
|
|
|
236
|
-
|
|
237
|
-
.middleware([authMiddleware])
|
|
238
|
-
.inputValidator(patchUserSchema)
|
|
239
|
-
.handler(async ({ data }) => {
|
|
240
|
-
const { id, ...updateData } = data
|
|
241
|
-
return prisma.user.update({
|
|
285
|
+
const user = await db.user.update({
|
|
242
286
|
where: { id },
|
|
243
|
-
data:
|
|
287
|
+
data: updates,
|
|
244
288
|
})
|
|
289
|
+
return user
|
|
245
290
|
})
|
|
246
291
|
```
|
|
247
292
|
|
|
248
|
-
### DELETE
|
|
293
|
+
### DELETE
|
|
249
294
|
|
|
250
295
|
```typescript
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
.
|
|
254
|
-
|
|
255
|
-
.handler(async ({ data, context }) => {
|
|
256
|
-
// 권한 체크
|
|
257
|
-
const post = await prisma.post.findUnique({
|
|
258
|
-
where: { id: data.id },
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
if (post?.authorId !== context.user.id) {
|
|
262
|
-
throw new Error('Unauthorized')
|
|
263
|
-
}
|
|
296
|
+
// app/functions/users.ts
|
|
297
|
+
const deleteUserSchema = z.object({
|
|
298
|
+
id: z.string().uuid(),
|
|
299
|
+
})
|
|
264
300
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
301
|
+
export const deleteUser = createServerFn({ method: 'DELETE' })
|
|
302
|
+
.inputValidator(deleteUserSchema)
|
|
303
|
+
.middleware([authMiddleware, adminMiddleware])
|
|
304
|
+
.handler(async ({ data }) => {
|
|
305
|
+
await db.user.delete({ where: { id: data.id } })
|
|
306
|
+
return { success: true }
|
|
268
307
|
})
|
|
269
308
|
```
|
|
270
309
|
|
|
271
|
-
|
|
310
|
+
## Client에서 호출 (TanStack Query)
|
|
272
311
|
|
|
273
|
-
|
|
274
|
-
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
312
|
+
### useSuspenseQuery (GET)
|
|
275
313
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
314
|
+
```typescript
|
|
315
|
+
// app/routes/users.$userId.tsx
|
|
316
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
317
|
+
import { useSuspenseQuery } from '@tanstack/react-query'
|
|
318
|
+
import { getUser } from '~/functions/users'
|
|
319
|
+
|
|
320
|
+
export const Route = createFileRoute('/users/$userId')({
|
|
321
|
+
component: UserDetail,
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
function UserDetail() {
|
|
325
|
+
const { userId } = Route.useParams()
|
|
282
326
|
|
|
283
|
-
|
|
284
|
-
|
|
327
|
+
const { data: user } = useSuspenseQuery({
|
|
328
|
+
queryKey: ['user', userId],
|
|
329
|
+
queryFn: () => getUser({ data: { id: userId } }),
|
|
330
|
+
})
|
|
285
331
|
|
|
286
332
|
return (
|
|
287
|
-
<
|
|
288
|
-
{
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
</ul>
|
|
333
|
+
<div>
|
|
334
|
+
<h1>{user.name}</h1>
|
|
335
|
+
<p>{user.email}</p>
|
|
336
|
+
</div>
|
|
292
337
|
)
|
|
293
338
|
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### useMutation (POST/PUT/DELETE)
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
// app/routes/users.new.tsx
|
|
345
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
346
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
347
|
+
import { createUser } from '~/functions/users'
|
|
294
348
|
|
|
295
|
-
|
|
296
|
-
|
|
349
|
+
export const Route = createFileRoute('/users/new')({
|
|
350
|
+
component: NewUser,
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
function NewUser() {
|
|
297
354
|
const queryClient = useQueryClient()
|
|
355
|
+
const navigate = Route.useNavigate()
|
|
298
356
|
|
|
299
357
|
const mutation = useMutation({
|
|
300
358
|
mutationFn: createUser,
|
|
301
|
-
onSuccess: () => {
|
|
302
|
-
// 캐시 무효화 → 자동 리페치
|
|
359
|
+
onSuccess: (user) => {
|
|
303
360
|
queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
361
|
+
navigate({ to: '/users/$userId', params: { userId: user.id } })
|
|
304
362
|
},
|
|
305
363
|
})
|
|
306
364
|
|
|
@@ -309,96 +367,123 @@ const CreateUserForm = (): JSX.Element => {
|
|
|
309
367
|
const formData = new FormData(e.currentTarget)
|
|
310
368
|
|
|
311
369
|
mutation.mutate({
|
|
312
|
-
|
|
313
|
-
|
|
370
|
+
data: {
|
|
371
|
+
email: formData.get('email') as string,
|
|
372
|
+
name: formData.get('name') as string,
|
|
373
|
+
},
|
|
314
374
|
})
|
|
315
375
|
}
|
|
316
376
|
|
|
317
377
|
return (
|
|
318
378
|
<form onSubmit={handleSubmit}>
|
|
319
379
|
<input name="email" type="email" required />
|
|
320
|
-
<input name="name" required />
|
|
380
|
+
<input name="name" type="text" required />
|
|
321
381
|
<button type="submit" disabled={mutation.isPending}>
|
|
322
|
-
{mutation.isPending ? 'Creating...' : 'Create'}
|
|
382
|
+
{mutation.isPending ? 'Creating...' : 'Create User'}
|
|
323
383
|
</button>
|
|
324
|
-
{mutation.
|
|
384
|
+
{mutation.isError && <p>Error: {mutation.error.message}</p>}
|
|
325
385
|
</form>
|
|
326
386
|
)
|
|
327
387
|
}
|
|
388
|
+
```
|
|
328
389
|
|
|
329
|
-
|
|
330
|
-
const BadComponent = (): JSX.Element => {
|
|
331
|
-
const [users, setUsers] = useState([])
|
|
332
|
-
|
|
333
|
-
useEffect(() => {
|
|
334
|
-
getUsers().then(setUsers) // ❌ 직접 호출
|
|
335
|
-
}, [])
|
|
390
|
+
## Helper Functions 패턴
|
|
336
391
|
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
```
|
|
392
|
+
**규칙**: 민감한 로직(DB 쿼리, 인증, 비즈니스 로직)은 Server Function 외부에 분리
|
|
340
393
|
|
|
341
|
-
###
|
|
394
|
+
### ✅ 올바른 패턴
|
|
342
395
|
|
|
343
396
|
```typescript
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
397
|
+
// app/functions/users-helpers.ts
|
|
398
|
+
import 'server-only' // Ensures this file is never bundled for client
|
|
399
|
+
|
|
400
|
+
export async function getUserFromDB(id: string) {
|
|
401
|
+
const user = await db.user.findUnique({ where: { id } })
|
|
402
|
+
if (!user) throw new Error('User not found')
|
|
403
|
+
return user
|
|
348
404
|
}
|
|
349
405
|
|
|
406
|
+
export async function createUserInDB(data: {
|
|
407
|
+
email: string
|
|
408
|
+
name: string
|
|
409
|
+
passwordHash: string
|
|
410
|
+
}) {
|
|
411
|
+
return db.user.create({ data })
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// app/functions/users.ts
|
|
415
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
416
|
+
import { getUserFromDB, createUserInDB } from './users-helpers'
|
|
417
|
+
|
|
418
|
+
export const getUser = createServerFn({ method: 'GET' })
|
|
419
|
+
.inputValidator(getUserSchema)
|
|
420
|
+
.handler(async ({ data }) => getUserFromDB(data.id))
|
|
421
|
+
|
|
350
422
|
export const createUser = createServerFn({ method: 'POST' })
|
|
351
423
|
.inputValidator(createUserSchema)
|
|
424
|
+
.middleware([authMiddleware])
|
|
352
425
|
.handler(async ({ data }) => {
|
|
353
|
-
await
|
|
354
|
-
return
|
|
426
|
+
const passwordHash = await hashPassword(data.password)
|
|
427
|
+
return createUserInDB({ ...data, passwordHash })
|
|
355
428
|
})
|
|
429
|
+
```
|
|
356
430
|
|
|
357
|
-
|
|
358
|
-
const validateUserData = async (email: string) => {
|
|
359
|
-
const exists = await prisma.user.findUnique({ where: { email } })
|
|
360
|
-
if (exists) throw new Error('Email already exists')
|
|
361
|
-
}
|
|
431
|
+
### ❌ 잘못된 패턴
|
|
362
432
|
|
|
363
|
-
|
|
364
|
-
|
|
433
|
+
```typescript
|
|
434
|
+
// ❌ Server Function 내부에 직접 DB 로직
|
|
435
|
+
export const getUser = createServerFn({ method: 'GET' })
|
|
436
|
+
.inputValidator(getUserSchema)
|
|
365
437
|
.handler(async ({ data }) => {
|
|
366
|
-
|
|
367
|
-
|
|
438
|
+
// ❌ 민감 로직이 클라이언트 번들에 포함될 수 있음
|
|
439
|
+
const user = await db.user.findUnique({ where: { id: data.id } })
|
|
440
|
+
return user
|
|
368
441
|
})
|
|
369
|
-
|
|
370
|
-
// index.ts: Server Function만 export
|
|
371
|
-
export { getUsers, createUser, updateUser } from './user-functions'
|
|
372
|
-
// ❌ export { validateUserData } 금지
|
|
373
442
|
```
|
|
374
443
|
|
|
375
|
-
|
|
444
|
+
## Environment Variables 보안
|
|
376
445
|
|
|
377
446
|
```typescript
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
447
|
+
// ✅ Helper function에서 env 사용
|
|
448
|
+
// app/functions/stripe-helpers.ts
|
|
449
|
+
import 'server-only'
|
|
450
|
+
import Stripe from 'stripe'
|
|
451
|
+
|
|
452
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
453
|
+
apiVersion: '2023-10-16',
|
|
385
454
|
})
|
|
386
455
|
|
|
387
|
-
|
|
388
|
-
|
|
456
|
+
export async function createPaymentIntent(amount: number) {
|
|
457
|
+
return stripe.paymentIntents.create({
|
|
458
|
+
amount,
|
|
459
|
+
currency: 'usd',
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// app/functions/stripe.ts
|
|
464
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
465
|
+
import { createPaymentIntent } from './stripe-helpers'
|
|
466
|
+
|
|
467
|
+
export const createPayment = createServerFn({ method: 'POST' })
|
|
468
|
+
.inputValidator(z.object({ amount: z.number().positive() }))
|
|
389
469
|
.middleware([authMiddleware])
|
|
470
|
+
.handler(async ({ data }) => createPaymentIntent(data.amount))
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
## Static Server Functions (Experimental)
|
|
474
|
+
|
|
475
|
+
정적 사이트 생성 시 Server Functions를 빌드 타임에 실행:
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
479
|
+
import { staticFunctionMiddleware } from '@tanstack/react-start/static'
|
|
480
|
+
|
|
481
|
+
export const getStaticPosts = createServerFn({ method: 'GET' })
|
|
482
|
+
.middleware([staticFunctionMiddleware])
|
|
390
483
|
.handler(async () => {
|
|
391
|
-
const
|
|
392
|
-
return
|
|
484
|
+
const posts = await fetchAllPosts()
|
|
485
|
+
return posts
|
|
393
486
|
})
|
|
394
|
-
|
|
395
|
-
export const Route = createFileRoute('/config')({
|
|
396
|
-
loader: async () => {
|
|
397
|
-
const config = await getConfig()
|
|
398
|
-
return config
|
|
399
|
-
},
|
|
400
|
-
component: ConfigPage,
|
|
401
|
-
})
|
|
402
487
|
```
|
|
403
488
|
|
|
404
489
|
</server_functions>
|
|
@@ -407,189 +492,272 @@ export const Route = createFileRoute('/config')({
|
|
|
407
492
|
|
|
408
493
|
<middleware>
|
|
409
494
|
|
|
410
|
-
## Middleware
|
|
411
|
-
|
|
412
|
-
Server Function 및 라우트에 공통 로직 적용.
|
|
495
|
+
## Middleware 개요
|
|
413
496
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
```typescript
|
|
417
|
-
// 미들웨어 정의
|
|
418
|
-
const loggingMiddleware = createMiddleware({ type: 'function' })
|
|
419
|
-
.server(({ next }) => {
|
|
420
|
-
console.log('Processing request')
|
|
421
|
-
return next()
|
|
422
|
-
})
|
|
497
|
+
Middleware는 Server Functions와 Routes에서 재사용 가능한 로직을 제공합니다.
|
|
423
498
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
499
|
+
**사용 사례**:
|
|
500
|
+
- 인증/인가
|
|
501
|
+
- 입력 검증
|
|
502
|
+
- 로깅
|
|
503
|
+
- Rate limiting
|
|
504
|
+
- CORS 설정
|
|
429
505
|
|
|
430
|
-
|
|
506
|
+
## createMiddleware
|
|
431
507
|
|
|
432
508
|
```typescript
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const session = await getSession()
|
|
437
|
-
if (!session?.user) {
|
|
438
|
-
throw redirect({ to: '/login' })
|
|
439
|
-
}
|
|
440
|
-
return next({ context: { user: session.user } })
|
|
441
|
-
})
|
|
509
|
+
// app/middleware/auth.ts
|
|
510
|
+
import { createMiddleware } from '@tanstack/react-start'
|
|
511
|
+
import { getSessionFromCookie } from '~/auth-helpers'
|
|
442
512
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
.middleware([authMiddleware])
|
|
446
|
-
.handler(async ({ context }) => {
|
|
447
|
-
return prisma.post.findMany({
|
|
448
|
-
where: { authorId: context.user.id },
|
|
449
|
-
})
|
|
450
|
-
})
|
|
513
|
+
export const authMiddleware = createMiddleware().server(async ({ next }) => {
|
|
514
|
+
const session = await getSessionFromCookie()
|
|
451
515
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const session = await getSession()
|
|
456
|
-
if (!session?.user || session.user.role !== 'ADMIN') {
|
|
457
|
-
throw new Error('Forbidden: Admin only')
|
|
458
|
-
}
|
|
459
|
-
return next({ context: { user: session.user } })
|
|
460
|
-
})
|
|
516
|
+
if (!session) {
|
|
517
|
+
throw new Error('Unauthorized')
|
|
518
|
+
}
|
|
461
519
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
return prisma.post.delete({ where: { id: data.id } })
|
|
520
|
+
return next({
|
|
521
|
+
context: {
|
|
522
|
+
user: session.user,
|
|
523
|
+
sessionId: session.id,
|
|
524
|
+
},
|
|
468
525
|
})
|
|
526
|
+
})
|
|
469
527
|
```
|
|
470
528
|
|
|
471
|
-
|
|
529
|
+
## Zod Validation Middleware
|
|
472
530
|
|
|
473
531
|
```typescript
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
.server(async ({ next, data }) => {
|
|
478
|
-
const workspace = await prisma.workspace.findUnique({
|
|
479
|
-
where: { id: data.workspaceId },
|
|
480
|
-
})
|
|
532
|
+
// app/middleware/validation.ts
|
|
533
|
+
import { createMiddleware } from '@tanstack/react-start'
|
|
534
|
+
import { z } from 'zod'
|
|
481
535
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
536
|
+
export const createValidationMiddleware = <T extends z.ZodType>(
|
|
537
|
+
schema: T
|
|
538
|
+
) => {
|
|
539
|
+
return createMiddleware().server(async ({ next, data }) => {
|
|
540
|
+
const validated = schema.parse(data)
|
|
485
541
|
|
|
486
|
-
return next({
|
|
542
|
+
return next({
|
|
543
|
+
context: {
|
|
544
|
+
validated: validated as z.infer<T>,
|
|
545
|
+
},
|
|
546
|
+
})
|
|
487
547
|
})
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Usage
|
|
551
|
+
const userSchema = z.object({ email: z.email() })
|
|
552
|
+
const validateUser = createValidationMiddleware(userSchema)
|
|
488
553
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
.middleware([authMiddleware, workspaceMiddleware])
|
|
554
|
+
export const createUser = createServerFn({ method: 'POST' })
|
|
555
|
+
.middleware([validateUser])
|
|
492
556
|
.handler(async ({ context }) => {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
workspace: context.workspace,
|
|
496
|
-
}
|
|
557
|
+
const { validated } = context // Type-safe
|
|
558
|
+
return db.user.create({ data: validated })
|
|
497
559
|
})
|
|
498
560
|
```
|
|
499
561
|
|
|
500
|
-
|
|
562
|
+
## Middleware Chaining
|
|
501
563
|
|
|
502
564
|
```typescript
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
565
|
+
// app/middleware/admin.ts
|
|
566
|
+
import { createMiddleware } from '@tanstack/react-start'
|
|
567
|
+
|
|
568
|
+
export const adminMiddleware = createMiddleware().server(
|
|
569
|
+
async ({ next, context }) => {
|
|
570
|
+
// authMiddleware에서 context.user 주입됨
|
|
571
|
+
if (context.user.role !== 'admin') {
|
|
572
|
+
throw new Error('Forbidden: Admin access required')
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return next({
|
|
576
|
+
context: {
|
|
577
|
+
...context,
|
|
578
|
+
isAdmin: true,
|
|
517
579
|
},
|
|
518
580
|
})
|
|
581
|
+
}
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
// Usage: authMiddleware → adminMiddleware 순서로 실행
|
|
585
|
+
export const deleteUser = createServerFn({ method: 'DELETE' })
|
|
586
|
+
.inputValidator(deleteUserSchema)
|
|
587
|
+
.middleware([authMiddleware, adminMiddleware])
|
|
588
|
+
.handler(async ({ data, context }) => {
|
|
589
|
+
console.log(context.isAdmin) // true
|
|
590
|
+
await db.user.delete({ where: { id: data.id } })
|
|
591
|
+
return { success: true }
|
|
519
592
|
})
|
|
520
593
|
```
|
|
521
594
|
|
|
522
|
-
|
|
595
|
+
## Global Middleware
|
|
523
596
|
|
|
524
597
|
```typescript
|
|
525
|
-
//
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
598
|
+
// app/router.tsx
|
|
599
|
+
import { createRouter } from '@tanstack/react-router'
|
|
600
|
+
import { loggingMiddleware } from '~/middleware/logging'
|
|
601
|
+
|
|
602
|
+
export const router = createRouter({
|
|
603
|
+
routeTree,
|
|
604
|
+
defaultPreload: 'intent',
|
|
605
|
+
context: {
|
|
606
|
+
// Global context
|
|
607
|
+
},
|
|
608
|
+
middleware: [loggingMiddleware], // 모든 routes에 적용
|
|
609
|
+
})
|
|
530
610
|
```
|
|
531
611
|
|
|
532
|
-
|
|
612
|
+
## Route-level Middleware
|
|
533
613
|
|
|
534
614
|
```typescript
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
615
|
+
// app/routes/_authenticated.tsx
|
|
616
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
617
|
+
import { authMiddleware } from '~/middleware/auth'
|
|
618
|
+
|
|
619
|
+
export const Route = createFileRoute('/_authenticated')({
|
|
620
|
+
beforeLoad: async ({ context }) => {
|
|
621
|
+
// authMiddleware 실행
|
|
622
|
+
const session = await getSession()
|
|
623
|
+
if (!session) {
|
|
624
|
+
throw redirect({ to: '/login' })
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
user: session.user,
|
|
628
|
+
}
|
|
542
629
|
},
|
|
543
630
|
})
|
|
544
631
|
```
|
|
545
632
|
|
|
633
|
+
## Logging Middleware
|
|
634
|
+
|
|
635
|
+
```typescript
|
|
636
|
+
// app/middleware/logging.ts
|
|
637
|
+
import { createMiddleware } from '@tanstack/react-start'
|
|
638
|
+
|
|
639
|
+
export const loggingMiddleware = createMiddleware().server(
|
|
640
|
+
async ({ next, method, url }) => {
|
|
641
|
+
const start = Date.now()
|
|
642
|
+
|
|
643
|
+
console.log(`[${method}] ${url} - Start`)
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
const result = await next()
|
|
647
|
+
const duration = Date.now() - start
|
|
648
|
+
console.log(`[${method}] ${url} - Success (${duration}ms)`)
|
|
649
|
+
return result
|
|
650
|
+
} catch (error) {
|
|
651
|
+
const duration = Date.now() - start
|
|
652
|
+
console.error(`[${method}] ${url} - Error (${duration}ms)`, error)
|
|
653
|
+
throw error
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
)
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
## Rate Limiting Middleware
|
|
660
|
+
|
|
661
|
+
```typescript
|
|
662
|
+
// app/middleware/rate-limit.ts
|
|
663
|
+
import { createMiddleware } from '@tanstack/react-start'
|
|
664
|
+
import { Ratelimit } from '@upstash/ratelimit'
|
|
665
|
+
import { Redis } from '@upstash/redis'
|
|
666
|
+
|
|
667
|
+
const ratelimit = new Ratelimit({
|
|
668
|
+
redis: Redis.fromEnv(),
|
|
669
|
+
limiter: Ratelimit.slidingWindow(10, '10 s'),
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
export const rateLimitMiddleware = createMiddleware().server(
|
|
673
|
+
async ({ next, context }) => {
|
|
674
|
+
const identifier = context.user?.id || 'anonymous'
|
|
675
|
+
const { success } = await ratelimit.limit(identifier)
|
|
676
|
+
|
|
677
|
+
if (!success) {
|
|
678
|
+
throw new Error('Rate limit exceeded')
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return next()
|
|
682
|
+
}
|
|
683
|
+
)
|
|
684
|
+
```
|
|
685
|
+
|
|
546
686
|
</middleware>
|
|
547
687
|
|
|
548
688
|
---
|
|
549
689
|
|
|
550
690
|
<routing>
|
|
551
691
|
|
|
552
|
-
## Routing
|
|
692
|
+
## File-based Routing
|
|
553
693
|
|
|
554
|
-
|
|
694
|
+
TanStack Start는 `app/routes/` 디렉토리 구조를 URL로 변환합니다.
|
|
555
695
|
|
|
556
696
|
### 파일 구조 → URL 매핑
|
|
557
697
|
|
|
558
|
-
| 파일 경로 | URL |
|
|
559
|
-
|
|
560
|
-
| `routes/index.tsx` | `/` |
|
|
561
|
-
| `routes/about.tsx` | `/about` |
|
|
562
|
-
| `routes/
|
|
563
|
-
| `routes/
|
|
564
|
-
| `routes
|
|
565
|
-
| `routes/
|
|
698
|
+
| 파일 경로 | URL |
|
|
699
|
+
|-----------|-----|
|
|
700
|
+
| `routes/index.tsx` | `/` |
|
|
701
|
+
| `routes/about.tsx` | `/about` |
|
|
702
|
+
| `routes/posts.index.tsx` | `/posts` |
|
|
703
|
+
| `routes/posts.$postId.tsx` | `/posts/:postId` |
|
|
704
|
+
| `routes/posts.$postId.edit.tsx` | `/posts/:postId/edit` |
|
|
705
|
+
| `routes/blog._layout.tsx` | Layout (no URL) |
|
|
706
|
+
| `routes/blog._layout.posts.tsx` | `/blog/posts` |
|
|
707
|
+
| `routes/admin._.tsx` | `/admin/*` (catch-all) |
|
|
708
|
+
|
|
709
|
+
## Basic Routes
|
|
710
|
+
|
|
711
|
+
### Index Route
|
|
712
|
+
|
|
713
|
+
```typescript
|
|
714
|
+
// app/routes/index.tsx
|
|
715
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
716
|
+
|
|
717
|
+
export const Route = createFileRoute('/')({
|
|
718
|
+
component: Home,
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
function Home() {
|
|
722
|
+
return (
|
|
723
|
+
<div>
|
|
724
|
+
<h1>Welcome to TanStack Start</h1>
|
|
725
|
+
</div>
|
|
726
|
+
)
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
### Static Route
|
|
566
731
|
|
|
567
|
-
|
|
732
|
+
```typescript
|
|
733
|
+
// app/routes/about.tsx
|
|
734
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
568
735
|
|
|
569
|
-
```tsx
|
|
570
|
-
// routes/about.tsx
|
|
571
736
|
export const Route = createFileRoute('/about')({
|
|
572
|
-
component:
|
|
737
|
+
component: About,
|
|
573
738
|
})
|
|
574
739
|
|
|
575
|
-
|
|
740
|
+
function About() {
|
|
576
741
|
return <div>About Page</div>
|
|
577
742
|
}
|
|
578
743
|
```
|
|
579
744
|
|
|
580
|
-
|
|
745
|
+
## Loader Pattern
|
|
746
|
+
|
|
747
|
+
```typescript
|
|
748
|
+
// app/routes/posts.index.tsx
|
|
749
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
750
|
+
import { getPosts } from '~/functions/posts'
|
|
581
751
|
|
|
582
|
-
|
|
583
|
-
// routes/posts/index.tsx
|
|
584
|
-
export const Route = createFileRoute('/posts')({
|
|
585
|
-
component: PostsPage,
|
|
752
|
+
export const Route = createFileRoute('/posts/')({
|
|
586
753
|
loader: async () => {
|
|
587
754
|
const posts = await getPosts()
|
|
588
755
|
return { posts }
|
|
589
756
|
},
|
|
757
|
+
component: PostsList,
|
|
590
758
|
})
|
|
591
759
|
|
|
592
|
-
|
|
760
|
+
function PostsList() {
|
|
593
761
|
const { posts } = Route.useLoaderData()
|
|
594
762
|
|
|
595
763
|
return (
|
|
@@ -602,767 +770,1243 @@ const PostsPage = (): JSX.Element => {
|
|
|
602
770
|
}
|
|
603
771
|
```
|
|
604
772
|
|
|
605
|
-
|
|
773
|
+
## Dynamic Routes
|
|
774
|
+
|
|
775
|
+
### Path Parameters
|
|
606
776
|
|
|
607
|
-
```
|
|
608
|
-
// routes/
|
|
609
|
-
|
|
777
|
+
```typescript
|
|
778
|
+
// app/routes/posts.$postId.tsx
|
|
779
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
780
|
+
import { getPost } from '~/functions/posts'
|
|
781
|
+
|
|
782
|
+
export const Route = createFileRoute('/posts/$postId')({
|
|
610
783
|
loader: async ({ params }) => {
|
|
611
|
-
const
|
|
612
|
-
return {
|
|
784
|
+
const post = await getPost({ data: { id: params.postId } })
|
|
785
|
+
return { post }
|
|
613
786
|
},
|
|
614
|
-
component:
|
|
787
|
+
component: PostDetail,
|
|
615
788
|
})
|
|
616
789
|
|
|
617
|
-
|
|
618
|
-
const {
|
|
790
|
+
function PostDetail() {
|
|
791
|
+
const { postId } = Route.useParams()
|
|
792
|
+
const { post } = Route.useLoaderData()
|
|
619
793
|
|
|
620
794
|
return (
|
|
621
|
-
<
|
|
622
|
-
<h1>{
|
|
623
|
-
<p>{
|
|
624
|
-
</
|
|
795
|
+
<article>
|
|
796
|
+
<h1>{post.title}</h1>
|
|
797
|
+
<p>{post.content}</p>
|
|
798
|
+
</article>
|
|
625
799
|
)
|
|
626
800
|
}
|
|
627
801
|
```
|
|
628
802
|
|
|
629
|
-
###
|
|
630
|
-
|
|
631
|
-
| 옵션 | 동작 | 사용 시점 |
|
|
632
|
-
|------|------|----------|
|
|
633
|
-
| `ssr: true` | 전체 SSR (기본값) | 일반 페이지 |
|
|
634
|
-
| `ssr: false` | 클라이언트만 렌더링 | 인증 필요 페이지 |
|
|
635
|
-
| `ssr: 'data-only'` | 데이터만 서버에서 로드 | 데이터 + 클라이언트 렌더링 |
|
|
636
|
-
|
|
637
|
-
```tsx
|
|
638
|
-
// ssr 옵션 예시
|
|
639
|
-
export const Route = createFileRoute('/dashboard')({
|
|
640
|
-
ssr: false, // 클라이언트에서만 렌더링
|
|
641
|
-
component: DashboardPage,
|
|
642
|
-
})
|
|
643
|
-
```
|
|
803
|
+
### Search Parameters
|
|
644
804
|
|
|
645
|
-
|
|
805
|
+
```typescript
|
|
806
|
+
// app/routes/search.tsx
|
|
807
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
808
|
+
import { z } from 'zod'
|
|
646
809
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const user = await getCurrentUser()
|
|
652
|
-
if (!user) {
|
|
653
|
-
throw redirect({ to: '/login' })
|
|
654
|
-
}
|
|
655
|
-
return { user }
|
|
656
|
-
},
|
|
657
|
-
component: DashboardPage,
|
|
810
|
+
const searchSchema = z.object({
|
|
811
|
+
q: z.string().optional(),
|
|
812
|
+
page: z.number().int().positive().default(1),
|
|
813
|
+
sort: z.enum(['asc', 'desc']).default('asc'),
|
|
658
814
|
})
|
|
659
815
|
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
### Server Routes (API 엔드포인트)
|
|
667
|
-
|
|
668
|
-
```tsx
|
|
669
|
-
// routes/api/hello.tsx
|
|
670
|
-
export const Route = createFileRoute('/api/hello')({
|
|
671
|
-
server: {
|
|
672
|
-
handlers: {
|
|
673
|
-
GET: async () => {
|
|
674
|
-
return new Response('Hello World')
|
|
675
|
-
},
|
|
676
|
-
POST: async ({ request }) => {
|
|
677
|
-
const body = await request.json()
|
|
678
|
-
return Response.json({ name: body.name })
|
|
679
|
-
},
|
|
680
|
-
},
|
|
816
|
+
export const Route = createFileRoute('/search')({
|
|
817
|
+
validateSearch: searchSchema,
|
|
818
|
+
loaderDeps: ({ search }) => ({ search }),
|
|
819
|
+
loader: async ({ deps }) => {
|
|
820
|
+
const results = await searchPosts(deps.search)
|
|
821
|
+
return { results }
|
|
681
822
|
},
|
|
823
|
+
component: SearchPage,
|
|
682
824
|
})
|
|
683
|
-
```
|
|
684
825
|
|
|
685
|
-
|
|
826
|
+
function SearchPage() {
|
|
827
|
+
const { q, page, sort } = Route.useSearch()
|
|
828
|
+
const { results } = Route.useLoaderData()
|
|
829
|
+
const navigate = Route.useNavigate()
|
|
686
830
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
}
|
|
831
|
+
const updateSearch = (newQ: string) => {
|
|
832
|
+
navigate({
|
|
833
|
+
search: (prev) => ({ ...prev, q: newQ, page: 1 }),
|
|
834
|
+
})
|
|
835
|
+
}
|
|
692
836
|
|
|
693
|
-
const NotFoundPage = (): JSX.Element => {
|
|
694
837
|
return (
|
|
695
838
|
<div>
|
|
696
|
-
<
|
|
697
|
-
|
|
839
|
+
<input
|
|
840
|
+
value={q || ''}
|
|
841
|
+
onChange={(e) => updateSearch(e.target.value)}
|
|
842
|
+
/>
|
|
843
|
+
<ul>
|
|
844
|
+
{results.map((result) => (
|
|
845
|
+
<li key={result.id}>{result.title}</li>
|
|
846
|
+
))}
|
|
847
|
+
</ul>
|
|
698
848
|
</div>
|
|
699
849
|
)
|
|
700
850
|
}
|
|
701
851
|
```
|
|
702
852
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
---
|
|
706
|
-
|
|
707
|
-
<auth_patterns>
|
|
708
|
-
|
|
709
|
-
## 인증 패턴
|
|
853
|
+
## SSR Options
|
|
710
854
|
|
|
711
|
-
|
|
855
|
+
```typescript
|
|
856
|
+
// app/routes/posts.$postId.tsx
|
|
857
|
+
export const Route = createFileRoute('/posts/$postId')({
|
|
858
|
+
loader: async ({ params }) => {
|
|
859
|
+
const post = await getPost({ data: { id: params.postId } })
|
|
860
|
+
return { post }
|
|
861
|
+
},
|
|
862
|
+
staleTime: 1000 * 60 * 5, // 5분간 캐시
|
|
863
|
+
gcTime: 1000 * 60 * 10, // 10분 후 가비지 컬렉션
|
|
864
|
+
shouldReload: false, // 재방문 시 reload 안함
|
|
865
|
+
component: PostDetail,
|
|
866
|
+
})
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
**SWR Caching 옵션**:
|
|
870
|
+
| 옵션 | 설명 | 기본값 |
|
|
871
|
+
|------|------|--------|
|
|
872
|
+
| `staleTime` | 데이터가 fresh한 시간 (ms) | 0 |
|
|
873
|
+
| `gcTime` | 메모리에서 제거되기까지 시간 (ms) | 30분 |
|
|
874
|
+
| `shouldReload` | 재방문 시 reload 여부 | true |
|
|
712
875
|
|
|
713
|
-
|
|
876
|
+
## beforeLoad (Auth Guard)
|
|
714
877
|
|
|
715
878
|
```typescript
|
|
716
|
-
//
|
|
717
|
-
import {
|
|
718
|
-
import {
|
|
719
|
-
import { prisma } from '@/database/prisma'
|
|
879
|
+
// app/routes/_authenticated.tsx
|
|
880
|
+
import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
881
|
+
import { getSession } from '~/auth-helpers'
|
|
720
882
|
|
|
721
|
-
export const
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
883
|
+
export const Route = createFileRoute('/_authenticated')({
|
|
884
|
+
beforeLoad: async ({ context, location }) => {
|
|
885
|
+
const session = await getSession()
|
|
886
|
+
|
|
887
|
+
if (!session) {
|
|
888
|
+
throw redirect({
|
|
889
|
+
to: '/login',
|
|
890
|
+
search: {
|
|
891
|
+
redirect: location.href,
|
|
892
|
+
},
|
|
893
|
+
})
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return {
|
|
897
|
+
user: session.user,
|
|
898
|
+
}
|
|
727
899
|
},
|
|
728
900
|
})
|
|
729
901
|
|
|
730
|
-
|
|
902
|
+
// app/routes/_authenticated.profile.tsx
|
|
903
|
+
export const Route = createFileRoute('/_authenticated/profile')({
|
|
904
|
+
component: Profile,
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
function Profile() {
|
|
908
|
+
const { user } = Route.useRouteContext()
|
|
909
|
+
return <div>Welcome, {user.name}</div>
|
|
910
|
+
}
|
|
731
911
|
```
|
|
732
912
|
|
|
733
|
-
|
|
913
|
+
## Nested Layouts
|
|
734
914
|
|
|
735
915
|
```typescript
|
|
736
|
-
//
|
|
916
|
+
// app/routes/_layout.tsx
|
|
917
|
+
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
|
737
918
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
email: z.email(),
|
|
741
|
-
password: z.string().min(8),
|
|
919
|
+
export const Route = createFileRoute('/_layout')({
|
|
920
|
+
component: Layout,
|
|
742
921
|
})
|
|
743
922
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
923
|
+
function Layout() {
|
|
924
|
+
return (
|
|
925
|
+
<div>
|
|
926
|
+
<nav>Navigation</nav>
|
|
927
|
+
<main>
|
|
928
|
+
<Outlet /> {/* Child routes render here */}
|
|
929
|
+
</main>
|
|
930
|
+
<footer>Footer</footer>
|
|
931
|
+
</div>
|
|
932
|
+
)
|
|
933
|
+
}
|
|
752
934
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
935
|
+
// app/routes/_layout.page1.tsx
|
|
936
|
+
export const Route = createFileRoute('/_layout/page1')({
|
|
937
|
+
component: () => <div>Page 1</div>,
|
|
938
|
+
})
|
|
756
939
|
|
|
757
|
-
|
|
758
|
-
|
|
940
|
+
// app/routes/_layout.page2.tsx
|
|
941
|
+
export const Route = createFileRoute('/_layout/page2')({
|
|
942
|
+
component: () => <div>Page 2</div>,
|
|
943
|
+
})
|
|
944
|
+
```
|
|
759
945
|
|
|
760
|
-
|
|
761
|
-
export const logout = createServerFn({ method: 'POST' })
|
|
762
|
-
.handler(async ({ request }) => {
|
|
763
|
-
await auth.api.signOut({ headers: request.headers })
|
|
764
|
-
throw redirect({ to: '/' })
|
|
765
|
-
})
|
|
946
|
+
## Server Routes (New API)
|
|
766
947
|
|
|
767
|
-
|
|
768
|
-
export const getCurrentUser = createServerFn({ method: 'GET' })
|
|
769
|
-
.handler(async ({ request }) => {
|
|
770
|
-
const session = await auth.api.getSession({
|
|
771
|
-
headers: request.headers,
|
|
772
|
-
})
|
|
773
|
-
return session?.user ?? null
|
|
774
|
-
})
|
|
948
|
+
**중요**: `createAPIFileRoute()` → `createServerFileRoute()` + `.methods()` 로 변경됨
|
|
775
949
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
950
|
+
### RESTful API Endpoint
|
|
951
|
+
|
|
952
|
+
```typescript
|
|
953
|
+
// app/routes/api/users.$userId.tsx
|
|
954
|
+
import { createServerFileRoute } from '@tanstack/react-start'
|
|
955
|
+
import { z } from 'zod'
|
|
956
|
+
|
|
957
|
+
const getUserSchema = z.object({
|
|
958
|
+
userId: z.string().uuid(),
|
|
781
959
|
})
|
|
782
960
|
|
|
783
|
-
|
|
784
|
-
.
|
|
785
|
-
.
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
password: data.password,
|
|
789
|
-
name: data.name,
|
|
790
|
-
headers: request.headers,
|
|
791
|
-
})
|
|
961
|
+
const updateUserSchema = z.object({
|
|
962
|
+
userId: z.string().uuid(),
|
|
963
|
+
name: z.string().optional(),
|
|
964
|
+
email: z.email().optional(),
|
|
965
|
+
})
|
|
792
966
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
}
|
|
967
|
+
export const Route = createServerFileRoute('/api/users/$userId')
|
|
968
|
+
.methods({
|
|
969
|
+
GET: async ({ request, params }) => {
|
|
970
|
+
const { userId } = getUserSchema.parse(params)
|
|
971
|
+
const user = await db.user.findUnique({ where: { id: userId } })
|
|
972
|
+
|
|
973
|
+
if (!user) {
|
|
974
|
+
return new Response('Not Found', { status: 404 })
|
|
975
|
+
}
|
|
796
976
|
|
|
797
|
-
|
|
977
|
+
return Response.json(user)
|
|
978
|
+
},
|
|
979
|
+
|
|
980
|
+
PUT: async ({ request, params }) => {
|
|
981
|
+
const body = await request.json()
|
|
982
|
+
const { userId, ...updates } = updateUserSchema.parse({
|
|
983
|
+
...params,
|
|
984
|
+
...body,
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
const user = await db.user.update({
|
|
988
|
+
where: { id: userId },
|
|
989
|
+
data: updates,
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
return Response.json(user)
|
|
993
|
+
},
|
|
994
|
+
|
|
995
|
+
DELETE: async ({ request, params }) => {
|
|
996
|
+
const { userId } = getUserSchema.parse(params)
|
|
997
|
+
await db.user.delete({ where: { id: userId } })
|
|
998
|
+
|
|
999
|
+
return Response.json({ success: true })
|
|
1000
|
+
},
|
|
798
1001
|
})
|
|
799
1002
|
```
|
|
800
1003
|
|
|
801
|
-
###
|
|
1004
|
+
### File Upload Endpoint
|
|
802
1005
|
|
|
803
1006
|
```typescript
|
|
804
|
-
//
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
headers: request.headers,
|
|
809
|
-
})
|
|
1007
|
+
// app/routes/api/upload.tsx
|
|
1008
|
+
import { createServerFileRoute } from '@tanstack/react-start'
|
|
1009
|
+
import { writeFile } from 'fs/promises'
|
|
1010
|
+
import { join } from 'path'
|
|
810
1011
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
}
|
|
1012
|
+
export const Route = createServerFileRoute('/api/upload')
|
|
1013
|
+
.methods({
|
|
1014
|
+
POST: async ({ request }) => {
|
|
1015
|
+
const formData = await request.formData()
|
|
1016
|
+
const file = formData.get('file') as File
|
|
814
1017
|
|
|
815
|
-
|
|
1018
|
+
if (!file) {
|
|
1019
|
+
return new Response('No file provided', { status: 400 })
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const bytes = await file.arrayBuffer()
|
|
1023
|
+
const buffer = Buffer.from(bytes)
|
|
1024
|
+
|
|
1025
|
+
const path = join(process.cwd(), 'uploads', file.name)
|
|
1026
|
+
await writeFile(path, buffer)
|
|
1027
|
+
|
|
1028
|
+
return Response.json({ success: true, path })
|
|
1029
|
+
},
|
|
816
1030
|
})
|
|
817
1031
|
```
|
|
818
1032
|
|
|
819
|
-
|
|
1033
|
+
## Catch-all Routes
|
|
820
1034
|
|
|
821
1035
|
```typescript
|
|
822
|
-
//
|
|
823
|
-
|
|
824
|
-
.middleware([authMiddleware])
|
|
825
|
-
.handler(async ({ context }) => {
|
|
826
|
-
return prisma.post.findMany({
|
|
827
|
-
where: { authorId: context.user.id },
|
|
828
|
-
})
|
|
829
|
-
})
|
|
1036
|
+
// app/routes/docs._.tsx
|
|
1037
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
830
1038
|
|
|
831
|
-
export const
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
authorId: context.user.id,
|
|
842
|
-
},
|
|
843
|
-
})
|
|
844
|
-
})
|
|
1039
|
+
export const Route = createFileRoute('/docs/$')({
|
|
1040
|
+
component: DocsPage,
|
|
1041
|
+
})
|
|
1042
|
+
|
|
1043
|
+
function DocsPage() {
|
|
1044
|
+
const params = Route.useParams()
|
|
1045
|
+
const splat = params['_splat'] // Captures everything after /docs/
|
|
1046
|
+
|
|
1047
|
+
return <div>Docs: {splat}</div>
|
|
1048
|
+
}
|
|
845
1049
|
```
|
|
846
1050
|
|
|
847
|
-
|
|
1051
|
+
## Link Navigation
|
|
848
1052
|
|
|
849
|
-
```
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1053
|
+
```typescript
|
|
1054
|
+
import { Link } from '@tanstack/react-router'
|
|
1055
|
+
|
|
1056
|
+
function Navigation() {
|
|
1057
|
+
return (
|
|
1058
|
+
<nav>
|
|
1059
|
+
<Link to="/">Home</Link>
|
|
1060
|
+
<Link to="/about">About</Link>
|
|
1061
|
+
<Link
|
|
1062
|
+
to="/posts/$postId"
|
|
1063
|
+
params={{ postId: '123' }}
|
|
1064
|
+
>
|
|
1065
|
+
Post 123
|
|
1066
|
+
</Link>
|
|
1067
|
+
<Link
|
|
1068
|
+
to="/search"
|
|
1069
|
+
search={{ q: 'tanstack', page: 1 }}
|
|
1070
|
+
>
|
|
1071
|
+
Search
|
|
1072
|
+
</Link>
|
|
1073
|
+
</nav>
|
|
1074
|
+
)
|
|
1075
|
+
}
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
## Preload Strategy
|
|
1079
|
+
|
|
1080
|
+
```typescript
|
|
1081
|
+
// app/routes/posts.$postId.tsx
|
|
1082
|
+
export const Route = createFileRoute('/posts/$postId')({
|
|
1083
|
+
loader: async ({ params }) => {
|
|
1084
|
+
const post = await getPost({ data: { id: params.postId } })
|
|
1085
|
+
return { post }
|
|
858
1086
|
},
|
|
859
|
-
component:
|
|
1087
|
+
component: PostDetail,
|
|
860
1088
|
})
|
|
861
1089
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1090
|
+
// Usage
|
|
1091
|
+
function PostsList() {
|
|
865
1092
|
return (
|
|
866
1093
|
<div>
|
|
867
|
-
|
|
1094
|
+
{posts.map((post) => (
|
|
1095
|
+
<Link
|
|
1096
|
+
key={post.id}
|
|
1097
|
+
to="/posts/$postId"
|
|
1098
|
+
params={{ postId: post.id }}
|
|
1099
|
+
preload="intent" // Preload on hover/focus
|
|
1100
|
+
>
|
|
1101
|
+
{post.title}
|
|
1102
|
+
</Link>
|
|
1103
|
+
))}
|
|
868
1104
|
</div>
|
|
869
1105
|
)
|
|
870
1106
|
}
|
|
871
1107
|
```
|
|
872
1108
|
|
|
873
|
-
|
|
1109
|
+
**Preload 옵션**:
|
|
1110
|
+
| 값 | 동작 |
|
|
1111
|
+
|----|------|
|
|
1112
|
+
| `false` | Preload 안함 |
|
|
1113
|
+
| `intent` | Hover/focus 시 preload |
|
|
1114
|
+
| `render` | Link 렌더링 시 preload |
|
|
1115
|
+
| `viewport` | Viewport 진입 시 preload |
|
|
874
1116
|
|
|
875
|
-
|
|
876
|
-
// routes/login.tsx
|
|
877
|
-
const LoginPage = (): JSX.Element => {
|
|
878
|
-
const mutation = useMutation({
|
|
879
|
-
mutationFn: login,
|
|
880
|
-
})
|
|
1117
|
+
## Pending Component
|
|
881
1118
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1119
|
+
```typescript
|
|
1120
|
+
// app/routes/posts.$postId.tsx
|
|
1121
|
+
export const Route = createFileRoute('/posts/$postId')({
|
|
1122
|
+
loader: async ({ params }) => {
|
|
1123
|
+
await delay(2000) // Simulate slow loading
|
|
1124
|
+
const post = await getPost({ data: { id: params.postId } })
|
|
1125
|
+
return { post }
|
|
1126
|
+
},
|
|
1127
|
+
pendingComponent: () => <div>Loading post...</div>,
|
|
1128
|
+
component: PostDetail,
|
|
1129
|
+
})
|
|
1130
|
+
```
|
|
885
1131
|
|
|
886
|
-
|
|
887
|
-
email: formData.get('email') as string,
|
|
888
|
-
password: formData.get('password') as string,
|
|
889
|
-
})
|
|
890
|
-
}
|
|
1132
|
+
## Error Boundaries
|
|
891
1133
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
}
|
|
1134
|
+
```typescript
|
|
1135
|
+
// app/routes/posts.$postId.tsx
|
|
1136
|
+
export const Route = createFileRoute('/posts/$postId')({
|
|
1137
|
+
loader: async ({ params }) => {
|
|
1138
|
+
const post = await getPost({ data: { id: params.postId } })
|
|
1139
|
+
if (!post) {
|
|
1140
|
+
throw new Error('Post not found')
|
|
1141
|
+
}
|
|
1142
|
+
return { post }
|
|
1143
|
+
},
|
|
1144
|
+
errorComponent: ({ error }) => (
|
|
1145
|
+
<div>
|
|
1146
|
+
<h1>Error</h1>
|
|
1147
|
+
<p>{error.message}</p>
|
|
1148
|
+
</div>
|
|
1149
|
+
),
|
|
1150
|
+
component: PostDetail,
|
|
1151
|
+
})
|
|
903
1152
|
```
|
|
904
1153
|
|
|
905
|
-
|
|
1154
|
+
## Router Context
|
|
906
1155
|
|
|
907
|
-
|
|
1156
|
+
```typescript
|
|
1157
|
+
// app/router.tsx
|
|
1158
|
+
import { createRouter } from '@tanstack/react-router'
|
|
1159
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
908
1160
|
|
|
909
|
-
|
|
1161
|
+
const queryClient = new QueryClient()
|
|
910
1162
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
│ ├── index.tsx
|
|
932
|
-
│ ├── -components/
|
|
933
|
-
│ ├── -hooks/
|
|
934
|
-
│ └── -functions/
|
|
935
|
-
│
|
|
936
|
-
├── functions/ # 공통 Server Functions
|
|
937
|
-
│ ├── auth.ts
|
|
938
|
-
│ ├── posts.ts
|
|
939
|
-
│ └── users.ts
|
|
940
|
-
│
|
|
941
|
-
├── middleware/ # 공통 Middleware
|
|
942
|
-
│ ├── auth.ts
|
|
943
|
-
│ └── logging.ts
|
|
944
|
-
│
|
|
945
|
-
├── components/ # 공통 컴포넌트
|
|
946
|
-
│ └── ui/
|
|
947
|
-
│ ├── button.tsx
|
|
948
|
-
│ └── input.tsx
|
|
949
|
-
│
|
|
950
|
-
├── lib/ # 유틸리티
|
|
951
|
-
│ ├── env.ts
|
|
952
|
-
│ └── utils.ts
|
|
953
|
-
│
|
|
954
|
-
└── database/ # Prisma
|
|
955
|
-
└── prisma.ts
|
|
1163
|
+
export const router = createRouter({
|
|
1164
|
+
routeTree,
|
|
1165
|
+
context: {
|
|
1166
|
+
queryClient,
|
|
1167
|
+
},
|
|
1168
|
+
defaultPreload: 'intent',
|
|
1169
|
+
defaultStaleTime: 1000 * 60 * 5, // 5분
|
|
1170
|
+
})
|
|
1171
|
+
|
|
1172
|
+
// app/routes/posts.$postId.tsx
|
|
1173
|
+
export const Route = createFileRoute('/posts/$postId')({
|
|
1174
|
+
loader: async ({ params, context }) => {
|
|
1175
|
+
const post = await context.queryClient.ensureQueryData({
|
|
1176
|
+
queryKey: ['post', params.postId],
|
|
1177
|
+
queryFn: () => getPost({ data: { id: params.postId } }),
|
|
1178
|
+
})
|
|
1179
|
+
return { post }
|
|
1180
|
+
},
|
|
1181
|
+
component: PostDetail,
|
|
1182
|
+
})
|
|
956
1183
|
```
|
|
957
1184
|
|
|
958
|
-
|
|
1185
|
+
## Streaming SSR
|
|
959
1186
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
| `routes/[path]/-functions/` | 해당 페이지 전용 Server Function | `user-mutations.ts` |
|
|
965
|
-
| `@/functions/` | 여러 페이지에서 재사용 | `auth.ts`, `posts.ts` |
|
|
966
|
-
| `@/components/` | 공통 UI 컴포넌트 | `button.tsx`, `input.tsx` |
|
|
967
|
-
| `@/middleware/` | 공통 미들웨어 | `auth.ts`, `logging.ts` |
|
|
1187
|
+
```typescript
|
|
1188
|
+
// app/routes/dashboard.tsx
|
|
1189
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
1190
|
+
import { Suspense } from 'react'
|
|
968
1191
|
|
|
969
|
-
|
|
1192
|
+
export const Route = createFileRoute('/dashboard')({
|
|
1193
|
+
component: Dashboard,
|
|
1194
|
+
})
|
|
970
1195
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1196
|
+
function Dashboard() {
|
|
1197
|
+
return (
|
|
1198
|
+
<div>
|
|
1199
|
+
<h1>Dashboard</h1>
|
|
1200
|
+
<Suspense fallback={<div>Loading analytics...</div>}>
|
|
1201
|
+
<AnalyticsPanel />
|
|
1202
|
+
</Suspense>
|
|
1203
|
+
<Suspense fallback={<div>Loading activity...</div>}>
|
|
1204
|
+
<ActivityFeed />
|
|
1205
|
+
</Suspense>
|
|
1206
|
+
</div>
|
|
1207
|
+
)
|
|
1208
|
+
}
|
|
974
1209
|
|
|
975
|
-
|
|
976
|
-
|
|
1210
|
+
function AnalyticsPanel() {
|
|
1211
|
+
const { data } = useSuspenseQuery({
|
|
1212
|
+
queryKey: ['analytics'],
|
|
1213
|
+
queryFn: () => getAnalytics(),
|
|
1214
|
+
})
|
|
1215
|
+
return <div>{/* Render analytics */}</div>
|
|
1216
|
+
}
|
|
1217
|
+
```
|
|
977
1218
|
|
|
978
|
-
|
|
979
|
-
- 해당 라우트에서만 사용하는 Server Function
|
|
1219
|
+
</routing>
|
|
980
1220
|
|
|
981
|
-
|
|
982
|
-
- Server Function만 export
|
|
1221
|
+
---
|
|
983
1222
|
|
|
984
|
-
|
|
1223
|
+
<auth_patterns>
|
|
985
1224
|
|
|
986
|
-
|
|
1225
|
+
## Better Auth 통합
|
|
987
1226
|
|
|
988
|
-
|
|
1227
|
+
Better Auth는 TanStack Start와 완벽하게 호환되는 인증 라이브러리입니다.
|
|
989
1228
|
|
|
990
|
-
|
|
1229
|
+
### 설치 및 설정
|
|
991
1230
|
|
|
992
|
-
|
|
1231
|
+
```bash
|
|
1232
|
+
npm install better-auth
|
|
1233
|
+
```
|
|
993
1234
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1235
|
+
```typescript
|
|
1236
|
+
// app/auth.ts
|
|
1237
|
+
import { betterAuth } from 'better-auth'
|
|
1238
|
+
import { prismaAdapter } from 'better-auth/adapters/prisma'
|
|
1239
|
+
import { prisma } from '~/db'
|
|
1240
|
+
|
|
1241
|
+
export const auth = betterAuth({
|
|
1242
|
+
database: prismaAdapter(prisma, {
|
|
1243
|
+
provider: 'postgresql',
|
|
1244
|
+
}),
|
|
1245
|
+
emailAndPassword: {
|
|
1246
|
+
enabled: true,
|
|
1247
|
+
},
|
|
1248
|
+
socialProviders: {
|
|
1249
|
+
github: {
|
|
1250
|
+
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
1251
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
1252
|
+
},
|
|
1253
|
+
},
|
|
1254
|
+
})
|
|
1255
|
+
```
|
|
1003
1256
|
|
|
1004
|
-
###
|
|
1257
|
+
### Auth Server Functions
|
|
1005
1258
|
|
|
1006
1259
|
```typescript
|
|
1007
|
-
//
|
|
1008
|
-
|
|
1260
|
+
// app/functions/auth.ts
|
|
1261
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
1262
|
+
import { z } from 'zod'
|
|
1263
|
+
import { auth } from '~/auth'
|
|
1264
|
+
|
|
1265
|
+
const signUpSchema = z.object({
|
|
1009
1266
|
email: z.email(),
|
|
1010
|
-
|
|
1267
|
+
password: z.string().min(8),
|
|
1268
|
+
name: z.string().min(2),
|
|
1011
1269
|
})
|
|
1012
1270
|
|
|
1013
|
-
export const
|
|
1014
|
-
.
|
|
1015
|
-
.
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1271
|
+
export const signUp = createServerFn({ method: 'POST' })
|
|
1272
|
+
.inputValidator(signUpSchema)
|
|
1273
|
+
.handler(async ({ data }) => {
|
|
1274
|
+
const user = await auth.api.signUpEmail({
|
|
1275
|
+
email: data.email,
|
|
1276
|
+
password: data.password,
|
|
1277
|
+
name: data.name,
|
|
1278
|
+
})
|
|
1279
|
+
return user
|
|
1280
|
+
})
|
|
1281
|
+
|
|
1282
|
+
const signInSchema = z.object({
|
|
1283
|
+
email: z.email(),
|
|
1284
|
+
password: z.string(),
|
|
1285
|
+
})
|
|
1286
|
+
|
|
1287
|
+
export const signIn = createServerFn({ method: 'POST' })
|
|
1288
|
+
.inputValidator(signInSchema)
|
|
1289
|
+
.handler(async ({ data }) => {
|
|
1290
|
+
const session = await auth.api.signInEmail({
|
|
1291
|
+
email: data.email,
|
|
1292
|
+
password: data.password,
|
|
1022
1293
|
})
|
|
1294
|
+
return session
|
|
1023
1295
|
})
|
|
1024
1296
|
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
throw new Error('Invalid data')
|
|
1297
|
+
export const signOut = createServerFn({ method: 'POST' }).handler(
|
|
1298
|
+
async () => {
|
|
1299
|
+
await auth.api.signOut()
|
|
1300
|
+
return { success: true }
|
|
1030
1301
|
}
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
export const getSession = createServerFn({ method: 'GET' }).handler(
|
|
1305
|
+
async () => {
|
|
1306
|
+
const session = await auth.api.getSession()
|
|
1307
|
+
return session
|
|
1308
|
+
}
|
|
1309
|
+
)
|
|
1310
|
+
```
|
|
1311
|
+
|
|
1312
|
+
### Auth Middleware
|
|
1313
|
+
|
|
1314
|
+
```typescript
|
|
1315
|
+
// app/middleware/auth.ts
|
|
1316
|
+
import { createMiddleware } from '@tanstack/react-start'
|
|
1317
|
+
import { auth } from '~/auth'
|
|
1318
|
+
|
|
1319
|
+
export const authMiddleware = createMiddleware().server(async ({ next }) => {
|
|
1320
|
+
const session = await auth.api.getSession()
|
|
1031
1321
|
|
|
1032
|
-
// ❌ 수동 인증 체크
|
|
1033
|
-
const session = await getSession()
|
|
1034
1322
|
if (!session) {
|
|
1035
1323
|
throw new Error('Unauthorized')
|
|
1036
1324
|
}
|
|
1037
1325
|
|
|
1038
|
-
return
|
|
1039
|
-
|
|
1326
|
+
return next({
|
|
1327
|
+
context: {
|
|
1328
|
+
user: session.user,
|
|
1329
|
+
sessionId: session.session.id,
|
|
1330
|
+
},
|
|
1331
|
+
})
|
|
1332
|
+
})
|
|
1333
|
+
|
|
1334
|
+
export const adminMiddleware = createMiddleware().server(
|
|
1335
|
+
async ({ next, context }) => {
|
|
1336
|
+
// Requires authMiddleware to run first
|
|
1337
|
+
if (context.user.role !== 'admin') {
|
|
1338
|
+
throw new Error('Forbidden: Admin access required')
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
return next({
|
|
1342
|
+
context: {
|
|
1343
|
+
...context,
|
|
1344
|
+
isAdmin: true,
|
|
1345
|
+
},
|
|
1346
|
+
})
|
|
1347
|
+
}
|
|
1348
|
+
)
|
|
1040
1349
|
```
|
|
1041
1350
|
|
|
1042
|
-
###
|
|
1351
|
+
### Protected Routes
|
|
1043
1352
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
| `isLoading`, `error` 상태 활용 | try-catch로 에러 처리 |
|
|
1049
|
-
| `queryKey`로 캐싱 관리 | useEffect + useState |
|
|
1353
|
+
```typescript
|
|
1354
|
+
// app/routes/_authenticated.tsx
|
|
1355
|
+
import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
1356
|
+
import { getSession } from '~/functions/auth'
|
|
1050
1357
|
|
|
1051
|
-
|
|
1358
|
+
export const Route = createFileRoute('/_authenticated')({
|
|
1359
|
+
beforeLoad: async ({ context, location }) => {
|
|
1360
|
+
const session = await getSession()
|
|
1052
1361
|
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1362
|
+
if (!session) {
|
|
1363
|
+
throw redirect({
|
|
1364
|
+
to: '/login',
|
|
1365
|
+
search: {
|
|
1366
|
+
redirect: location.href,
|
|
1367
|
+
},
|
|
1368
|
+
})
|
|
1369
|
+
}
|
|
1060
1370
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1371
|
+
return {
|
|
1372
|
+
user: session.user,
|
|
1373
|
+
}
|
|
1374
|
+
},
|
|
1375
|
+
})
|
|
1376
|
+
|
|
1377
|
+
// app/routes/_authenticated.profile.tsx
|
|
1378
|
+
export const Route = createFileRoute('/_authenticated/profile')({
|
|
1379
|
+
component: Profile,
|
|
1380
|
+
})
|
|
1381
|
+
|
|
1382
|
+
function Profile() {
|
|
1383
|
+
const { user } = Route.useRouteContext()
|
|
1063
1384
|
|
|
1064
|
-
return
|
|
1385
|
+
return (
|
|
1386
|
+
<div>
|
|
1387
|
+
<h1>Profile</h1>
|
|
1388
|
+
<p>Email: {user.email}</p>
|
|
1389
|
+
<p>Name: {user.name}</p>
|
|
1390
|
+
</div>
|
|
1391
|
+
)
|
|
1065
1392
|
}
|
|
1066
1393
|
|
|
1067
|
-
//
|
|
1068
|
-
const
|
|
1394
|
+
// app/routes/_authenticated.settings.tsx
|
|
1395
|
+
export const Route = createFileRoute('/_authenticated/settings')({
|
|
1396
|
+
component: Settings,
|
|
1397
|
+
})
|
|
1398
|
+
|
|
1399
|
+
function Settings() {
|
|
1400
|
+
const { user } = Route.useRouteContext()
|
|
1069
1401
|
const queryClient = useQueryClient()
|
|
1070
1402
|
|
|
1071
|
-
const
|
|
1072
|
-
mutationFn:
|
|
1403
|
+
const signOutMutation = useMutation({
|
|
1404
|
+
mutationFn: signOut,
|
|
1073
1405
|
onSuccess: () => {
|
|
1074
|
-
queryClient.
|
|
1406
|
+
queryClient.clear()
|
|
1407
|
+
window.location.href = '/login'
|
|
1075
1408
|
},
|
|
1076
1409
|
})
|
|
1077
1410
|
|
|
1078
1411
|
return (
|
|
1079
|
-
<
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
}}>
|
|
1087
|
-
<input name="email" type="email" />
|
|
1088
|
-
<input name="name" />
|
|
1089
|
-
<button type="submit">Create</button>
|
|
1090
|
-
</form>
|
|
1412
|
+
<div>
|
|
1413
|
+
<h1>Settings</h1>
|
|
1414
|
+
<p>Logged in as {user.email}</p>
|
|
1415
|
+
<button onClick={() => signOutMutation.mutate()}>
|
|
1416
|
+
Sign Out
|
|
1417
|
+
</button>
|
|
1418
|
+
</div>
|
|
1091
1419
|
)
|
|
1092
1420
|
}
|
|
1421
|
+
```
|
|
1093
1422
|
|
|
1094
|
-
|
|
1095
|
-
const BadComponent = (): JSX.Element => {
|
|
1096
|
-
const [users, setUsers] = useState([])
|
|
1097
|
-
const [loading, setLoading] = useState(false)
|
|
1423
|
+
### Login Form
|
|
1098
1424
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
}, [])
|
|
1425
|
+
```typescript
|
|
1426
|
+
// app/routes/login.tsx
|
|
1427
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
1428
|
+
import { useMutation } from '@tanstack/react-query'
|
|
1429
|
+
import { signIn } from '~/functions/auth'
|
|
1430
|
+
import { z } from 'zod'
|
|
1106
1431
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1432
|
+
const searchSchema = z.object({
|
|
1433
|
+
redirect: z.string().optional(),
|
|
1434
|
+
})
|
|
1435
|
+
|
|
1436
|
+
export const Route = createFileRoute('/login')({
|
|
1437
|
+
validateSearch: searchSchema,
|
|
1438
|
+
component: Login,
|
|
1439
|
+
})
|
|
1110
1440
|
|
|
1111
|
-
|
|
1441
|
+
function Login() {
|
|
1442
|
+
const { redirect } = Route.useSearch()
|
|
1443
|
+
const navigate = Route.useNavigate()
|
|
1112
1444
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1445
|
+
const signInMutation = useMutation({
|
|
1446
|
+
mutationFn: signIn,
|
|
1447
|
+
onSuccess: () => {
|
|
1448
|
+
window.location.href = redirect || '/'
|
|
1449
|
+
},
|
|
1450
|
+
})
|
|
1118
1451
|
|
|
1119
|
-
|
|
1452
|
+
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
1453
|
+
e.preventDefault()
|
|
1454
|
+
const formData = new FormData(e.currentTarget)
|
|
1455
|
+
|
|
1456
|
+
signInMutation.mutate({
|
|
1457
|
+
data: {
|
|
1458
|
+
email: formData.get('email') as string,
|
|
1459
|
+
password: formData.get('password') as string,
|
|
1460
|
+
},
|
|
1461
|
+
})
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
return (
|
|
1465
|
+
<div>
|
|
1466
|
+
<h1>Login</h1>
|
|
1467
|
+
<form onSubmit={handleSubmit}>
|
|
1468
|
+
<input
|
|
1469
|
+
name="email"
|
|
1470
|
+
type="email"
|
|
1471
|
+
placeholder="Email"
|
|
1472
|
+
required
|
|
1473
|
+
/>
|
|
1474
|
+
<input
|
|
1475
|
+
name="password"
|
|
1476
|
+
type="password"
|
|
1477
|
+
placeholder="Password"
|
|
1478
|
+
required
|
|
1479
|
+
/>
|
|
1480
|
+
<button type="submit" disabled={signInMutation.isPending}>
|
|
1481
|
+
{signInMutation.isPending ? 'Signing in...' : 'Sign In'}
|
|
1482
|
+
</button>
|
|
1483
|
+
{signInMutation.isError && (
|
|
1484
|
+
<p>Error: {signInMutation.error.message}</p>
|
|
1485
|
+
)}
|
|
1486
|
+
</form>
|
|
1487
|
+
</div>
|
|
1488
|
+
)
|
|
1489
|
+
}
|
|
1490
|
+
```
|
|
1491
|
+
|
|
1492
|
+
### Role-based Access Control
|
|
1120
1493
|
|
|
1121
1494
|
```typescript
|
|
1122
|
-
//
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
const session = await getSession()
|
|
1126
|
-
if (!session) throw redirect({ to: '/login' })
|
|
1127
|
-
return next({ context: { user: session.user } })
|
|
1128
|
-
})
|
|
1495
|
+
// app/functions/admin.ts
|
|
1496
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
1497
|
+
import { authMiddleware, adminMiddleware } from '~/middleware/auth'
|
|
1129
1498
|
|
|
1130
|
-
export const
|
|
1131
|
-
.middleware([authMiddleware])
|
|
1499
|
+
export const getAdminStats = createServerFn({ method: 'GET' })
|
|
1500
|
+
.middleware([authMiddleware, adminMiddleware])
|
|
1132
1501
|
.handler(async ({ context }) => {
|
|
1133
|
-
|
|
1502
|
+
const stats = await db.stats.findMany()
|
|
1503
|
+
return stats
|
|
1134
1504
|
})
|
|
1135
1505
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
.
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
return { user: session.user }
|
|
1506
|
+
export const deleteUser = createServerFn({ method: 'DELETE' })
|
|
1507
|
+
.inputValidator(z.object({ userId: z.string().uuid() }))
|
|
1508
|
+
.middleware([authMiddleware, adminMiddleware])
|
|
1509
|
+
.handler(async ({ data }) => {
|
|
1510
|
+
await db.user.delete({ where: { id: data.userId } })
|
|
1511
|
+
return { success: true }
|
|
1144
1512
|
})
|
|
1513
|
+
|
|
1514
|
+
// app/routes/_authenticated._admin.dashboard.tsx
|
|
1515
|
+
export const Route = createFileRoute('/_authenticated/_admin/dashboard')({
|
|
1516
|
+
beforeLoad: async ({ context }) => {
|
|
1517
|
+
if (context.user.role !== 'admin') {
|
|
1518
|
+
throw redirect({ to: '/' })
|
|
1519
|
+
}
|
|
1520
|
+
},
|
|
1521
|
+
loader: async () => {
|
|
1522
|
+
const stats = await getAdminStats()
|
|
1523
|
+
return { stats }
|
|
1524
|
+
},
|
|
1525
|
+
component: AdminDashboard,
|
|
1526
|
+
})
|
|
1527
|
+
|
|
1528
|
+
function AdminDashboard() {
|
|
1529
|
+
const { stats } = Route.useLoaderData()
|
|
1530
|
+
return <div>{/* Render admin dashboard */}</div>
|
|
1531
|
+
}
|
|
1145
1532
|
```
|
|
1146
1533
|
|
|
1147
|
-
|
|
1534
|
+
</auth_patterns>
|
|
1148
1535
|
|
|
1149
|
-
|
|
1150
|
-
|-------|----------|
|
|
1151
|
-
| 페이지 전용: `routes/[path]/-functions/` | 모든 함수를 `@/functions/`에 |
|
|
1152
|
-
| 공통 함수: `@/functions/` | 라우트 파일에 함수 직접 작성 |
|
|
1153
|
-
| Custom Hook: `-hooks/` 폴더에 분리 (필수) | 라우트 파일에 Hook 작성 |
|
|
1536
|
+
---
|
|
1154
1537
|
|
|
1155
|
-
|
|
1538
|
+
<file_structure>
|
|
1156
1539
|
|
|
1157
|
-
|
|
1158
|
-
// ✅ 올바른 구조
|
|
1159
|
-
// routes/users/-functions/user-mutations.ts
|
|
1160
|
-
export const createUser = createServerFn({ method: 'POST' })
|
|
1161
|
-
.inputValidator(schema)
|
|
1162
|
-
.handler(async ({ data }) => prisma.user.create({ data }))
|
|
1540
|
+
## 권장 디렉토리 구조
|
|
1163
1541
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1542
|
+
```
|
|
1543
|
+
app/
|
|
1544
|
+
├── routes/
|
|
1545
|
+
│ ├── __root.tsx
|
|
1546
|
+
│ ├── index.tsx
|
|
1547
|
+
│ ├── about.tsx
|
|
1548
|
+
│ ├── posts.index.tsx
|
|
1549
|
+
│ ├── posts.$postId.tsx
|
|
1550
|
+
│ ├── posts.$postId.edit.tsx
|
|
1551
|
+
│ ├── _authenticated.tsx
|
|
1552
|
+
│ ├── _authenticated.profile.tsx
|
|
1553
|
+
│ └── api/
|
|
1554
|
+
│ └── users.$userId.tsx
|
|
1555
|
+
├── components/
|
|
1556
|
+
│ ├── ui/
|
|
1557
|
+
│ │ ├── Button.tsx
|
|
1558
|
+
│ │ └── Input.tsx
|
|
1559
|
+
│ └── PostCard.tsx
|
|
1560
|
+
├── functions/
|
|
1561
|
+
│ ├── posts.ts
|
|
1562
|
+
│ ├── posts-helpers.ts
|
|
1563
|
+
│ ├── users.ts
|
|
1564
|
+
│ └── auth.ts
|
|
1565
|
+
├── middleware/
|
|
1566
|
+
│ ├── auth.ts
|
|
1567
|
+
│ ├── logging.ts
|
|
1568
|
+
│ └── validation.ts
|
|
1569
|
+
├── hooks/
|
|
1570
|
+
│ ├── useUser.ts
|
|
1571
|
+
│ └── usePosts.ts
|
|
1572
|
+
├── lib/
|
|
1573
|
+
│ ├── db.ts
|
|
1574
|
+
│ ├── auth.ts
|
|
1575
|
+
│ └── utils.ts
|
|
1576
|
+
├── router.tsx
|
|
1577
|
+
└── env.ts
|
|
1578
|
+
```
|
|
1169
1579
|
|
|
1170
|
-
|
|
1171
|
-
import { useUserForm } from './-hooks/use-user-form'
|
|
1172
|
-
import { createUser } from './-functions/user-mutations'
|
|
1580
|
+
## Route 파일 컨벤션
|
|
1173
1581
|
|
|
1174
|
-
|
|
1175
|
-
// routes/users/index.tsx (모든 로직이 한 파일에)
|
|
1176
|
-
const createUser = createServerFn({ method: 'POST' })
|
|
1177
|
-
.inputValidator(schema)
|
|
1178
|
-
.handler(async ({ data }) => prisma.user.create({ data }))
|
|
1582
|
+
### Colocation Pattern
|
|
1179
1583
|
|
|
1180
|
-
|
|
1181
|
-
const mutation = useMutation({ mutationFn: createUser })
|
|
1182
|
-
return { mutation }
|
|
1183
|
-
}
|
|
1584
|
+
Route 파일 옆에 관련 파일들을 배치할 때 `-` prefix 사용:
|
|
1184
1585
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1586
|
+
```
|
|
1587
|
+
routes/
|
|
1588
|
+
├── posts.index.tsx
|
|
1589
|
+
├── posts.index-components/
|
|
1590
|
+
│ ├── PostCard.tsx
|
|
1591
|
+
│ └── PostsList.tsx
|
|
1592
|
+
├── posts.index-hooks/
|
|
1593
|
+
│ └── usePosts.ts
|
|
1594
|
+
├── posts.$postId.tsx
|
|
1595
|
+
├── posts.$postId-components/
|
|
1596
|
+
│ ├── PostHeader.tsx
|
|
1597
|
+
│ └── CommentSection.tsx
|
|
1598
|
+
└── posts.$postId-functions/
|
|
1599
|
+
├── updatePost.ts
|
|
1600
|
+
└── deletePost.ts
|
|
1188
1601
|
```
|
|
1189
1602
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
| ✅ Do | ❌ Don't |
|
|
1193
|
-
|-------|----------|
|
|
1194
|
-
| Server Function에서만 `process.env` 사용 | loader에서 직접 사용 |
|
|
1195
|
-
| Zod로 환경 변수 검증 | 검증 없이 사용 |
|
|
1603
|
+
**규칙**: `-components`, `-hooks`, `-functions` 접미사 사용
|
|
1196
1604
|
|
|
1197
|
-
###
|
|
1605
|
+
### ✅ 올바른 패턴
|
|
1198
1606
|
|
|
1199
1607
|
```typescript
|
|
1200
|
-
//
|
|
1201
|
-
|
|
1202
|
-
const envSchema = z.object({
|
|
1203
|
-
DATABASE_URL: z.string().url(),
|
|
1204
|
-
API_SECRET: z.string().min(32),
|
|
1205
|
-
})
|
|
1608
|
+
// posts.index-functions/getPosts.ts
|
|
1609
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
1206
1610
|
|
|
1207
|
-
export const
|
|
1611
|
+
export const getPosts = createServerFn({ method: 'GET' }).handler(
|
|
1612
|
+
async () => {
|
|
1613
|
+
return db.post.findMany()
|
|
1614
|
+
}
|
|
1615
|
+
)
|
|
1208
1616
|
|
|
1209
|
-
//
|
|
1210
|
-
|
|
1211
|
-
.
|
|
1212
|
-
|
|
1213
|
-
return {
|
|
1214
|
-
secret: env.API_SECRET, // ✅ 서버에서만 실행
|
|
1215
|
-
}
|
|
1216
|
-
})
|
|
1617
|
+
// posts.index-components/PostCard.tsx
|
|
1618
|
+
export function PostCard({ post }: { post: Post }) {
|
|
1619
|
+
return <div>{post.title}</div>
|
|
1620
|
+
}
|
|
1217
1621
|
|
|
1218
|
-
//
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1622
|
+
// posts.index.tsx
|
|
1623
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
1624
|
+
import { getPosts } from './posts.index-functions/getPosts'
|
|
1625
|
+
import { PostCard } from './posts.index-components/PostCard'
|
|
1626
|
+
|
|
1627
|
+
export const Route = createFileRoute('/posts/')({
|
|
1628
|
+
loader: async () => {
|
|
1629
|
+
const posts = await getPosts()
|
|
1630
|
+
return { posts }
|
|
1223
1631
|
},
|
|
1632
|
+
component: PostsList,
|
|
1224
1633
|
})
|
|
1634
|
+
|
|
1635
|
+
function PostsList() {
|
|
1636
|
+
const { posts } = Route.useLoaderData()
|
|
1637
|
+
return (
|
|
1638
|
+
<div>
|
|
1639
|
+
{posts.map((post) => (
|
|
1640
|
+
<PostCard key={post.id} post={post} />
|
|
1641
|
+
))}
|
|
1642
|
+
</div>
|
|
1643
|
+
)
|
|
1644
|
+
}
|
|
1225
1645
|
```
|
|
1226
1646
|
|
|
1227
|
-
|
|
1647
|
+
### ❌ 잘못된 패턴
|
|
1648
|
+
|
|
1649
|
+
```
|
|
1650
|
+
routes/
|
|
1651
|
+
├── posts.index.tsx
|
|
1652
|
+
├── PostCard.tsx ❌ Route 파일과 혼재
|
|
1653
|
+
└── getPosts.ts ❌ 명확한 분류 없음
|
|
1654
|
+
```
|
|
1655
|
+
|
|
1656
|
+
</file_structure>
|
|
1228
1657
|
|
|
1229
1658
|
---
|
|
1230
1659
|
|
|
1231
|
-
<
|
|
1660
|
+
<dos_donts>
|
|
1232
1661
|
|
|
1233
|
-
##
|
|
1662
|
+
## Do's
|
|
1663
|
+
|
|
1664
|
+
| 패턴 | 예시 |
|
|
1665
|
+
|------|------|
|
|
1666
|
+
| **✅ .inputValidator() 사용** | `createServerFn({ method: 'POST' }).inputValidator(schema)` |
|
|
1667
|
+
| **✅ Middleware로 인증** | `.middleware([authMiddleware])` |
|
|
1668
|
+
| **✅ Helper functions 분리** | `import 'server-only'` + 별도 파일 |
|
|
1669
|
+
| **✅ TanStack Query로 호출** | `useSuspenseQuery`, `useMutation` |
|
|
1670
|
+
| **✅ beforeLoad로 guard** | `beforeLoad: async () => { if (!session) throw redirect() }` |
|
|
1671
|
+
| **✅ Zod로 입력 검증** | `z.email()`, `z.url()`, `z.string().min()` |
|
|
1672
|
+
| **✅ 명시적 return type** | `function getData(): Promise<User[]>` |
|
|
1673
|
+
| **✅ Server env 분리** | Server: `process.env`, Client: `import.meta.env.VITE_*` |
|
|
1674
|
+
| **✅ SWR caching 활용** | `staleTime`, `gcTime`, `shouldReload` 설정 |
|
|
1675
|
+
| **✅ Streaming SSR** | `<Suspense>` + `useSuspenseQuery` |
|
|
1676
|
+
|
|
1677
|
+
## Don'ts
|
|
1678
|
+
|
|
1679
|
+
| 패턴 | 이유 |
|
|
1680
|
+
|------|------|
|
|
1681
|
+
| **❌ .validator() 사용** | Deprecated. `.inputValidator()` 사용 필수 |
|
|
1682
|
+
| **❌ createAPIFileRoute()** | Deprecated. `createServerFileRoute().methods()` 사용 |
|
|
1683
|
+
| **❌ @tanstack/start** | 패키지명 변경됨. `@tanstack/react-start` 사용 |
|
|
1684
|
+
| **❌ Server Function에 민감 로직** | 클라이언트 번들 포함 위험. Helper 분리 |
|
|
1685
|
+
| **❌ Server에서 import.meta.env** | 서버에서는 `process.env` 사용 |
|
|
1686
|
+
| **❌ Client에서 process.env** | 클라이언트에서는 `import.meta.env.VITE_*` 사용 |
|
|
1687
|
+
| **❌ verbatimModuleSyntax: true** | TanStack Start 호환성 문제 |
|
|
1688
|
+
| **❌ gcTime: 0 남발** | 캐시 무효화. 신중히 사용 |
|
|
1689
|
+
| **❌ any 타입** | Type safety 상실. `unknown` 또는 명시적 타입 |
|
|
1690
|
+
| **❌ Route 파일에 비즈니스 로직** | 관심사 분리. `-functions/` 사용 |
|
|
1691
|
+
|
|
1692
|
+
## 마이그레이션 체크리스트
|
|
1693
|
+
|
|
1694
|
+
### v1.121.0+ 업데이트
|
|
1695
|
+
|
|
1696
|
+
| 변경 전 | 변경 후 |
|
|
1697
|
+
|---------|---------|
|
|
1698
|
+
| `import { createServerFn } from '@tanstack/start'` | `import { createServerFn } from '@tanstack/react-start'` |
|
|
1699
|
+
| `.validator(schema)` | `.inputValidator(schema)` |
|
|
1700
|
+
| `createAPIFileRoute('/api/users')` | `createServerFileRoute('/api/users').methods({ GET, POST })` |
|
|
1701
|
+
| Vinxi config | Vite config (`vite.config.ts`) |
|
|
1234
1702
|
|
|
1235
|
-
|
|
1703
|
+
</dos_donts>
|
|
1236
1704
|
|
|
1237
|
-
|
|
1238
|
-
export const getUsers = createServerFn({ method: 'GET' })
|
|
1239
|
-
.handler(async () => prisma.user.findMany())
|
|
1705
|
+
---
|
|
1240
1706
|
|
|
1241
|
-
|
|
1242
|
-
.middleware([authMiddleware])
|
|
1243
|
-
.handler(async ({ context }) => context.user)
|
|
1244
|
-
```
|
|
1707
|
+
<quick_reference>
|
|
1245
1708
|
|
|
1246
|
-
|
|
1709
|
+
## Server Function 템플릿
|
|
1247
1710
|
|
|
1248
1711
|
```typescript
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
})
|
|
1712
|
+
// GET
|
|
1713
|
+
export const getData = createServerFn({ method: 'GET' })
|
|
1714
|
+
.inputValidator(z.object({ id: z.string() }))
|
|
1715
|
+
.handler(async ({ data }) => {
|
|
1716
|
+
return await getDataFromDB(data.id)
|
|
1717
|
+
})
|
|
1253
1718
|
|
|
1254
|
-
|
|
1719
|
+
// POST
|
|
1720
|
+
export const createData = createServerFn({ method: 'POST' })
|
|
1721
|
+
.inputValidator(z.object({ name: z.string() }))
|
|
1255
1722
|
.middleware([authMiddleware])
|
|
1256
|
-
.inputValidator(schema)
|
|
1257
1723
|
.handler(async ({ data, context }) => {
|
|
1258
|
-
return
|
|
1259
|
-
|
|
1724
|
+
return await createDataInDB({
|
|
1725
|
+
...data,
|
|
1726
|
+
userId: context.user.id,
|
|
1260
1727
|
})
|
|
1261
1728
|
})
|
|
1262
|
-
```
|
|
1263
1729
|
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
```typescript
|
|
1267
|
-
export const updateUser = createServerFn({ method: 'PUT' })
|
|
1268
|
-
.middleware([authMiddleware])
|
|
1730
|
+
// PUT
|
|
1731
|
+
export const updateData = createServerFn({ method: 'PUT' })
|
|
1269
1732
|
.inputValidator(z.object({ id: z.string(), name: z.string() }))
|
|
1733
|
+
.middleware([authMiddleware])
|
|
1270
1734
|
.handler(async ({ data }) => {
|
|
1271
|
-
return
|
|
1272
|
-
where: { id: data.id },
|
|
1273
|
-
data: { name: data.name },
|
|
1274
|
-
})
|
|
1735
|
+
return await updateDataInDB(data.id, data)
|
|
1275
1736
|
})
|
|
1276
|
-
```
|
|
1277
|
-
|
|
1278
|
-
### DELETE: 데이터 삭제
|
|
1279
1737
|
|
|
1280
|
-
|
|
1281
|
-
export const
|
|
1282
|
-
.middleware([authMiddleware])
|
|
1738
|
+
// DELETE
|
|
1739
|
+
export const deleteData = createServerFn({ method: 'DELETE' })
|
|
1283
1740
|
.inputValidator(z.object({ id: z.string() }))
|
|
1741
|
+
.middleware([authMiddleware, adminMiddleware])
|
|
1284
1742
|
.handler(async ({ data }) => {
|
|
1285
|
-
|
|
1743
|
+
await deleteDataFromDB(data.id)
|
|
1744
|
+
return { success: true }
|
|
1286
1745
|
})
|
|
1287
1746
|
```
|
|
1288
1747
|
|
|
1289
|
-
|
|
1748
|
+
## Route 템플릿
|
|
1290
1749
|
|
|
1291
1750
|
```typescript
|
|
1292
|
-
|
|
1293
|
-
|
|
1751
|
+
// Basic Route
|
|
1752
|
+
export const Route = createFileRoute('/path')({
|
|
1753
|
+
component: Component,
|
|
1754
|
+
})
|
|
1755
|
+
|
|
1756
|
+
// Route with Loader
|
|
1757
|
+
export const Route = createFileRoute('/path')({
|
|
1294
1758
|
loader: async () => {
|
|
1295
|
-
const
|
|
1296
|
-
return {
|
|
1759
|
+
const data = await getData()
|
|
1760
|
+
return { data }
|
|
1297
1761
|
},
|
|
1762
|
+
component: Component,
|
|
1298
1763
|
})
|
|
1299
1764
|
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
}
|
|
1765
|
+
// Route with Params
|
|
1766
|
+
export const Route = createFileRoute('/path/$id')({
|
|
1767
|
+
loader: async ({ params }) => {
|
|
1768
|
+
const data = await getData({ data: { id: params.id } })
|
|
1769
|
+
return { data }
|
|
1770
|
+
},
|
|
1771
|
+
component: Component,
|
|
1772
|
+
})
|
|
1773
|
+
|
|
1774
|
+
// Route with Search
|
|
1775
|
+
export const Route = createFileRoute('/path')({
|
|
1776
|
+
validateSearch: z.object({
|
|
1777
|
+
q: z.string().optional(),
|
|
1778
|
+
}),
|
|
1779
|
+
loaderDeps: ({ search }) => ({ search }),
|
|
1780
|
+
loader: async ({ deps }) => {
|
|
1781
|
+
const results = await search(deps.search.q)
|
|
1782
|
+
return { results }
|
|
1783
|
+
},
|
|
1784
|
+
component: Component,
|
|
1785
|
+
})
|
|
1786
|
+
|
|
1787
|
+
// Protected Route
|
|
1788
|
+
export const Route = createFileRoute('/_auth/path')({
|
|
1789
|
+
beforeLoad: async ({ context }) => {
|
|
1790
|
+
const session = await getSession()
|
|
1791
|
+
if (!session) throw redirect({ to: '/login' })
|
|
1792
|
+
return { user: session.user }
|
|
1793
|
+
},
|
|
1794
|
+
component: Component,
|
|
1795
|
+
})
|
|
1304
1796
|
```
|
|
1305
1797
|
|
|
1306
|
-
|
|
1798
|
+
## Middleware 템플릿
|
|
1307
1799
|
|
|
1308
1800
|
```typescript
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1801
|
+
// Auth Middleware
|
|
1802
|
+
export const authMiddleware = createMiddleware().server(
|
|
1803
|
+
async ({ next }) => {
|
|
1804
|
+
const session = await getSession()
|
|
1805
|
+
if (!session) throw new Error('Unauthorized')
|
|
1806
|
+
return next({ context: { user: session.user } })
|
|
1807
|
+
}
|
|
1808
|
+
)
|
|
1809
|
+
|
|
1810
|
+
// Validation Middleware
|
|
1811
|
+
export const validateMiddleware = <T extends z.ZodType>(schema: T) =>
|
|
1812
|
+
createMiddleware().server(async ({ next, data }) => {
|
|
1813
|
+
const validated = schema.parse(data)
|
|
1814
|
+
return next({ context: { validated } })
|
|
1815
|
+
})
|
|
1816
|
+
|
|
1817
|
+
// Logging Middleware
|
|
1818
|
+
export const loggingMiddleware = createMiddleware().server(
|
|
1819
|
+
async ({ next, method, url }) => {
|
|
1820
|
+
console.log(`[${method}] ${url}`)
|
|
1821
|
+
const result = await next()
|
|
1822
|
+
console.log(`[${method}] ${url} - Success`)
|
|
1823
|
+
return result
|
|
1824
|
+
}
|
|
1825
|
+
)
|
|
1313
1826
|
```
|
|
1314
1827
|
|
|
1315
|
-
|
|
1828
|
+
## TanStack Query 템플릿
|
|
1316
1829
|
|
|
1317
1830
|
```typescript
|
|
1318
|
-
|
|
1831
|
+
// useSuspenseQuery (GET)
|
|
1832
|
+
const { data } = useSuspenseQuery({
|
|
1833
|
+
queryKey: ['key', id],
|
|
1834
|
+
queryFn: () => getData({ data: { id } }),
|
|
1835
|
+
})
|
|
1319
1836
|
|
|
1837
|
+
// useMutation (POST/PUT/DELETE)
|
|
1320
1838
|
const mutation = useMutation({
|
|
1321
|
-
mutationFn:
|
|
1322
|
-
onSuccess: () => {
|
|
1323
|
-
queryClient.invalidateQueries({ queryKey: ['
|
|
1839
|
+
mutationFn: createData,
|
|
1840
|
+
onSuccess: (data) => {
|
|
1841
|
+
queryClient.invalidateQueries({ queryKey: ['key'] })
|
|
1324
1842
|
},
|
|
1325
1843
|
})
|
|
1326
1844
|
|
|
1327
|
-
mutation.mutate({
|
|
1845
|
+
mutation.mutate({ data: { name: 'value' } })
|
|
1328
1846
|
```
|
|
1329
1847
|
|
|
1330
|
-
|
|
1848
|
+
## Server Routes 템플릿
|
|
1331
1849
|
|
|
1332
1850
|
```typescript
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1851
|
+
// RESTful API
|
|
1852
|
+
export const Route = createServerFileRoute('/api/resource/$id')
|
|
1853
|
+
.methods({
|
|
1854
|
+
GET: async ({ params }) => {
|
|
1855
|
+
const data = await getResource(params.id)
|
|
1856
|
+
return Response.json(data)
|
|
1857
|
+
},
|
|
1858
|
+
PUT: async ({ request, params }) => {
|
|
1859
|
+
const body = await request.json()
|
|
1860
|
+
const data = await updateResource(params.id, body)
|
|
1861
|
+
return Response.json(data)
|
|
1862
|
+
},
|
|
1863
|
+
DELETE: async ({ params }) => {
|
|
1864
|
+
await deleteResource(params.id)
|
|
1865
|
+
return Response.json({ success: true })
|
|
1866
|
+
},
|
|
1338
1867
|
})
|
|
1339
1868
|
```
|
|
1340
1869
|
|
|
1341
|
-
|
|
1870
|
+
## Auth 템플릿
|
|
1342
1871
|
|
|
1343
1872
|
```typescript
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
return
|
|
1873
|
+
// Auth Server Functions
|
|
1874
|
+
export const signIn = createServerFn({ method: 'POST' })
|
|
1875
|
+
.inputValidator(z.object({ email: z.email(), password: z.string() }))
|
|
1876
|
+
.handler(async ({ data }) => {
|
|
1877
|
+
return await auth.api.signInEmail(data)
|
|
1878
|
+
})
|
|
1879
|
+
|
|
1880
|
+
export const getSession = createServerFn({ method: 'GET' })
|
|
1881
|
+
.handler(async () => {
|
|
1882
|
+
return await auth.api.getSession()
|
|
1883
|
+
})
|
|
1884
|
+
|
|
1885
|
+
// Protected Route
|
|
1886
|
+
export const Route = createFileRoute('/_auth')({
|
|
1887
|
+
beforeLoad: async ({ location }) => {
|
|
1888
|
+
const session = await getSession()
|
|
1889
|
+
if (!session) {
|
|
1890
|
+
throw redirect({ to: '/login', search: { redirect: location.href } })
|
|
1891
|
+
}
|
|
1892
|
+
return { user: session.user }
|
|
1349
1893
|
},
|
|
1350
|
-
component: DashboardPage,
|
|
1351
1894
|
})
|
|
1352
1895
|
```
|
|
1353
1896
|
|
|
1897
|
+
## Deployment 템플릿
|
|
1898
|
+
|
|
1899
|
+
### Cloudflare Workers
|
|
1900
|
+
|
|
1901
|
+
```typescript
|
|
1902
|
+
// app/entry.server.tsx
|
|
1903
|
+
export default {
|
|
1904
|
+
async fetch(request: Request, env: Env) {
|
|
1905
|
+
return handleRequest(request, { env })
|
|
1906
|
+
},
|
|
1907
|
+
}
|
|
1908
|
+
```
|
|
1909
|
+
|
|
1910
|
+
### Vercel
|
|
1911
|
+
|
|
1912
|
+
```json
|
|
1913
|
+
// vercel.json
|
|
1914
|
+
{
|
|
1915
|
+
"buildCommand": "npm run build",
|
|
1916
|
+
"framework": "vite"
|
|
1917
|
+
}
|
|
1918
|
+
```
|
|
1919
|
+
|
|
1920
|
+
### Netlify
|
|
1921
|
+
|
|
1922
|
+
```toml
|
|
1923
|
+
# netlify.toml
|
|
1924
|
+
[build]
|
|
1925
|
+
command = "npm run build"
|
|
1926
|
+
publish = "dist/public"
|
|
1927
|
+
|
|
1928
|
+
[[redirects]]
|
|
1929
|
+
from = "/*"
|
|
1930
|
+
to = "/.netlify/functions/server"
|
|
1931
|
+
status = 200
|
|
1932
|
+
```
|
|
1933
|
+
|
|
1354
1934
|
</quick_reference>
|
|
1355
1935
|
|
|
1356
1936
|
---
|
|
1357
1937
|
|
|
1358
1938
|
<version_info>
|
|
1359
1939
|
|
|
1360
|
-
|
|
1940
|
+
## 버전 정보
|
|
1941
|
+
|
|
1942
|
+
**현재 버전**: v1.x (RC, ~v1.159.x)
|
|
1943
|
+
|
|
1944
|
+
**주요 변경사항**:
|
|
1945
|
+
|
|
1946
|
+
| 버전 | 변경 내용 |
|
|
1947
|
+
|------|-----------|
|
|
1948
|
+
| v1.121.0+ | `@tanstack/start` → `@tanstack/react-start` 패키지명 변경 |
|
|
1949
|
+
| v1.x | Vinxi → Vite 빌드 시스템 마이그레이션 |
|
|
1950
|
+
| v1.x | `.validator()` → `.inputValidator()` (deprecated) |
|
|
1951
|
+
| v1.x | `createAPIFileRoute()` → `createServerFileRoute().methods()` |
|
|
1952
|
+
| v1.x | React 18+ or 19+ 지원 |
|
|
1953
|
+
| v1.x | Vite 7+ 필수 |
|
|
1954
|
+
| v1.x | Node.js >=22.12.0 필수 |
|
|
1955
|
+
|
|
1956
|
+
**Breaking Changes**:
|
|
1957
|
+
|
|
1958
|
+
1. **패키지명 변경**: `@tanstack/start` → `@tanstack/react-start`
|
|
1959
|
+
2. **빌드 시스템**: Vinxi config → Vite config
|
|
1960
|
+
3. **Validator API**: `.validator()` deprecated, `.inputValidator()` 사용
|
|
1961
|
+
4. **Server Routes API**: `createAPIFileRoute()` deprecated, `createServerFileRoute().methods()` 사용
|
|
1962
|
+
|
|
1963
|
+
**마이그레이션 가이드**:
|
|
1964
|
+
|
|
1965
|
+
```bash
|
|
1966
|
+
# 1. 패키지 업데이트
|
|
1967
|
+
npm uninstall @tanstack/start
|
|
1968
|
+
npm install @tanstack/react-start@latest
|
|
1969
|
+
|
|
1970
|
+
# 2. Import 변경
|
|
1971
|
+
# Before: import { createServerFn } from '@tanstack/start'
|
|
1972
|
+
# After: import { createServerFn } from '@tanstack/react-start'
|
|
1973
|
+
|
|
1974
|
+
# 3. Validator 변경
|
|
1975
|
+
# Before: .validator(schema)
|
|
1976
|
+
# After: .inputValidator(schema)
|
|
1977
|
+
|
|
1978
|
+
# 4. Server Routes 변경
|
|
1979
|
+
# Before: createAPIFileRoute('/api/users')
|
|
1980
|
+
# After: createServerFileRoute('/api/users').methods({ GET, POST })
|
|
1981
|
+
|
|
1982
|
+
# 5. Vite config 생성
|
|
1983
|
+
# vinxi.config.ts 삭제 → vite.config.ts 생성
|
|
1984
|
+
```
|
|
1985
|
+
|
|
1986
|
+
**향후 계획**:
|
|
1987
|
+
|
|
1988
|
+
- **React Server Components**: v1.x에 non-breaking 방식으로 추가 예정
|
|
1989
|
+
- **Static Server Functions**: Experimental 단계, 정식 지원 예정
|
|
1990
|
+
- **v2.0**: Breaking changes 예정 (정확한 일정 미정)
|
|
1991
|
+
|
|
1992
|
+
**호환성**:
|
|
1993
|
+
|
|
1994
|
+
| 패키지 | 최소 버전 | 권장 버전 |
|
|
1995
|
+
|--------|-----------|-----------|
|
|
1996
|
+
| React | 18.0.0 | 19.x |
|
|
1997
|
+
| Vite | 7.0.0 | 7.x |
|
|
1998
|
+
| Node.js | 22.12.0 | 22.x LTS |
|
|
1999
|
+
| TypeScript | 5.0.0 | 5.x |
|
|
2000
|
+
|
|
2001
|
+
**알려진 이슈**:
|
|
2002
|
+
|
|
2003
|
+
- `verbatimModuleSyntax: true` 사용 시 호환성 문제
|
|
2004
|
+
- Full `strict` mode 대신 `strictNullChecks: true` 권장
|
|
2005
|
+
|
|
2006
|
+
**참고 자료**:
|
|
1361
2007
|
|
|
1362
|
-
|
|
1363
|
-
-
|
|
1364
|
-
-
|
|
1365
|
-
- Improved type safety for Server Functions
|
|
1366
|
-
- Better TanStack Query integration
|
|
2008
|
+
- Official Docs: https://tanstack.com/start
|
|
2009
|
+
- GitHub: https://github.com/TanStack/router
|
|
2010
|
+
- Discord: https://tanstack.com/discord
|
|
1367
2011
|
|
|
1368
2012
|
</version_info>
|