@kood/claude-code 0.3.9 → 0.3.10
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 +1 -1
- package/package.json +1 -1
- package/templates/tanstack-start/docs/library/better-auth/index.md +225 -185
- package/templates/tanstack-start/docs/library/prisma/index.md +1025 -41
- package/templates/tanstack-start/docs/library/t3-env/index.md +207 -40
- package/templates/tanstack-start/docs/library/tanstack-query/index.md +878 -42
- package/templates/tanstack-start/docs/library/tanstack-router/index.md +602 -54
- package/templates/tanstack-start/docs/library/tanstack-start/index.md +1334 -33
- package/templates/tanstack-start/docs/library/zod/index.md +674 -31
|
@@ -1,67 +1,1368 @@
|
|
|
1
1
|
# TanStack Start
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> v1 | Full-stack React Framework
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<context>
|
|
8
|
+
|
|
9
|
+
**Purpose:** TanStack Start를 사용한 Full-stack React 애플리케이션 개발 가이드
|
|
10
|
+
|
|
11
|
+
**Scope:**
|
|
12
|
+
- Server Functions (타입 안전 API)
|
|
13
|
+
- File-based Routing (TanStack Router)
|
|
14
|
+
- Middleware 체계
|
|
15
|
+
- SSR + Streaming
|
|
16
|
+
- TanStack Query 통합
|
|
17
|
+
|
|
18
|
+
**Key Features:**
|
|
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)
|
|
25
|
+
|
|
26
|
+
**Version:** v1.x
|
|
27
|
+
|
|
28
|
+
</context>
|
|
10
29
|
|
|
11
30
|
---
|
|
12
31
|
|
|
13
|
-
|
|
32
|
+
<forbidden>
|
|
14
33
|
|
|
15
|
-
|
|
|
16
|
-
|
|
17
|
-
|
|
|
18
|
-
|
|
|
19
|
-
|
|
|
34
|
+
| 분류 | ❌ 금지 | 이유 |
|
|
35
|
+
|------|---------|------|
|
|
36
|
+
| **API 라우터** | `/api` 경로에 라우터 생성 | Server Functions 사용 |
|
|
37
|
+
| **수동 검증** | handler 내부에서 Zod 수동 검증 | `.inputValidator()` 사용 |
|
|
38
|
+
| **수동 인증** | handler 내부에서 세션 체크 | `.middleware()` 사용 |
|
|
39
|
+
| **deprecated API** | `.validator()` 메서드 | `.inputValidator()` 사용 (v1) |
|
|
40
|
+
| **직접 호출** | 컴포넌트에서 Server Function 직접 호출 | TanStack Query 사용 필수 |
|
|
41
|
+
| **환경변수 노출** | loader에서 `process.env` 직접 사용 | Server Function에서만 |
|
|
42
|
+
| **헬퍼 export** | 내부 헬퍼 함수 export | Server Function만 export |
|
|
43
|
+
| **any 타입** | Server Function 파라미터에 any | 명시적 타입/Zod 스키마 |
|
|
44
|
+
| **일반 함수** | 일반 async 함수를 API로 사용 | `createServerFn()` 사용 |
|
|
20
45
|
|
|
21
|
-
|
|
22
|
-
✅ Auth needed → middleware required
|
|
46
|
+
</forbidden>
|
|
23
47
|
|
|
24
48
|
---
|
|
25
49
|
|
|
26
|
-
|
|
50
|
+
<required>
|
|
51
|
+
|
|
52
|
+
| 분류 | ✅ 필수 | 상세 |
|
|
53
|
+
|------|---------|------|
|
|
54
|
+
| **POST/PUT/PATCH** | `.inputValidator()` 사용 | Zod 스키마로 검증 |
|
|
55
|
+
| **인증 필요** | `.middleware()` 사용 | authMiddleware 적용 |
|
|
56
|
+
| **클라이언트 호출** | TanStack Query 사용 | useQuery/useMutation |
|
|
57
|
+
| **Server Function** | `createServerFn()` 사용 | method 명시 (GET/POST/etc) |
|
|
58
|
+
| **타입 안전성** | 명시적 return type | TypeScript strict 모드 |
|
|
59
|
+
| **파일 구조** | `-functions/` 폴더 사용 | 페이지 전용 함수 분리 |
|
|
60
|
+
| **공통 함수** | `@/functions/` 사용 | 재사용 가능한 함수 |
|
|
61
|
+
| **환경변수** | Server Function 내부에서만 | 클라이언트 노출 방지 |
|
|
62
|
+
|
|
63
|
+
</required>
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
<setup>
|
|
68
|
+
|
|
69
|
+
## 설치
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
yarn add @tanstack/react-start @tanstack/react-router vinxi
|
|
73
|
+
yarn add -D vite @vitejs/plugin-react vite-tsconfig-paths
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 설정
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
// vite.config.ts
|
|
80
|
+
import { defineConfig } from 'vite'
|
|
81
|
+
import tsConfigPaths from 'vite-tsconfig-paths'
|
|
82
|
+
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
|
83
|
+
import viteReact from '@vitejs/plugin-react'
|
|
84
|
+
|
|
85
|
+
export default defineConfig({
|
|
86
|
+
server: { port: 3000 },
|
|
87
|
+
plugins: [tsConfigPaths(), tanstackStart(), viteReact()],
|
|
88
|
+
})
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
// tsconfig.json
|
|
93
|
+
{
|
|
94
|
+
"compilerOptions": {
|
|
95
|
+
"target": "ES2022",
|
|
96
|
+
"module": "ESNext",
|
|
97
|
+
"moduleResolution": "bundler",
|
|
98
|
+
"strict": true,
|
|
99
|
+
"jsx": "react-jsx",
|
|
100
|
+
"paths": { "@/*": ["./src/*"] }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## 환경 변수 검증
|
|
27
106
|
|
|
28
107
|
```typescript
|
|
29
|
-
//
|
|
108
|
+
// lib/env.ts
|
|
109
|
+
import { z } from 'zod'
|
|
110
|
+
|
|
111
|
+
const envSchema = z.object({
|
|
112
|
+
NODE_ENV: z.enum(['development', 'production', 'test']),
|
|
113
|
+
DATABASE_URL: z.string().url(),
|
|
114
|
+
API_SECRET: z.string().min(32),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
export const env = envSchema.parse(process.env)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
</setup>
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
<server_functions>
|
|
125
|
+
|
|
126
|
+
## Server Functions
|
|
127
|
+
|
|
128
|
+
서버에서만 실행되는 타입 안전한 함수.
|
|
129
|
+
|
|
130
|
+
### 기본 패턴
|
|
131
|
+
|
|
132
|
+
| 메서드 | 사용 시점 | inputValidator | middleware |
|
|
133
|
+
|--------|----------|---------------|-----------|
|
|
134
|
+
| **GET** | 데이터 조회 | ❌ 선택 | ✅ 인증 시 필수 |
|
|
135
|
+
| **POST** | 데이터 생성 | ✅ 필수 | ✅ 인증 시 필수 |
|
|
136
|
+
| **PUT** | 전체 수정 | ✅ 필수 | ✅ 인증 시 필수 |
|
|
137
|
+
| **PATCH** | 부분 수정 | ✅ 필수 | ✅ 인증 시 필수 |
|
|
138
|
+
| **DELETE** | 삭제 | ❌ 선택 | ✅ 인증 시 필수 |
|
|
139
|
+
|
|
140
|
+
### GET: 데이터 조회
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
// ✅ 기본 GET
|
|
30
144
|
export const getUsers = createServerFn({ method: 'GET' })
|
|
145
|
+
.handler(async () => {
|
|
146
|
+
return prisma.user.findMany()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// ✅ GET + 인증
|
|
150
|
+
export const getMyProfile = createServerFn({ method: 'GET' })
|
|
31
151
|
.middleware([authMiddleware])
|
|
32
|
-
.handler(async () =>
|
|
152
|
+
.handler(async ({ context }) => {
|
|
153
|
+
return prisma.user.findUnique({
|
|
154
|
+
where: { id: context.user.id },
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// ✅ GET + Query Params (선택적 검증)
|
|
159
|
+
export const getUserById = createServerFn({ method: 'GET' })
|
|
160
|
+
.inputValidator(z.object({ id: z.string() }))
|
|
161
|
+
.handler(async ({ data }) => {
|
|
162
|
+
return prisma.user.findUnique({
|
|
163
|
+
where: { id: data.id },
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### POST: 데이터 생성
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
// ✅ POST + inputValidator 필수
|
|
172
|
+
const createUserSchema = z.object({
|
|
173
|
+
email: z.email(),
|
|
174
|
+
name: z.string().min(1).max(100).trim(),
|
|
175
|
+
age: z.number().int().min(0).optional(),
|
|
176
|
+
})
|
|
33
177
|
|
|
34
|
-
// POST + Validation + Auth
|
|
35
178
|
export const createUser = createServerFn({ method: 'POST' })
|
|
179
|
+
.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' })
|
|
36
187
|
.middleware([authMiddleware])
|
|
188
|
+
.inputValidator(z.object({
|
|
189
|
+
title: z.string().min(1).max(200),
|
|
190
|
+
content: z.string().min(1),
|
|
191
|
+
}))
|
|
192
|
+
.handler(async ({ data, context }) => {
|
|
193
|
+
return prisma.post.create({
|
|
194
|
+
data: {
|
|
195
|
+
...data,
|
|
196
|
+
authorId: context.user.id,
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// ❌ inputValidator 없이 POST (금지)
|
|
202
|
+
export const badCreate = createServerFn({ method: 'POST' })
|
|
203
|
+
.handler(async ({ data }) => {
|
|
204
|
+
// data 타입 불안전, 검증 없음
|
|
205
|
+
return prisma.user.create({ data })
|
|
206
|
+
})
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### PUT/PATCH: 데이터 수정
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
// ✅ PUT (전체 수정) + inputValidator 필수
|
|
213
|
+
const updateUserSchema = z.object({
|
|
214
|
+
id: z.string(),
|
|
215
|
+
email: z.email(),
|
|
216
|
+
name: z.string().min(1).max(100),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
export const updateUser = createServerFn({ method: 'PUT' })
|
|
220
|
+
.middleware([authMiddleware])
|
|
221
|
+
.inputValidator(updateUserSchema)
|
|
222
|
+
.handler(async ({ data }) => {
|
|
223
|
+
return prisma.user.update({
|
|
224
|
+
where: { id: data.id },
|
|
225
|
+
data: { email: data.email, name: data.name },
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// ✅ PATCH (부분 수정) + inputValidator 필수
|
|
230
|
+
const patchUserSchema = z.object({
|
|
231
|
+
id: z.string(),
|
|
232
|
+
name: z.string().min(1).max(100).optional(),
|
|
233
|
+
age: z.number().int().min(0).optional(),
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
export const patchUser = createServerFn({ method: 'PATCH' })
|
|
237
|
+
.middleware([authMiddleware])
|
|
238
|
+
.inputValidator(patchUserSchema)
|
|
239
|
+
.handler(async ({ data }) => {
|
|
240
|
+
const { id, ...updateData } = data
|
|
241
|
+
return prisma.user.update({
|
|
242
|
+
where: { id },
|
|
243
|
+
data: updateData,
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### DELETE: 데이터 삭제
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
// ✅ DELETE + 인증
|
|
252
|
+
export const deletePost = createServerFn({ method: 'DELETE' })
|
|
253
|
+
.middleware([authMiddleware])
|
|
254
|
+
.inputValidator(z.object({ id: z.string() }))
|
|
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
|
+
}
|
|
264
|
+
|
|
265
|
+
return prisma.post.delete({
|
|
266
|
+
where: { id: data.id },
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### 클라이언트에서 호출 (TanStack Query 필수)
|
|
272
|
+
|
|
273
|
+
```tsx
|
|
274
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
275
|
+
|
|
276
|
+
// ✅ useQuery (조회)
|
|
277
|
+
const UsersPage = (): JSX.Element => {
|
|
278
|
+
const { data, isLoading, error } = useQuery({
|
|
279
|
+
queryKey: ['users'],
|
|
280
|
+
queryFn: () => getUsers(),
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
if (isLoading) return <div>Loading...</div>
|
|
284
|
+
if (error) return <div>Error: {error.message}</div>
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<ul>
|
|
288
|
+
{data?.map((user) => (
|
|
289
|
+
<li key={user.id}>{user.name}</li>
|
|
290
|
+
))}
|
|
291
|
+
</ul>
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ✅ useMutation (생성/수정/삭제)
|
|
296
|
+
const CreateUserForm = (): JSX.Element => {
|
|
297
|
+
const queryClient = useQueryClient()
|
|
298
|
+
|
|
299
|
+
const mutation = useMutation({
|
|
300
|
+
mutationFn: createUser,
|
|
301
|
+
onSuccess: () => {
|
|
302
|
+
// 캐시 무효화 → 자동 리페치
|
|
303
|
+
queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
304
|
+
},
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
308
|
+
e.preventDefault()
|
|
309
|
+
const formData = new FormData(e.currentTarget)
|
|
310
|
+
|
|
311
|
+
mutation.mutate({
|
|
312
|
+
email: formData.get('email') as string,
|
|
313
|
+
name: formData.get('name') as string,
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<form onSubmit={handleSubmit}>
|
|
319
|
+
<input name="email" type="email" required />
|
|
320
|
+
<input name="name" required />
|
|
321
|
+
<button type="submit" disabled={mutation.isPending}>
|
|
322
|
+
{mutation.isPending ? 'Creating...' : 'Create'}
|
|
323
|
+
</button>
|
|
324
|
+
{mutation.error && <p>Error: {mutation.error.message}</p>}
|
|
325
|
+
</form>
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ❌ 직접 호출 금지 (캐싱 없음, 동기화 안됨)
|
|
330
|
+
const BadComponent = (): JSX.Element => {
|
|
331
|
+
const [users, setUsers] = useState([])
|
|
332
|
+
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
getUsers().then(setUsers) // ❌ 직접 호출
|
|
335
|
+
}, [])
|
|
336
|
+
|
|
337
|
+
return <div>{/* ... */}</div>
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### 함수 분리 규칙
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
// ❌ 잘못된 구조: 헬퍼 함수 export
|
|
345
|
+
export const validateUserData = async (email: string) => {
|
|
346
|
+
const exists = await prisma.user.findUnique({ where: { email } })
|
|
347
|
+
if (exists) throw new Error('Email already exists')
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export const createUser = createServerFn({ method: 'POST' })
|
|
37
351
|
.inputValidator(createUserSchema)
|
|
352
|
+
.handler(async ({ data }) => {
|
|
353
|
+
await validateUserData(data.email) // ❌ export된 헬퍼 사용
|
|
354
|
+
return prisma.user.create({ data })
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
// ✅ 올바른 구조: 헬퍼는 export 금지
|
|
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
|
+
}
|
|
362
|
+
|
|
363
|
+
export const createUser = createServerFn({ method: 'POST' })
|
|
364
|
+
.inputValidator(createUserSchema)
|
|
365
|
+
.handler(async ({ data }) => {
|
|
366
|
+
await validateUserData(data.email) // ✅ 내부 헬퍼만 사용
|
|
367
|
+
return prisma.user.create({ data })
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
// index.ts: Server Function만 export
|
|
371
|
+
export { getUsers, createUser, updateUser } from './user-functions'
|
|
372
|
+
// ❌ export { validateUserData } 금지
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### 보안: 환경 변수
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
// ❌ loader에서 환경변수 직접 사용 (클라이언트 노출)
|
|
379
|
+
export const Route = createFileRoute('/config')({
|
|
380
|
+
loader: () => {
|
|
381
|
+
const secret = process.env.API_SECRET // ❌ 클라이언트에 노출됨!
|
|
382
|
+
return { secret }
|
|
383
|
+
},
|
|
384
|
+
component: ConfigPage,
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
// ✅ Server Function에서만 사용
|
|
388
|
+
const getConfig = createServerFn({ method: 'GET' })
|
|
389
|
+
.middleware([authMiddleware])
|
|
390
|
+
.handler(async () => {
|
|
391
|
+
const secret = process.env.API_SECRET // ✅ 서버에서만 실행
|
|
392
|
+
return { secret }
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
export const Route = createFileRoute('/config')({
|
|
396
|
+
loader: async () => {
|
|
397
|
+
const config = await getConfig()
|
|
398
|
+
return config
|
|
399
|
+
},
|
|
400
|
+
component: ConfigPage,
|
|
401
|
+
})
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
</server_functions>
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
<middleware>
|
|
409
|
+
|
|
410
|
+
## Middleware
|
|
411
|
+
|
|
412
|
+
Server Function 및 라우트에 공통 로직 적용.
|
|
413
|
+
|
|
414
|
+
### 기본 패턴
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
// 미들웨어 정의
|
|
418
|
+
const loggingMiddleware = createMiddleware({ type: 'function' })
|
|
419
|
+
.server(({ next }) => {
|
|
420
|
+
console.log('Processing request')
|
|
421
|
+
return next()
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
// Server Function에 적용
|
|
425
|
+
const fn = createServerFn({ method: 'GET' })
|
|
426
|
+
.middleware([loggingMiddleware])
|
|
427
|
+
.handler(async () => ({ message: 'Hello' }))
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### 인증 미들웨어
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
// 세션 기반 인증
|
|
434
|
+
const authMiddleware = createMiddleware({ type: 'function' })
|
|
435
|
+
.server(async ({ next }) => {
|
|
436
|
+
const session = await getSession()
|
|
437
|
+
if (!session?.user) {
|
|
438
|
+
throw redirect({ to: '/login' })
|
|
439
|
+
}
|
|
440
|
+
return next({ context: { user: session.user } })
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// Server Function에 적용
|
|
444
|
+
export const getMyPosts = createServerFn({ method: 'GET' })
|
|
445
|
+
.middleware([authMiddleware])
|
|
446
|
+
.handler(async ({ context }) => {
|
|
447
|
+
return prisma.post.findMany({
|
|
448
|
+
where: { authorId: context.user.id },
|
|
449
|
+
})
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
// 권한 체크 미들웨어
|
|
453
|
+
const adminMiddleware = createMiddleware({ type: 'function' })
|
|
454
|
+
.server(async ({ next }) => {
|
|
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
|
+
})
|
|
461
|
+
|
|
462
|
+
// 적용
|
|
463
|
+
export const deleteAnyPost = createServerFn({ method: 'DELETE' })
|
|
464
|
+
.middleware([adminMiddleware])
|
|
465
|
+
.inputValidator(z.object({ id: z.string() }))
|
|
466
|
+
.handler(async ({ data }) => {
|
|
467
|
+
return prisma.post.delete({ where: { id: data.id } })
|
|
468
|
+
})
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### Zod Validation Middleware
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
// 재사용 가능한 검증 미들웨어
|
|
475
|
+
const workspaceMiddleware = createMiddleware({ type: 'function' })
|
|
476
|
+
.inputValidator(z.object({ workspaceId: z.string() }))
|
|
477
|
+
.server(async ({ next, data }) => {
|
|
478
|
+
const workspace = await prisma.workspace.findUnique({
|
|
479
|
+
where: { id: data.workspaceId },
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
if (!workspace) {
|
|
483
|
+
throw new Error('Workspace not found')
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return next({ context: { workspace } })
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
// 적용
|
|
490
|
+
export const getWorkspaceData = createServerFn({ method: 'GET' })
|
|
491
|
+
.middleware([authMiddleware, workspaceMiddleware])
|
|
492
|
+
.handler(async ({ context }) => {
|
|
493
|
+
return {
|
|
494
|
+
user: context.user,
|
|
495
|
+
workspace: context.workspace,
|
|
496
|
+
}
|
|
497
|
+
})
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### 미들웨어 체이닝
|
|
501
|
+
|
|
502
|
+
```typescript
|
|
503
|
+
// 여러 미들웨어 조합
|
|
504
|
+
export const protectedWorkspaceFn = createServerFn({ method: 'POST' })
|
|
505
|
+
.middleware([
|
|
506
|
+
loggingMiddleware, // 1. 로깅
|
|
507
|
+
authMiddleware, // 2. 인증
|
|
508
|
+
workspaceMiddleware, // 3. Workspace 검증
|
|
509
|
+
])
|
|
510
|
+
.inputValidator(taskSchema)
|
|
511
|
+
.handler(async ({ data, context }) => {
|
|
512
|
+
return prisma.task.create({
|
|
513
|
+
data: {
|
|
514
|
+
...data,
|
|
515
|
+
workspaceId: context.workspace.id,
|
|
516
|
+
createdById: context.user.id,
|
|
517
|
+
},
|
|
518
|
+
})
|
|
519
|
+
})
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
### Global Middleware
|
|
523
|
+
|
|
524
|
+
```typescript
|
|
525
|
+
// src/start.ts
|
|
526
|
+
export const startInstance = createStart(() => ({
|
|
527
|
+
requestMiddleware: [corsMiddleware], // 모든 요청
|
|
528
|
+
functionMiddleware: [loggingMiddleware], // 모든 Server Function
|
|
529
|
+
}))
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### Route-Level Middleware
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
// 특정 라우트에만 적용
|
|
536
|
+
export const Route = createFileRoute('/admin/dashboard')({
|
|
537
|
+
server: {
|
|
538
|
+
middleware: [adminMiddleware], // 이 라우트의 모든 핸들러에 적용
|
|
539
|
+
handlers: {
|
|
540
|
+
GET: async () => new Response('Admin Dashboard'),
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
})
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
</middleware>
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
550
|
+
<routing>
|
|
551
|
+
|
|
552
|
+
## Routing
|
|
553
|
+
|
|
554
|
+
File-based routing with TanStack Router.
|
|
555
|
+
|
|
556
|
+
### 파일 구조 → URL 매핑
|
|
557
|
+
|
|
558
|
+
| 파일 경로 | URL | 설명 |
|
|
559
|
+
|----------|-----|------|
|
|
560
|
+
| `routes/index.tsx` | `/` | 홈 페이지 |
|
|
561
|
+
| `routes/about.tsx` | `/about` | 정적 라우트 |
|
|
562
|
+
| `routes/users/$id.tsx` | `/users/:id` | 동적 파라미터 |
|
|
563
|
+
| `routes/users/index.tsx` | `/users` | 사용자 목록 |
|
|
564
|
+
| `routes/$.tsx` | `/*` | Catch-all (404) |
|
|
565
|
+
| `routes/__root.tsx` | - | Root layout |
|
|
566
|
+
|
|
567
|
+
### 기본 라우트
|
|
568
|
+
|
|
569
|
+
```tsx
|
|
570
|
+
// routes/about.tsx
|
|
571
|
+
export const Route = createFileRoute('/about')({
|
|
572
|
+
component: AboutPage,
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
const AboutPage = (): JSX.Element => {
|
|
576
|
+
return <div>About Page</div>
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### Loader: 데이터 사전 로드
|
|
581
|
+
|
|
582
|
+
```tsx
|
|
583
|
+
// routes/posts/index.tsx
|
|
584
|
+
export const Route = createFileRoute('/posts')({
|
|
585
|
+
component: PostsPage,
|
|
586
|
+
loader: async () => {
|
|
587
|
+
const posts = await getPosts()
|
|
588
|
+
return { posts }
|
|
589
|
+
},
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
const PostsPage = (): JSX.Element => {
|
|
593
|
+
const { posts } = Route.useLoaderData()
|
|
594
|
+
|
|
595
|
+
return (
|
|
596
|
+
<ul>
|
|
597
|
+
{posts.map((post) => (
|
|
598
|
+
<li key={post.id}>{post.title}</li>
|
|
599
|
+
))}
|
|
600
|
+
</ul>
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### 동적 라우트
|
|
606
|
+
|
|
607
|
+
```tsx
|
|
608
|
+
// routes/users/$id.tsx
|
|
609
|
+
export const Route = createFileRoute('/users/$id')({
|
|
610
|
+
loader: async ({ params }) => {
|
|
611
|
+
const user = await getUserById(params.id)
|
|
612
|
+
return { user }
|
|
613
|
+
},
|
|
614
|
+
component: UserPage,
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
const UserPage = (): JSX.Element => {
|
|
618
|
+
const { user } = Route.useLoaderData()
|
|
619
|
+
|
|
620
|
+
return (
|
|
621
|
+
<div>
|
|
622
|
+
<h1>{user.name}</h1>
|
|
623
|
+
<p>{user.email}</p>
|
|
624
|
+
</div>
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
### SSR 옵션
|
|
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
|
+
```
|
|
644
|
+
|
|
645
|
+
### beforeLoad: 라우트 접근 전 체크
|
|
646
|
+
|
|
647
|
+
```tsx
|
|
648
|
+
// routes/dashboard.tsx
|
|
649
|
+
export const Route = createFileRoute('/dashboard')({
|
|
650
|
+
beforeLoad: async () => {
|
|
651
|
+
const user = await getCurrentUser()
|
|
652
|
+
if (!user) {
|
|
653
|
+
throw redirect({ to: '/login' })
|
|
654
|
+
}
|
|
655
|
+
return { user }
|
|
656
|
+
},
|
|
657
|
+
component: DashboardPage,
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
const DashboardPage = (): JSX.Element => {
|
|
661
|
+
const { user } = Route.useRouteContext()
|
|
662
|
+
return <h1>Welcome, {user.name}!</h1>
|
|
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
|
+
},
|
|
681
|
+
},
|
|
682
|
+
})
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
### Catch-all Route (404)
|
|
686
|
+
|
|
687
|
+
```tsx
|
|
688
|
+
// routes/$.tsx
|
|
689
|
+
export const Route = createFileRoute('/$')({
|
|
690
|
+
component: NotFoundPage,
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
const NotFoundPage = (): JSX.Element => {
|
|
694
|
+
return (
|
|
695
|
+
<div>
|
|
696
|
+
<h1>404 - Page Not Found</h1>
|
|
697
|
+
<a href="/">Go Home</a>
|
|
698
|
+
</div>
|
|
699
|
+
)
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
</routing>
|
|
704
|
+
|
|
705
|
+
---
|
|
706
|
+
|
|
707
|
+
<auth_patterns>
|
|
708
|
+
|
|
709
|
+
## 인증 패턴
|
|
710
|
+
|
|
711
|
+
Better Auth 통합 및 인증 패턴.
|
|
712
|
+
|
|
713
|
+
### Better Auth 설정
|
|
714
|
+
|
|
715
|
+
```typescript
|
|
716
|
+
// lib/auth.ts
|
|
717
|
+
import { betterAuth } from 'better-auth'
|
|
718
|
+
import { prismaAdapter } from 'better-auth/adapters/prisma'
|
|
719
|
+
import { prisma } from '@/database/prisma'
|
|
720
|
+
|
|
721
|
+
export const auth = betterAuth({
|
|
722
|
+
database: prismaAdapter(prisma),
|
|
723
|
+
emailAndPassword: { enabled: true },
|
|
724
|
+
session: {
|
|
725
|
+
expiresIn: 60 * 60 * 24 * 7, // 7일
|
|
726
|
+
updateAge: 60 * 60 * 24, // 1일마다 갱신
|
|
727
|
+
},
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
export type Session = typeof auth.$Infer.Session
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
### 인증 Server Functions
|
|
734
|
+
|
|
735
|
+
```typescript
|
|
736
|
+
// functions/auth.ts
|
|
737
|
+
|
|
738
|
+
// 로그인
|
|
739
|
+
const loginSchema = z.object({
|
|
740
|
+
email: z.email(),
|
|
741
|
+
password: z.string().min(8),
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
export const login = createServerFn({ method: 'POST' })
|
|
745
|
+
.inputValidator(loginSchema)
|
|
746
|
+
.handler(async ({ data, request }) => {
|
|
747
|
+
const result = await auth.api.signInEmail({
|
|
748
|
+
email: data.email,
|
|
749
|
+
password: data.password,
|
|
750
|
+
headers: request.headers,
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
if (!result.user) {
|
|
754
|
+
throw new Error('Invalid credentials')
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
throw redirect({ to: '/dashboard' })
|
|
758
|
+
})
|
|
759
|
+
|
|
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
|
+
})
|
|
766
|
+
|
|
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
|
+
})
|
|
775
|
+
|
|
776
|
+
// 회원가입
|
|
777
|
+
const registerSchema = z.object({
|
|
778
|
+
email: z.email(),
|
|
779
|
+
password: z.string().min(8),
|
|
780
|
+
name: z.string().min(1),
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
export const register = createServerFn({ method: 'POST' })
|
|
784
|
+
.inputValidator(registerSchema)
|
|
785
|
+
.handler(async ({ data, request }) => {
|
|
786
|
+
const result = await auth.api.signUpEmail({
|
|
787
|
+
email: data.email,
|
|
788
|
+
password: data.password,
|
|
789
|
+
name: data.name,
|
|
790
|
+
headers: request.headers,
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
if (!result.user) {
|
|
794
|
+
throw new Error('Registration failed')
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
throw redirect({ to: '/dashboard' })
|
|
798
|
+
})
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
### 인증 미들웨어
|
|
802
|
+
|
|
803
|
+
```typescript
|
|
804
|
+
// middleware/auth.ts
|
|
805
|
+
export const authMiddleware = createMiddleware({ type: 'function' })
|
|
806
|
+
.server(async ({ next, request }) => {
|
|
807
|
+
const session = await auth.api.getSession({
|
|
808
|
+
headers: request.headers,
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
if (!session?.user) {
|
|
812
|
+
throw redirect({ to: '/login' })
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return next({ context: { user: session.user } })
|
|
816
|
+
})
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
### 보호된 Server Function
|
|
820
|
+
|
|
821
|
+
```typescript
|
|
822
|
+
// functions/posts.ts
|
|
823
|
+
export const getMyPosts = createServerFn({ method: 'GET' })
|
|
824
|
+
.middleware([authMiddleware])
|
|
825
|
+
.handler(async ({ context }) => {
|
|
826
|
+
return prisma.post.findMany({
|
|
827
|
+
where: { authorId: context.user.id },
|
|
828
|
+
})
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
export const createPost = createServerFn({ method: 'POST' })
|
|
832
|
+
.middleware([authMiddleware])
|
|
833
|
+
.inputValidator(z.object({
|
|
834
|
+
title: z.string().min(1),
|
|
835
|
+
content: z.string().min(1),
|
|
836
|
+
}))
|
|
837
|
+
.handler(async ({ data, context }) => {
|
|
838
|
+
return prisma.post.create({
|
|
839
|
+
data: {
|
|
840
|
+
...data,
|
|
841
|
+
authorId: context.user.id,
|
|
842
|
+
},
|
|
843
|
+
})
|
|
844
|
+
})
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
### 보호된 라우트
|
|
848
|
+
|
|
849
|
+
```tsx
|
|
850
|
+
// routes/dashboard.tsx
|
|
851
|
+
export const Route = createFileRoute('/dashboard')({
|
|
852
|
+
beforeLoad: async () => {
|
|
853
|
+
const user = await getCurrentUser()
|
|
854
|
+
if (!user) {
|
|
855
|
+
throw redirect({ to: '/login' })
|
|
856
|
+
}
|
|
857
|
+
return { user }
|
|
858
|
+
},
|
|
859
|
+
component: DashboardPage,
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
const DashboardPage = (): JSX.Element => {
|
|
863
|
+
const { user } = Route.useRouteContext()
|
|
864
|
+
|
|
865
|
+
return (
|
|
866
|
+
<div>
|
|
867
|
+
<h1>Welcome, {user.name}!</h1>
|
|
868
|
+
</div>
|
|
869
|
+
)
|
|
870
|
+
}
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
### 로그인 폼 (TanStack Query)
|
|
874
|
+
|
|
875
|
+
```tsx
|
|
876
|
+
// routes/login.tsx
|
|
877
|
+
const LoginPage = (): JSX.Element => {
|
|
878
|
+
const mutation = useMutation({
|
|
879
|
+
mutationFn: login,
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
883
|
+
e.preventDefault()
|
|
884
|
+
const formData = new FormData(e.currentTarget)
|
|
885
|
+
|
|
886
|
+
mutation.mutate({
|
|
887
|
+
email: formData.get('email') as string,
|
|
888
|
+
password: formData.get('password') as string,
|
|
889
|
+
})
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return (
|
|
893
|
+
<form onSubmit={handleSubmit}>
|
|
894
|
+
<input name="email" type="email" required />
|
|
895
|
+
<input name="password" type="password" required />
|
|
896
|
+
<button type="submit" disabled={mutation.isPending}>
|
|
897
|
+
{mutation.isPending ? 'Logging in...' : 'Login'}
|
|
898
|
+
</button>
|
|
899
|
+
{mutation.error && <p>Error: {mutation.error.message}</p>}
|
|
900
|
+
</form>
|
|
901
|
+
)
|
|
902
|
+
}
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
</auth_patterns>
|
|
906
|
+
|
|
907
|
+
---
|
|
908
|
+
|
|
909
|
+
<file_structure>
|
|
910
|
+
|
|
911
|
+
## 파일 구조
|
|
912
|
+
|
|
913
|
+
### 디렉터리 레이아웃
|
|
914
|
+
|
|
915
|
+
```
|
|
916
|
+
src/
|
|
917
|
+
├── routes/ # File-based routes
|
|
918
|
+
│ ├── __root.tsx # Root layout (모든 라우트의 부모)
|
|
919
|
+
│ ├── index.tsx # / (홈)
|
|
920
|
+
│ ├── about.tsx # /about
|
|
921
|
+
│ ├── users/
|
|
922
|
+
│ │ ├── index.tsx # /users
|
|
923
|
+
│ │ ├── $id.tsx # /users/:id
|
|
924
|
+
│ │ ├── -components/ # 페이지 전용 컴포넌트 (필수)
|
|
925
|
+
│ │ │ └── user-card.tsx
|
|
926
|
+
│ │ ├── -hooks/ # 페이지 전용 Custom Hooks (필수)
|
|
927
|
+
│ │ │ └── use-user-data.ts
|
|
928
|
+
│ │ └── -functions/ # 페이지 전용 Server Functions (필수)
|
|
929
|
+
│ │ └── user-mutations.ts
|
|
930
|
+
│ └── dashboard/
|
|
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
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
### 파일 분리 규칙
|
|
959
|
+
|
|
960
|
+
| 위치 | 사용 시점 | 예시 |
|
|
961
|
+
|------|----------|------|
|
|
962
|
+
| `routes/[path]/-components/` | 해당 페이지에서만 사용 | `user-card.tsx` |
|
|
963
|
+
| `routes/[path]/-hooks/` | 해당 페이지 전용 Hook | `use-user-data.ts` |
|
|
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` |
|
|
968
|
+
|
|
969
|
+
### 필수 규칙
|
|
970
|
+
|
|
971
|
+
✅ **페이지당 `-components/`, `-hooks/`, `-functions/` 폴더 필수**
|
|
972
|
+
- Custom Hook은 페이지 크기와 무관하게 **반드시** `-hooks/` 폴더에 분리
|
|
973
|
+
- 줄 수 무관: 10줄이든 100줄이든 분리 필수
|
|
974
|
+
|
|
975
|
+
✅ **공통 함수 → `@/functions/`**
|
|
976
|
+
- 여러 페이지에서 사용하는 Server Function
|
|
977
|
+
|
|
978
|
+
✅ **라우트 전용 → `routes/[경로]/-functions/`**
|
|
979
|
+
- 해당 라우트에서만 사용하는 Server Function
|
|
980
|
+
|
|
981
|
+
❌ **index.ts에서 내부 헬퍼 함수 export 금지**
|
|
982
|
+
- Server Function만 export
|
|
983
|
+
|
|
984
|
+
</file_structure>
|
|
985
|
+
|
|
986
|
+
---
|
|
987
|
+
|
|
988
|
+
<dos_donts>
|
|
989
|
+
|
|
990
|
+
## Do's & Don'ts
|
|
991
|
+
|
|
992
|
+
### Server Functions
|
|
993
|
+
|
|
994
|
+
| ✅ Do | ❌ Don't |
|
|
995
|
+
|-------|----------|
|
|
996
|
+
| `createServerFn({ method: 'POST' })` 사용 | 일반 async 함수를 API로 사용 |
|
|
997
|
+
| POST/PUT/PATCH에 `.inputValidator()` 필수 | handler 내부에서 수동 검증 |
|
|
998
|
+
| 인증 필요 시 `.middleware([authMiddleware])` | handler 내부에서 세션 체크 |
|
|
999
|
+
| `.inputValidator(zodSchema)` 사용 (v1) | `.validator()` 사용 (deprecated) |
|
|
1000
|
+
| 명시적 return type 선언 | any 타입 사용 |
|
|
1001
|
+
| 내부 헬퍼는 export 금지 | 헬퍼 함수 export |
|
|
1002
|
+
| Server Function에서만 `process.env` 사용 | loader에서 환경변수 직접 사용 |
|
|
1003
|
+
|
|
1004
|
+
### 예시: Server Functions
|
|
1005
|
+
|
|
1006
|
+
```typescript
|
|
1007
|
+
// ✅ 올바른 패턴
|
|
1008
|
+
const schema = z.object({
|
|
1009
|
+
email: z.email(),
|
|
1010
|
+
name: z.string().min(1),
|
|
1011
|
+
})
|
|
1012
|
+
|
|
1013
|
+
export const createUser = createServerFn({ method: 'POST' })
|
|
1014
|
+
.middleware([authMiddleware])
|
|
1015
|
+
.inputValidator(schema)
|
|
1016
|
+
.handler(async ({ data, context }): Promise<User> => {
|
|
1017
|
+
return prisma.user.create({
|
|
1018
|
+
data: {
|
|
1019
|
+
...data,
|
|
1020
|
+
createdBy: context.user.id,
|
|
1021
|
+
},
|
|
1022
|
+
})
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
// ❌ 잘못된 패턴
|
|
1026
|
+
export const badCreate = async (data: any) => { // ❌ createServerFn 없음, any 타입
|
|
1027
|
+
// ❌ 수동 검증
|
|
1028
|
+
if (!data.email || !data.name) {
|
|
1029
|
+
throw new Error('Invalid data')
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ❌ 수동 인증 체크
|
|
1033
|
+
const session = await getSession()
|
|
1034
|
+
if (!session) {
|
|
1035
|
+
throw new Error('Unauthorized')
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
return prisma.user.create({ data })
|
|
1039
|
+
}
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
### 클라이언트 호출
|
|
1043
|
+
|
|
1044
|
+
| ✅ Do | ❌ Don't |
|
|
1045
|
+
|-------|----------|
|
|
1046
|
+
| TanStack Query 사용 (`useQuery`/`useMutation`) | Server Function 직접 호출 |
|
|
1047
|
+
| `queryClient.invalidateQueries()` 로 동기화 | 수동 상태 관리 |
|
|
1048
|
+
| `isLoading`, `error` 상태 활용 | try-catch로 에러 처리 |
|
|
1049
|
+
| `queryKey`로 캐싱 관리 | useEffect + useState |
|
|
1050
|
+
|
|
1051
|
+
### 예시: 클라이언트 호출
|
|
1052
|
+
|
|
1053
|
+
```tsx
|
|
1054
|
+
// ✅ 올바른 패턴 (useQuery)
|
|
1055
|
+
const UsersPage = (): JSX.Element => {
|
|
1056
|
+
const { data, isLoading, error } = useQuery({
|
|
1057
|
+
queryKey: ['users'],
|
|
1058
|
+
queryFn: () => getUsers(),
|
|
1059
|
+
})
|
|
1060
|
+
|
|
1061
|
+
if (isLoading) return <div>Loading...</div>
|
|
1062
|
+
if (error) return <div>Error: {error.message}</div>
|
|
1063
|
+
|
|
1064
|
+
return <ul>{data?.map(u => <li key={u.id}>{u.name}</li>)}</ul>
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// ✅ 올바른 패턴 (useMutation)
|
|
1068
|
+
const CreateForm = (): JSX.Element => {
|
|
1069
|
+
const queryClient = useQueryClient()
|
|
1070
|
+
|
|
1071
|
+
const mutation = useMutation({
|
|
1072
|
+
mutationFn: createUser,
|
|
1073
|
+
onSuccess: () => {
|
|
1074
|
+
queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
1075
|
+
},
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
return (
|
|
1079
|
+
<form onSubmit={(e) => {
|
|
1080
|
+
e.preventDefault()
|
|
1081
|
+
const formData = new FormData(e.currentTarget)
|
|
1082
|
+
mutation.mutate({
|
|
1083
|
+
email: formData.get('email') as string,
|
|
1084
|
+
name: formData.get('name') as string,
|
|
1085
|
+
})
|
|
1086
|
+
}}>
|
|
1087
|
+
<input name="email" type="email" />
|
|
1088
|
+
<input name="name" />
|
|
1089
|
+
<button type="submit">Create</button>
|
|
1090
|
+
</form>
|
|
1091
|
+
)
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// ❌ 잘못된 패턴
|
|
1095
|
+
const BadComponent = (): JSX.Element => {
|
|
1096
|
+
const [users, setUsers] = useState([])
|
|
1097
|
+
const [loading, setLoading] = useState(false)
|
|
1098
|
+
|
|
1099
|
+
useEffect(() => {
|
|
1100
|
+
setLoading(true)
|
|
1101
|
+
getUsers() // ❌ 직접 호출 (캐싱 없음)
|
|
1102
|
+
.then(setUsers)
|
|
1103
|
+
.catch(console.error)
|
|
1104
|
+
.finally(() => setLoading(false))
|
|
1105
|
+
}, [])
|
|
1106
|
+
|
|
1107
|
+
return <div>{/* ... */}</div>
|
|
1108
|
+
}
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
### Middleware
|
|
1112
|
+
|
|
1113
|
+
| ✅ Do | ❌ Don't |
|
|
1114
|
+
|-------|----------|
|
|
1115
|
+
| 인증/권한 체크를 미들웨어로 분리 | handler 내부에서 체크 |
|
|
1116
|
+
| `context`로 데이터 전달 | 전역 변수 사용 |
|
|
1117
|
+
| 여러 미들웨어 체이닝 가능 | 하나의 미들웨어에 모든 로직 |
|
|
1118
|
+
|
|
1119
|
+
### 예시: Middleware
|
|
1120
|
+
|
|
1121
|
+
```typescript
|
|
1122
|
+
// ✅ 올바른 패턴
|
|
1123
|
+
const authMiddleware = createMiddleware({ type: 'function' })
|
|
1124
|
+
.server(async ({ next }) => {
|
|
1125
|
+
const session = await getSession()
|
|
1126
|
+
if (!session) throw redirect({ to: '/login' })
|
|
1127
|
+
return next({ context: { user: session.user } })
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
export const fn = createServerFn({ method: 'GET' })
|
|
1131
|
+
.middleware([authMiddleware])
|
|
1132
|
+
.handler(async ({ context }) => {
|
|
1133
|
+
return { user: context.user }
|
|
1134
|
+
})
|
|
1135
|
+
|
|
1136
|
+
// ❌ 잘못된 패턴
|
|
1137
|
+
export const badFn = createServerFn({ method: 'GET' })
|
|
1138
|
+
.handler(async () => {
|
|
1139
|
+
// ❌ handler 내부에서 인증 체크
|
|
1140
|
+
const session = await getSession()
|
|
1141
|
+
if (!session) throw redirect({ to: '/login' })
|
|
1142
|
+
|
|
1143
|
+
return { user: session.user }
|
|
1144
|
+
})
|
|
1145
|
+
```
|
|
1146
|
+
|
|
1147
|
+
### 파일 구조
|
|
1148
|
+
|
|
1149
|
+
| ✅ Do | ❌ Don't |
|
|
1150
|
+
|-------|----------|
|
|
1151
|
+
| 페이지 전용: `routes/[path]/-functions/` | 모든 함수를 `@/functions/`에 |
|
|
1152
|
+
| 공통 함수: `@/functions/` | 라우트 파일에 함수 직접 작성 |
|
|
1153
|
+
| Custom Hook: `-hooks/` 폴더에 분리 (필수) | 라우트 파일에 Hook 작성 |
|
|
1154
|
+
|
|
1155
|
+
### 예시: 파일 구조
|
|
1156
|
+
|
|
1157
|
+
```typescript
|
|
1158
|
+
// ✅ 올바른 구조
|
|
1159
|
+
// routes/users/-functions/user-mutations.ts
|
|
1160
|
+
export const createUser = createServerFn({ method: 'POST' })
|
|
1161
|
+
.inputValidator(schema)
|
|
38
1162
|
.handler(async ({ data }) => prisma.user.create({ data }))
|
|
39
1163
|
|
|
40
|
-
//
|
|
1164
|
+
// routes/users/-hooks/use-user-form.ts
|
|
1165
|
+
export const useUserForm = () => {
|
|
1166
|
+
const mutation = useMutation({ mutationFn: createUser })
|
|
1167
|
+
return { mutation }
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// routes/users/index.tsx
|
|
1171
|
+
import { useUserForm } from './-hooks/use-user-form'
|
|
1172
|
+
import { createUser } from './-functions/user-mutations'
|
|
1173
|
+
|
|
1174
|
+
// ❌ 잘못된 구조
|
|
1175
|
+
// routes/users/index.tsx (모든 로직이 한 파일에)
|
|
1176
|
+
const createUser = createServerFn({ method: 'POST' })
|
|
1177
|
+
.inputValidator(schema)
|
|
1178
|
+
.handler(async ({ data }) => prisma.user.create({ data }))
|
|
1179
|
+
|
|
1180
|
+
const useUserForm = () => {
|
|
1181
|
+
const mutation = useMutation({ mutationFn: createUser })
|
|
1182
|
+
return { mutation }
|
|
1183
|
+
}
|
|
1184
|
+
|
|
41
1185
|
export const Route = createFileRoute('/users')({
|
|
42
1186
|
component: UsersPage,
|
|
43
|
-
|
|
1187
|
+
})
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
### 환경 변수
|
|
1191
|
+
|
|
1192
|
+
| ✅ Do | ❌ Don't |
|
|
1193
|
+
|-------|----------|
|
|
1194
|
+
| Server Function에서만 `process.env` 사용 | loader에서 직접 사용 |
|
|
1195
|
+
| Zod로 환경 변수 검증 | 검증 없이 사용 |
|
|
1196
|
+
|
|
1197
|
+
### 예시: 환경 변수
|
|
1198
|
+
|
|
1199
|
+
```typescript
|
|
1200
|
+
// ✅ 올바른 패턴
|
|
1201
|
+
// lib/env.ts
|
|
1202
|
+
const envSchema = z.object({
|
|
1203
|
+
DATABASE_URL: z.string().url(),
|
|
1204
|
+
API_SECRET: z.string().min(32),
|
|
1205
|
+
})
|
|
1206
|
+
|
|
1207
|
+
export const env = envSchema.parse(process.env)
|
|
1208
|
+
|
|
1209
|
+
// functions/config.ts
|
|
1210
|
+
const getConfig = createServerFn({ method: 'GET' })
|
|
1211
|
+
.middleware([authMiddleware])
|
|
1212
|
+
.handler(async () => {
|
|
1213
|
+
return {
|
|
1214
|
+
secret: env.API_SECRET, // ✅ 서버에서만 실행
|
|
1215
|
+
}
|
|
1216
|
+
})
|
|
1217
|
+
|
|
1218
|
+
// ❌ 잘못된 패턴
|
|
1219
|
+
export const Route = createFileRoute('/config')({
|
|
1220
|
+
loader: () => {
|
|
1221
|
+
const secret = process.env.API_SECRET // ❌ 클라이언트에 노출됨!
|
|
1222
|
+
return { secret }
|
|
1223
|
+
},
|
|
1224
|
+
})
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
</dos_donts>
|
|
1228
|
+
|
|
1229
|
+
---
|
|
1230
|
+
|
|
1231
|
+
<quick_reference>
|
|
1232
|
+
|
|
1233
|
+
## Quick Reference
|
|
1234
|
+
|
|
1235
|
+
### GET: 데이터 조회
|
|
1236
|
+
|
|
1237
|
+
```typescript
|
|
1238
|
+
export const getUsers = createServerFn({ method: 'GET' })
|
|
1239
|
+
.handler(async () => prisma.user.findMany())
|
|
1240
|
+
|
|
1241
|
+
export const getMyProfile = createServerFn({ method: 'GET' })
|
|
1242
|
+
.middleware([authMiddleware])
|
|
1243
|
+
.handler(async ({ context }) => context.user)
|
|
1244
|
+
```
|
|
1245
|
+
|
|
1246
|
+
### POST: 데이터 생성 (inputValidator 필수)
|
|
1247
|
+
|
|
1248
|
+
```typescript
|
|
1249
|
+
const schema = z.object({
|
|
1250
|
+
email: z.email(),
|
|
1251
|
+
name: z.string().min(1),
|
|
1252
|
+
})
|
|
1253
|
+
|
|
1254
|
+
export const createUser = createServerFn({ method: 'POST' })
|
|
1255
|
+
.middleware([authMiddleware])
|
|
1256
|
+
.inputValidator(schema)
|
|
1257
|
+
.handler(async ({ data, context }) => {
|
|
1258
|
+
return prisma.user.create({
|
|
1259
|
+
data: { ...data, createdBy: context.user.id },
|
|
1260
|
+
})
|
|
1261
|
+
})
|
|
1262
|
+
```
|
|
1263
|
+
|
|
1264
|
+
### PUT/PATCH: 데이터 수정 (inputValidator 필수)
|
|
1265
|
+
|
|
1266
|
+
```typescript
|
|
1267
|
+
export const updateUser = createServerFn({ method: 'PUT' })
|
|
1268
|
+
.middleware([authMiddleware])
|
|
1269
|
+
.inputValidator(z.object({ id: z.string(), name: z.string() }))
|
|
1270
|
+
.handler(async ({ data }) => {
|
|
1271
|
+
return prisma.user.update({
|
|
1272
|
+
where: { id: data.id },
|
|
1273
|
+
data: { name: data.name },
|
|
1274
|
+
})
|
|
1275
|
+
})
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
### DELETE: 데이터 삭제
|
|
1279
|
+
|
|
1280
|
+
```typescript
|
|
1281
|
+
export const deletePost = createServerFn({ method: 'DELETE' })
|
|
1282
|
+
.middleware([authMiddleware])
|
|
1283
|
+
.inputValidator(z.object({ id: z.string() }))
|
|
1284
|
+
.handler(async ({ data }) => {
|
|
1285
|
+
return prisma.post.delete({ where: { id: data.id } })
|
|
1286
|
+
})
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
### Loader: 데이터 사전 로드
|
|
1290
|
+
|
|
1291
|
+
```typescript
|
|
1292
|
+
export const Route = createFileRoute('/users')({
|
|
1293
|
+
component: UsersPage,
|
|
1294
|
+
loader: async () => {
|
|
1295
|
+
const users = await getUsers()
|
|
1296
|
+
return { users }
|
|
1297
|
+
},
|
|
44
1298
|
})
|
|
45
1299
|
|
|
46
|
-
// Using Loader data
|
|
47
1300
|
const UsersPage = (): JSX.Element => {
|
|
48
1301
|
const { users } = Route.useLoaderData()
|
|
49
|
-
return <
|
|
1302
|
+
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
|
|
50
1303
|
}
|
|
51
1304
|
```
|
|
52
1305
|
|
|
53
|
-
###
|
|
1306
|
+
### TanStack Query: useQuery
|
|
54
1307
|
|
|
1308
|
+
```typescript
|
|
1309
|
+
const { data, isLoading, error } = useQuery({
|
|
1310
|
+
queryKey: ['posts'],
|
|
1311
|
+
queryFn: () => getPosts(),
|
|
1312
|
+
})
|
|
55
1313
|
```
|
|
56
|
-
routes/
|
|
57
|
-
├── __root.tsx # Root layout
|
|
58
|
-
├── index.tsx # /
|
|
59
|
-
├── users/
|
|
60
|
-
│ ├── index.tsx # /users
|
|
61
|
-
│ ├── $id.tsx # /users/:id
|
|
62
|
-
│ ├── -components/ # Page-specific
|
|
63
|
-
│ └── -functions/ # Page-specific Server Functions
|
|
64
1314
|
|
|
65
|
-
|
|
66
|
-
|
|
1315
|
+
### TanStack Query: useMutation
|
|
1316
|
+
|
|
1317
|
+
```typescript
|
|
1318
|
+
const queryClient = useQueryClient()
|
|
1319
|
+
|
|
1320
|
+
const mutation = useMutation({
|
|
1321
|
+
mutationFn: createPost,
|
|
1322
|
+
onSuccess: () => {
|
|
1323
|
+
queryClient.invalidateQueries({ queryKey: ['posts'] })
|
|
1324
|
+
},
|
|
1325
|
+
})
|
|
1326
|
+
|
|
1327
|
+
mutation.mutate({ title: 'New Post', content: 'Content' })
|
|
1328
|
+
```
|
|
1329
|
+
|
|
1330
|
+
### 인증 미들웨어
|
|
1331
|
+
|
|
1332
|
+
```typescript
|
|
1333
|
+
const authMiddleware = createMiddleware({ type: 'function' })
|
|
1334
|
+
.server(async ({ next }) => {
|
|
1335
|
+
const session = await getSession()
|
|
1336
|
+
if (!session) throw redirect({ to: '/login' })
|
|
1337
|
+
return next({ context: { user: session.user } })
|
|
1338
|
+
})
|
|
67
1339
|
```
|
|
1340
|
+
|
|
1341
|
+
### 보호된 라우트
|
|
1342
|
+
|
|
1343
|
+
```typescript
|
|
1344
|
+
export const Route = createFileRoute('/dashboard')({
|
|
1345
|
+
beforeLoad: async () => {
|
|
1346
|
+
const user = await getCurrentUser()
|
|
1347
|
+
if (!user) throw redirect({ to: '/login' })
|
|
1348
|
+
return { user }
|
|
1349
|
+
},
|
|
1350
|
+
component: DashboardPage,
|
|
1351
|
+
})
|
|
1352
|
+
```
|
|
1353
|
+
|
|
1354
|
+
</quick_reference>
|
|
1355
|
+
|
|
1356
|
+
---
|
|
1357
|
+
|
|
1358
|
+
<version_info>
|
|
1359
|
+
|
|
1360
|
+
**Version:** v1.x
|
|
1361
|
+
|
|
1362
|
+
**Key Changes in v1:**
|
|
1363
|
+
- `.inputValidator()` (v1) replaces `.validator()` (deprecated)
|
|
1364
|
+
- Enhanced middleware system with context passing
|
|
1365
|
+
- Improved type safety for Server Functions
|
|
1366
|
+
- Better TanStack Query integration
|
|
1367
|
+
|
|
1368
|
+
</version_info>
|