@kood/claude-code 0.3.6 → 0.3.7
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/nextjs/CLAUDE.md +228 -0
- package/templates/nextjs/docs/design.md +558 -0
- package/templates/nextjs/docs/guides/conventions.md +343 -0
- package/templates/nextjs/docs/guides/getting-started.md +367 -0
- package/templates/nextjs/docs/guides/routes.md +342 -0
- package/templates/nextjs/docs/library/better-auth/index.md +541 -0
- package/templates/nextjs/docs/library/nextjs/app-router.md +269 -0
- package/templates/nextjs/docs/library/nextjs/caching.md +351 -0
- package/templates/nextjs/docs/library/nextjs/index.md +291 -0
- package/templates/nextjs/docs/library/nextjs/middleware.md +391 -0
- package/templates/nextjs/docs/library/nextjs/route-handlers.md +382 -0
- package/templates/nextjs/docs/library/nextjs/server-actions.md +366 -0
- package/templates/nextjs/docs/library/prisma/cloudflare-d1.md +76 -0
- package/templates/nextjs/docs/library/prisma/config.md +77 -0
- package/templates/nextjs/docs/library/prisma/crud.md +90 -0
- package/templates/nextjs/docs/library/prisma/index.md +73 -0
- package/templates/nextjs/docs/library/prisma/relations.md +69 -0
- package/templates/nextjs/docs/library/prisma/schema.md +98 -0
- package/templates/nextjs/docs/library/prisma/setup.md +49 -0
- package/templates/nextjs/docs/library/prisma/transactions.md +50 -0
- package/templates/nextjs/docs/library/tanstack-query/index.md +66 -0
- package/templates/nextjs/docs/library/tanstack-query/invalidation.md +54 -0
- package/templates/nextjs/docs/library/tanstack-query/optimistic-updates.md +77 -0
- package/templates/nextjs/docs/library/tanstack-query/use-mutation.md +63 -0
- package/templates/nextjs/docs/library/tanstack-query/use-query.md +70 -0
- package/templates/nextjs/docs/library/zod/complex-types.md +61 -0
- package/templates/nextjs/docs/library/zod/index.md +56 -0
- package/templates/nextjs/docs/library/zod/transforms.md +51 -0
- package/templates/nextjs/docs/library/zod/validation.md +70 -0
- package/templates/tanstack-start/CLAUDE.md +7 -3
- package/templates/tanstack-start/docs/guides/hooks.md +28 -0
- package/templates/tanstack-start/docs/guides/routes.md +29 -10
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# Next.js - Index
|
|
2
|
+
|
|
3
|
+
> Next.js 15 App Router 핵심 개념
|
|
4
|
+
|
|
5
|
+
<context>
|
|
6
|
+
@app-router.md
|
|
7
|
+
@server-actions.md
|
|
8
|
+
@route-handlers.md
|
|
9
|
+
@middleware.md
|
|
10
|
+
@caching.md
|
|
11
|
+
</context>
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 핵심 개념
|
|
16
|
+
|
|
17
|
+
| 개념 | 설명 |
|
|
18
|
+
|------|------|
|
|
19
|
+
| **App Router** | 파일 기반 라우팅 (`app/` 디렉토리) |
|
|
20
|
+
| **Server Components** | 기본 컴포넌트 (서버 렌더링) |
|
|
21
|
+
| **Client Components** | `"use client"` 선언 필요 |
|
|
22
|
+
| **Server Actions** | `"use server"` 함수 (타입 안전 API) |
|
|
23
|
+
| **Route Handlers** | REST API 엔드포인트 (`app/api/`) |
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 빠른 시작
|
|
28
|
+
|
|
29
|
+
### 프로젝트 생성
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx create-next-app@latest my-app --typescript --tailwind --app
|
|
33
|
+
cd my-app
|
|
34
|
+
npm run dev
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 기본 페이지
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
// app/page.tsx (Server Component - 기본)
|
|
41
|
+
export default async function HomePage() {
|
|
42
|
+
const data = await fetch('https://api.example.com/data')
|
|
43
|
+
const json = await data.json()
|
|
44
|
+
|
|
45
|
+
return <div>{json.title}</div>
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Client Component
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
// app/_components/counter.tsx
|
|
53
|
+
"use client"
|
|
54
|
+
|
|
55
|
+
import { useState } from "react"
|
|
56
|
+
|
|
57
|
+
export function Counter() {
|
|
58
|
+
const [count, setCount] = useState(0)
|
|
59
|
+
return <button onClick={() => setCount(count + 1)}>{count}</button>
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 파일 구조
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
app/
|
|
69
|
+
├── layout.tsx # Root layout (필수)
|
|
70
|
+
├── page.tsx # Home (/)
|
|
71
|
+
├── about/
|
|
72
|
+
│ └── page.tsx # /about
|
|
73
|
+
├── blog/
|
|
74
|
+
│ ├── page.tsx # /blog
|
|
75
|
+
│ └── [slug]/
|
|
76
|
+
│ └── page.tsx # /blog/:slug
|
|
77
|
+
└── api/
|
|
78
|
+
└── posts/
|
|
79
|
+
└── route.ts # API /api/posts
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 주요 파일
|
|
85
|
+
|
|
86
|
+
| 파일 | 용도 |
|
|
87
|
+
|------|------|
|
|
88
|
+
| `layout.tsx` | 공통 레이아웃 (중첩 가능) |
|
|
89
|
+
| `page.tsx` | 페이지 컴포넌트 |
|
|
90
|
+
| `loading.tsx` | 로딩 UI (Suspense) |
|
|
91
|
+
| `error.tsx` | 에러 UI (Error Boundary) |
|
|
92
|
+
| `not-found.tsx` | 404 페이지 |
|
|
93
|
+
| `route.ts` | API 엔드포인트 |
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Server vs Client Components
|
|
98
|
+
|
|
99
|
+
| 구분 | Server | Client |
|
|
100
|
+
|------|--------|--------|
|
|
101
|
+
| 선언 | 기본 | `"use client"` |
|
|
102
|
+
| 데이터 페칭 | ✅ async/await | ❌ (useQuery 사용) |
|
|
103
|
+
| Hooks | ❌ | ✅ useState, useEffect |
|
|
104
|
+
| 브라우저 API | ❌ | ✅ window, localStorage |
|
|
105
|
+
| Event Handlers | ❌ | ✅ onClick, onChange |
|
|
106
|
+
|
|
107
|
+
**규칙:**
|
|
108
|
+
- Server Component가 기본 → Client Component 필요 시만 `"use client"` 추가
|
|
109
|
+
- Server Component 안에 Client Component 포함 가능
|
|
110
|
+
- Client Component 안에 Server Component 불가 (props로 전달은 가능)
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Server Actions
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// app/actions.ts
|
|
118
|
+
"use server"
|
|
119
|
+
|
|
120
|
+
import { z } from "zod"
|
|
121
|
+
import { revalidatePath } from "next/cache"
|
|
122
|
+
|
|
123
|
+
const schema = z.object({
|
|
124
|
+
title: z.string().min(1),
|
|
125
|
+
content: z.string(),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
export async function createPost(formData: FormData) {
|
|
129
|
+
const parsed = schema.parse({
|
|
130
|
+
title: formData.get("title"),
|
|
131
|
+
content: formData.get("content"),
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const post = await prisma.post.create({ data: parsed })
|
|
135
|
+
revalidatePath("/posts")
|
|
136
|
+
return post
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Route Handlers
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// app/api/posts/route.ts
|
|
146
|
+
import { NextRequest, NextResponse } from "next/server"
|
|
147
|
+
|
|
148
|
+
export async function GET(request: NextRequest) {
|
|
149
|
+
const posts = await prisma.post.findMany()
|
|
150
|
+
return NextResponse.json(posts)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function POST(request: NextRequest) {
|
|
154
|
+
const body = await request.json()
|
|
155
|
+
const post = await prisma.post.create({ data: body })
|
|
156
|
+
return NextResponse.json(post, { status: 201 })
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Middleware
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
// middleware.ts
|
|
166
|
+
import { NextResponse } from "next/server"
|
|
167
|
+
import type { NextRequest } from "next/server"
|
|
168
|
+
|
|
169
|
+
export function middleware(request: NextRequest) {
|
|
170
|
+
const token = request.cookies.get("token")
|
|
171
|
+
|
|
172
|
+
if (!token) {
|
|
173
|
+
return NextResponse.redirect(new URL("/login", request.url))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return NextResponse.next()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const config = {
|
|
180
|
+
matcher: ["/dashboard/:path*", "/profile/:path*"],
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 데이터 페칭
|
|
187
|
+
|
|
188
|
+
### Server Component (권장)
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
export default async function PostsPage() {
|
|
192
|
+
const posts = await prisma.post.findMany() // 직접 DB 접근
|
|
193
|
+
return <PostsList posts={posts} />
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Client Component (TanStack Query)
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
"use client"
|
|
201
|
+
|
|
202
|
+
import { useQuery } from "@tanstack/react-query"
|
|
203
|
+
|
|
204
|
+
export function PostsList() {
|
|
205
|
+
const { data } = useQuery({
|
|
206
|
+
queryKey: ["posts"],
|
|
207
|
+
queryFn: () => fetch("/api/posts").then(r => r.json()),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
return <ul>{data?.map(post => <li key={post.id}>{post.title}</li>)}</ul>
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## 캐싱
|
|
217
|
+
|
|
218
|
+
| 함수 | 용도 |
|
|
219
|
+
|------|------|
|
|
220
|
+
| `revalidatePath("/posts")` | 특정 경로 캐시 무효화 |
|
|
221
|
+
| `revalidateTag("posts")` | 태그 기반 캐시 무효화 |
|
|
222
|
+
| `unstable_cache()` | 함수 결과 캐싱 |
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
import { revalidatePath, revalidateTag } from "next/cache"
|
|
226
|
+
|
|
227
|
+
export async function createPost(data: PostInput) {
|
|
228
|
+
const post = await prisma.post.create({ data })
|
|
229
|
+
|
|
230
|
+
revalidatePath("/posts") // /posts 캐시 무효화
|
|
231
|
+
revalidateTag("posts") // "posts" 태그 캐시 무효화
|
|
232
|
+
|
|
233
|
+
return post
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## 환경 변수
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
# .env.local
|
|
243
|
+
DATABASE_URL="postgresql://..."
|
|
244
|
+
NEXTAUTH_SECRET="..."
|
|
245
|
+
NEXTAUTH_URL="http://localhost:3000"
|
|
246
|
+
NEXT_PUBLIC_API_URL="https://api.example.com"
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**규칙:**
|
|
250
|
+
- `NEXT_PUBLIC_*`: 클라이언트에서 접근 가능
|
|
251
|
+
- 나머지: 서버 전용
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## 배포
|
|
256
|
+
|
|
257
|
+
### Vercel (권장)
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
npm i -g vercel
|
|
261
|
+
vercel
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Docker
|
|
265
|
+
|
|
266
|
+
```dockerfile
|
|
267
|
+
FROM node:20-alpine
|
|
268
|
+
WORKDIR /app
|
|
269
|
+
COPY package*.json ./
|
|
270
|
+
RUN npm ci
|
|
271
|
+
COPY . .
|
|
272
|
+
RUN npm run build
|
|
273
|
+
CMD ["npm", "start"]
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Node.js
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
npm run build
|
|
280
|
+
npm start
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## 참조
|
|
286
|
+
|
|
287
|
+
- [App Router](app-router.md)
|
|
288
|
+
- [Server Actions](server-actions.md)
|
|
289
|
+
- [Route Handlers](route-handlers.md)
|
|
290
|
+
- [Middleware](middleware.md)
|
|
291
|
+
- [Caching](caching.md)
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# Middleware
|
|
2
|
+
|
|
3
|
+
> 요청 처리 전 실행되는 함수
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 기본 사용법
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// middleware.ts (루트)
|
|
11
|
+
import { NextResponse } from "next/server"
|
|
12
|
+
import type { NextRequest } from "next/server"
|
|
13
|
+
|
|
14
|
+
export function middleware(request: NextRequest) {
|
|
15
|
+
// 로직 실행...
|
|
16
|
+
return NextResponse.next()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 매처 설정
|
|
20
|
+
export const config = {
|
|
21
|
+
matcher: ["/dashboard/:path*", "/api/:path*"],
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Response 타입
|
|
28
|
+
|
|
29
|
+
### NextResponse.next()
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
// 요청을 다음 미들웨어 또는 라우트로 전달
|
|
33
|
+
export function middleware(request: NextRequest) {
|
|
34
|
+
return NextResponse.next()
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### NextResponse.redirect()
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// 다른 URL로 리다이렉트
|
|
42
|
+
export function middleware(request: NextRequest) {
|
|
43
|
+
return NextResponse.redirect(new URL("/login", request.url))
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### NextResponse.rewrite()
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// URL은 유지하되 다른 페이지 렌더링
|
|
51
|
+
export function middleware(request: NextRequest) {
|
|
52
|
+
return NextResponse.rewrite(new URL("/dashboard/home", request.url))
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 인증
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import { NextResponse } from "next/server"
|
|
62
|
+
import type { NextRequest } from "next/server"
|
|
63
|
+
|
|
64
|
+
export function middleware(request: NextRequest) {
|
|
65
|
+
const token = request.cookies.get("token")
|
|
66
|
+
|
|
67
|
+
// 토큰 없으면 로그인 페이지로
|
|
68
|
+
if (!token) {
|
|
69
|
+
return NextResponse.redirect(new URL("/login", request.url))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return NextResponse.next()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const config = {
|
|
76
|
+
matcher: ["/dashboard/:path*", "/profile/:path*"],
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 쿠키 처리
|
|
83
|
+
|
|
84
|
+
### 읽기
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
export function middleware(request: NextRequest) {
|
|
88
|
+
const token = request.cookies.get("token")
|
|
89
|
+
const userId = request.cookies.get("userId")
|
|
90
|
+
|
|
91
|
+
console.log({ token, userId })
|
|
92
|
+
|
|
93
|
+
return NextResponse.next()
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 설정
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
export function middleware(request: NextRequest) {
|
|
101
|
+
const response = NextResponse.next()
|
|
102
|
+
|
|
103
|
+
response.cookies.set("visited", "true", {
|
|
104
|
+
httpOnly: true,
|
|
105
|
+
secure: true,
|
|
106
|
+
maxAge: 60 * 60 * 24 * 7, // 7일
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
return response
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Headers 처리
|
|
116
|
+
|
|
117
|
+
### 읽기
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
export function middleware(request: NextRequest) {
|
|
121
|
+
const userAgent = request.headers.get("user-agent")
|
|
122
|
+
const authorization = request.headers.get("authorization")
|
|
123
|
+
|
|
124
|
+
console.log({ userAgent, authorization })
|
|
125
|
+
|
|
126
|
+
return NextResponse.next()
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 설정
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
export function middleware(request: NextRequest) {
|
|
134
|
+
const response = NextResponse.next()
|
|
135
|
+
|
|
136
|
+
response.headers.set("X-Custom-Header", "value")
|
|
137
|
+
response.headers.set("X-Request-Id", crypto.randomUUID())
|
|
138
|
+
|
|
139
|
+
return response
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 경로별 처리
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
export function middleware(request: NextRequest) {
|
|
149
|
+
const { pathname } = request.nextUrl
|
|
150
|
+
|
|
151
|
+
// /api/* 경로
|
|
152
|
+
if (pathname.startsWith("/api/")) {
|
|
153
|
+
const token = request.headers.get("authorization")
|
|
154
|
+
|
|
155
|
+
if (!token) {
|
|
156
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// /admin/* 경로
|
|
161
|
+
if (pathname.startsWith("/admin/")) {
|
|
162
|
+
const role = request.cookies.get("role")?.value
|
|
163
|
+
|
|
164
|
+
if (role !== "admin") {
|
|
165
|
+
return NextResponse.redirect(new URL("/", request.url))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return NextResponse.next()
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Matcher 설정
|
|
176
|
+
|
|
177
|
+
### 배열
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
export const config = {
|
|
181
|
+
matcher: ["/dashboard/:path*", "/profile/:path*"],
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### 정규식
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
export const config = {
|
|
189
|
+
matcher: [
|
|
190
|
+
/*
|
|
191
|
+
* 다음 경로 제외:
|
|
192
|
+
* - _next/static (정적 파일)
|
|
193
|
+
* - _next/image (이미지 최적화)
|
|
194
|
+
* - favicon.ico (파비콘)
|
|
195
|
+
*/
|
|
196
|
+
"/((?!_next/static|_next/image|favicon.ico).*)",
|
|
197
|
+
],
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### 조건부
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
export const config = {
|
|
205
|
+
matcher: [
|
|
206
|
+
"/dashboard/:path*",
|
|
207
|
+
{
|
|
208
|
+
source: "/api/:path*",
|
|
209
|
+
has: [
|
|
210
|
+
{ type: "header", key: "authorization" },
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## 로깅
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
export function middleware(request: NextRequest) {
|
|
223
|
+
const start = Date.now()
|
|
224
|
+
|
|
225
|
+
const response = NextResponse.next()
|
|
226
|
+
|
|
227
|
+
const duration = Date.now() - start
|
|
228
|
+
|
|
229
|
+
console.log({
|
|
230
|
+
method: request.method,
|
|
231
|
+
url: request.url,
|
|
232
|
+
duration: `${duration}ms`,
|
|
233
|
+
status: response.status,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
return response
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Rate Limiting
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
import { NextResponse } from "next/server"
|
|
246
|
+
import type { NextRequest } from "next/server"
|
|
247
|
+
|
|
248
|
+
const rateLimit = new Map<string, { count: number; resetAt: number }>()
|
|
249
|
+
|
|
250
|
+
const LIMIT = 10 // 10 requests
|
|
251
|
+
const WINDOW = 60 * 1000 // 1분
|
|
252
|
+
|
|
253
|
+
export function middleware(request: NextRequest) {
|
|
254
|
+
const ip = request.ip || "unknown"
|
|
255
|
+
const now = Date.now()
|
|
256
|
+
|
|
257
|
+
const record = rateLimit.get(ip)
|
|
258
|
+
|
|
259
|
+
if (!record || now > record.resetAt) {
|
|
260
|
+
rateLimit.set(ip, { count: 1, resetAt: now + WINDOW })
|
|
261
|
+
return NextResponse.next()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (record.count >= LIMIT) {
|
|
265
|
+
return NextResponse.json(
|
|
266
|
+
{ error: "Too many requests" },
|
|
267
|
+
{ status: 429 }
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
record.count++
|
|
272
|
+
return NextResponse.next()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export const config = {
|
|
276
|
+
matcher: "/api/:path*",
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Geolocation
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
export function middleware(request: NextRequest) {
|
|
286
|
+
const country = request.geo?.country || "US"
|
|
287
|
+
const city = request.geo?.city || "Unknown"
|
|
288
|
+
|
|
289
|
+
const response = NextResponse.next()
|
|
290
|
+
response.headers.set("X-Country", country)
|
|
291
|
+
response.headers.set("X-City", city)
|
|
292
|
+
|
|
293
|
+
return response
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## A/B 테스팅
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
import { NextResponse } from "next/server"
|
|
303
|
+
import type { NextRequest } from "next/server"
|
|
304
|
+
|
|
305
|
+
export function middleware(request: NextRequest) {
|
|
306
|
+
const bucket = request.cookies.get("bucket")
|
|
307
|
+
|
|
308
|
+
if (!bucket) {
|
|
309
|
+
const newBucket = Math.random() > 0.5 ? "A" : "B"
|
|
310
|
+
const response = NextResponse.next()
|
|
311
|
+
|
|
312
|
+
response.cookies.set("bucket", newBucket)
|
|
313
|
+
|
|
314
|
+
if (newBucket === "B") {
|
|
315
|
+
return NextResponse.rewrite(new URL("/variant-b", request.url))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return response
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (bucket.value === "B") {
|
|
322
|
+
return NextResponse.rewrite(new URL("/variant-b", request.url))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return NextResponse.next()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export const config = {
|
|
329
|
+
matcher: "/",
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## 베스트 프랙티스
|
|
336
|
+
|
|
337
|
+
### ✅ DO
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
// 1. 가벼운 로직만
|
|
341
|
+
export function middleware(request: NextRequest) {
|
|
342
|
+
const token = request.cookies.get("token")
|
|
343
|
+
|
|
344
|
+
if (!token) {
|
|
345
|
+
return NextResponse.redirect(new URL("/login", request.url))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return NextResponse.next()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 2. matcher 설정
|
|
352
|
+
export const config = {
|
|
353
|
+
matcher: ["/dashboard/:path*"],
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### ❌ DON'T
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
// 1. 무거운 DB 쿼리
|
|
361
|
+
export async function middleware(request: NextRequest) {
|
|
362
|
+
// ❌ 미들웨어에서 DB 쿼리 금지
|
|
363
|
+
const user = await prisma.user.findUnique({ where: { id: "..." } })
|
|
364
|
+
return NextResponse.next()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 2. matcher 없이 모든 요청 처리
|
|
368
|
+
export function middleware(request: NextRequest) {
|
|
369
|
+
// ❌ 성능 저하
|
|
370
|
+
return NextResponse.next()
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## NextAuth.js와 함께 사용
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
// middleware.ts
|
|
380
|
+
export { default } from "next-auth/middleware"
|
|
381
|
+
|
|
382
|
+
export const config = {
|
|
383
|
+
matcher: ["/dashboard/:path*", "/profile/:path*"],
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
## 참조
|
|
390
|
+
|
|
391
|
+
- [Next.js Middleware](https://nextjs.org/docs/app/building-your-application/routing/middleware)
|