@girardmedia/bootspring 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/agents/README.md +93 -0
- package/agents/api-expert/context.md +416 -0
- package/agents/architecture-expert/context.md +454 -0
- package/agents/backend-expert/context.md +483 -0
- package/agents/code-review-expert/context.md +365 -0
- package/agents/database-expert/context.md +250 -0
- package/agents/devops-expert/context.md +446 -0
- package/agents/frontend-expert/context.md +364 -0
- package/agents/index.js +140 -0
- package/agents/performance-expert/context.md +377 -0
- package/agents/security-expert/context.md +343 -0
- package/agents/testing-expert/context.md +414 -0
- package/agents/ui-ux-expert/context.md +448 -0
- package/agents/vercel-expert/context.md +426 -0
- package/bin/bootspring.js +310 -0
- package/cli/agent.js +337 -0
- package/cli/context.js +194 -0
- package/cli/dashboard.js +150 -0
- package/cli/generate.js +294 -0
- package/cli/init.js +410 -0
- package/cli/loop.js +421 -0
- package/cli/mcp.js +241 -0
- package/cli/memory.js +303 -0
- package/cli/orchestrator.js +400 -0
- package/cli/plugin.js +451 -0
- package/cli/quality.js +332 -0
- package/cli/skill.js +369 -0
- package/cli/task.js +628 -0
- package/cli/telemetry.js +114 -0
- package/cli/todo.js +614 -0
- package/cli/update.js +312 -0
- package/core/config.js +245 -0
- package/core/context.js +329 -0
- package/core/entitlements.js +209 -0
- package/core/index.js +43 -0
- package/core/policies.js +68 -0
- package/core/telemetry.js +247 -0
- package/core/utils.js +380 -0
- package/dashboard/server.js +818 -0
- package/docs/integrations/claude-code.md +42 -0
- package/docs/integrations/codex.md +42 -0
- package/docs/mcp-api-platform.md +102 -0
- package/generators/generate.js +598 -0
- package/generators/index.js +18 -0
- package/hooks/context-detector.js +177 -0
- package/hooks/index.js +35 -0
- package/hooks/prompt-enhancer.js +289 -0
- package/intelligence/git-memory.js +551 -0
- package/intelligence/index.js +59 -0
- package/intelligence/orchestrator.js +964 -0
- package/intelligence/prd.js +447 -0
- package/intelligence/recommendation-weights.json +18 -0
- package/intelligence/recommendations.js +234 -0
- package/mcp/capabilities.js +71 -0
- package/mcp/contracts/mcp-contract.v1.json +497 -0
- package/mcp/registry.js +213 -0
- package/mcp/response-formatter.js +462 -0
- package/mcp/server.js +99 -0
- package/mcp/tools/agent-tool.js +137 -0
- package/mcp/tools/capabilities-tool.js +54 -0
- package/mcp/tools/context-tool.js +49 -0
- package/mcp/tools/dashboard-tool.js +58 -0
- package/mcp/tools/generate-tool.js +46 -0
- package/mcp/tools/loop-tool.js +134 -0
- package/mcp/tools/memory-tool.js +180 -0
- package/mcp/tools/orchestrator-tool.js +232 -0
- package/mcp/tools/plugin-tool.js +76 -0
- package/mcp/tools/quality-tool.js +47 -0
- package/mcp/tools/skill-tool.js +233 -0
- package/mcp/tools/telemetry-tool.js +95 -0
- package/mcp/tools/todo-tool.js +133 -0
- package/package.json +98 -0
- package/plugins/index.js +141 -0
- package/quality/index.js +380 -0
- package/quality/lint-budgets.json +19 -0
- package/skills/index.js +787 -0
- package/skills/patterns/README.md +163 -0
- package/skills/patterns/api/route-handler.md +217 -0
- package/skills/patterns/api/server-action.md +249 -0
- package/skills/patterns/auth/clerk.md +132 -0
- package/skills/patterns/database/prisma.md +180 -0
- package/skills/patterns/payments/stripe.md +272 -0
- package/skills/patterns/security/validation.md +268 -0
- package/skills/patterns/testing/vitest.md +307 -0
- package/templates/bootspring.config.js +83 -0
- package/templates/mcp.json +9 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Bootspring Skill Patterns
|
|
2
|
+
|
|
3
|
+
Battle-tested code patterns for common development tasks.
|
|
4
|
+
|
|
5
|
+
## Available Patterns
|
|
6
|
+
|
|
7
|
+
### Authentication
|
|
8
|
+
| Pattern | Description |
|
|
9
|
+
|---------|-------------|
|
|
10
|
+
| [auth/clerk](./auth/clerk.md) | Clerk authentication - server auth, middleware, user sync |
|
|
11
|
+
|
|
12
|
+
### Database
|
|
13
|
+
| Pattern | Description |
|
|
14
|
+
|---------|-------------|
|
|
15
|
+
| [database/prisma](./database/prisma.md) | Prisma ORM - client setup, queries, transactions, migrations |
|
|
16
|
+
|
|
17
|
+
### API Development
|
|
18
|
+
| Pattern | Description |
|
|
19
|
+
|---------|-------------|
|
|
20
|
+
| [api/route-handler](./api/route-handler.md) | Next.js route handlers - CRUD, error handling, rate limiting |
|
|
21
|
+
| [api/server-action](./api/server-action.md) | Server actions - forms, optimistic updates, useActionState |
|
|
22
|
+
|
|
23
|
+
### Payments
|
|
24
|
+
| Pattern | Description |
|
|
25
|
+
|---------|-------------|
|
|
26
|
+
| [payments/stripe](./payments/stripe.md) | Stripe - checkout, subscriptions, webhooks, customer portal |
|
|
27
|
+
|
|
28
|
+
### Security
|
|
29
|
+
| Pattern | Description |
|
|
30
|
+
|---------|-------------|
|
|
31
|
+
| [security/validation](./security/validation.md) | Input validation - Zod schemas, sanitization, CSRF, rate limiting |
|
|
32
|
+
|
|
33
|
+
### Testing
|
|
34
|
+
| Pattern | Description |
|
|
35
|
+
|---------|-------------|
|
|
36
|
+
| [testing/vitest](./testing/vitest.md) | Vitest - setup, unit tests, component tests, mocking |
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
### Via CLI
|
|
41
|
+
```bash
|
|
42
|
+
# List all patterns
|
|
43
|
+
bootspring skill list
|
|
44
|
+
|
|
45
|
+
# Include curated external skills catalog (skills/external)
|
|
46
|
+
bootspring skill list --external
|
|
47
|
+
|
|
48
|
+
# Search for patterns
|
|
49
|
+
bootspring skill search "auth"
|
|
50
|
+
bootspring skill search "vercel" --external
|
|
51
|
+
|
|
52
|
+
# Show a specific pattern
|
|
53
|
+
bootspring skill show auth/clerk
|
|
54
|
+
|
|
55
|
+
# Show concise summary
|
|
56
|
+
bootspring skill show auth/clerk --summary
|
|
57
|
+
|
|
58
|
+
# Show only matching sections and cap output size
|
|
59
|
+
bootspring skill show api/route-handler --sections "basic crud,error handling" --max-chars 1200
|
|
60
|
+
|
|
61
|
+
# Show an external skill by id
|
|
62
|
+
bootspring skill show external/vercel-automation
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Via MCP Tool
|
|
66
|
+
```text
|
|
67
|
+
bootspring_skill { action: "show", name: "auth/clerk" }
|
|
68
|
+
bootspring_skill { action: "show", name: "auth/clerk", summary: true }
|
|
69
|
+
bootspring_skill { action: "show", name: "api/route-handler", sections: "basic crud", maxChars: 1200 }
|
|
70
|
+
bootspring_skill { action: "search", query: "database" }
|
|
71
|
+
bootspring_skill { action: "list", includeExternal: true, limit: 20 }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Direct Reference
|
|
75
|
+
Patterns are markdown files with code blocks. Reference them in prompts:
|
|
76
|
+
```text
|
|
77
|
+
Using the pattern from bootspring/skills/patterns/api/server-action.md,
|
|
78
|
+
implement a server action for updating user profile.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Pattern Structure
|
|
82
|
+
|
|
83
|
+
Each pattern includes:
|
|
84
|
+
- **Setup code** - Configuration and initialization
|
|
85
|
+
- **Common patterns** - Frequently used implementations
|
|
86
|
+
- **Best practices** - Security and performance considerations
|
|
87
|
+
- **When to use** - Guidance on appropriate use cases
|
|
88
|
+
|
|
89
|
+
## Adding Custom Patterns
|
|
90
|
+
|
|
91
|
+
Create a markdown file in the appropriate category:
|
|
92
|
+
```text
|
|
93
|
+
skills/patterns/
|
|
94
|
+
├── auth/
|
|
95
|
+
│ └── my-custom-auth.md
|
|
96
|
+
├── database/
|
|
97
|
+
├── api/
|
|
98
|
+
└── ...
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Pattern template:
|
|
102
|
+
```markdown
|
|
103
|
+
# Pattern Name
|
|
104
|
+
|
|
105
|
+
Brief description.
|
|
106
|
+
|
|
107
|
+
## Setup
|
|
108
|
+
|
|
109
|
+
~~~typescript
|
|
110
|
+
// Configuration code
|
|
111
|
+
~~~
|
|
112
|
+
|
|
113
|
+
## Common Patterns
|
|
114
|
+
|
|
115
|
+
~~~typescript
|
|
116
|
+
// Implementation examples
|
|
117
|
+
~~~
|
|
118
|
+
|
|
119
|
+
## When to Use
|
|
120
|
+
|
|
121
|
+
- Use case 1
|
|
122
|
+
- Use case 2
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## External Catalog (Curated)
|
|
126
|
+
|
|
127
|
+
If `skills/external/` exists, Bootspring indexes it as an optional external catalog:
|
|
128
|
+
|
|
129
|
+
- `bootspring skill list --external`
|
|
130
|
+
- `bootspring skill search <query> --external`
|
|
131
|
+
- `bootspring skill show external/<skill-id>`
|
|
132
|
+
|
|
133
|
+
This catalog is optional. If you want a leaner repo, you can remove `skills/external/` without affecting built-in patterns.
|
|
134
|
+
|
|
135
|
+
### Entitlement Policy
|
|
136
|
+
|
|
137
|
+
External skills are checked through `core/entitlements.js`:
|
|
138
|
+
|
|
139
|
+
- `BOOTSPRING_SKILL_ACCESS_MODE=local` (default): external skills are accessible.
|
|
140
|
+
- `BOOTSPRING_SKILL_ACCESS_MODE=server`: external skills require entitlement.
|
|
141
|
+
- Entitlement in server mode is granted by either:
|
|
142
|
+
- `BOOTSPRING_SKILLS_ENTITLED=true`
|
|
143
|
+
- `BOOTSPRING_USER_TIER=pro|team|enterprise`
|
|
144
|
+
|
|
145
|
+
### Remote Catalog Sync
|
|
146
|
+
|
|
147
|
+
For lightweight npm installs, sync protected skills from your backend into local cache:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
bootspring skill sync \
|
|
151
|
+
--manifest-url https://api.bootspring.com/skills/manifest.json \
|
|
152
|
+
--content-base-url https://api.bootspring.com/skills/content
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Relevant environment variables:
|
|
156
|
+
|
|
157
|
+
- `BOOTSPRING_SKILL_MANIFEST_URL`
|
|
158
|
+
- `BOOTSPRING_SKILL_CONTENT_BASE_URL`
|
|
159
|
+
- `BOOTSPRING_SKILL_TOKEN`
|
|
160
|
+
- `BOOTSPRING_SKILL_CACHE_DIR`
|
|
161
|
+
- `BOOTSPRING_SKILL_CATALOG_SOURCE=auto|cache|local`
|
|
162
|
+
- `BOOTSPRING_SKILL_MANIFEST_PUBLIC_KEY`
|
|
163
|
+
- `BOOTSPRING_SKILL_MANIFEST_REQUIRE_SIGNATURE=true|false`
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Next.js Route Handler Patterns
|
|
2
|
+
|
|
3
|
+
Battle-tested patterns for API routes in Next.js App Router.
|
|
4
|
+
|
|
5
|
+
## Basic CRUD Route Handler
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// app/api/posts/route.ts
|
|
9
|
+
import { prisma } from '@/lib/db'
|
|
10
|
+
import { auth } from '@/lib/auth'
|
|
11
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
|
|
14
|
+
const CreatePostSchema = z.object({
|
|
15
|
+
title: z.string().min(1).max(200),
|
|
16
|
+
content: z.string().optional(),
|
|
17
|
+
published: z.boolean().default(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// GET /api/posts
|
|
21
|
+
export async function GET(request: NextRequest) {
|
|
22
|
+
const searchParams = request.nextUrl.searchParams
|
|
23
|
+
const page = parseInt(searchParams.get('page') ?? '1')
|
|
24
|
+
const limit = parseInt(searchParams.get('limit') ?? '10')
|
|
25
|
+
|
|
26
|
+
const posts = await prisma.post.findMany({
|
|
27
|
+
where: { published: true },
|
|
28
|
+
orderBy: { createdAt: 'desc' },
|
|
29
|
+
skip: (page - 1) * limit,
|
|
30
|
+
take: limit,
|
|
31
|
+
include: { author: { select: { name: true } } }
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return NextResponse.json({ posts, page, limit })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// POST /api/posts
|
|
38
|
+
export async function POST(request: NextRequest) {
|
|
39
|
+
try {
|
|
40
|
+
const { userId } = await auth()
|
|
41
|
+
if (!userId) {
|
|
42
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const body = await request.json()
|
|
46
|
+
const validated = CreatePostSchema.parse(body)
|
|
47
|
+
|
|
48
|
+
const post = await prisma.post.create({
|
|
49
|
+
data: { ...validated, authorId: userId }
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return NextResponse.json(post, { status: 201 })
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error instanceof z.ZodError) {
|
|
55
|
+
return NextResponse.json({ error: error.errors }, { status: 400 })
|
|
56
|
+
}
|
|
57
|
+
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Dynamic Route Handler
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// app/api/posts/[id]/route.ts
|
|
66
|
+
import { prisma } from '@/lib/db'
|
|
67
|
+
import { auth } from '@/lib/auth'
|
|
68
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
69
|
+
|
|
70
|
+
type Params = { params: Promise<{ id: string }> }
|
|
71
|
+
|
|
72
|
+
// GET /api/posts/:id
|
|
73
|
+
export async function GET(request: NextRequest, { params }: Params) {
|
|
74
|
+
const { id } = await params
|
|
75
|
+
|
|
76
|
+
const post = await prisma.post.findUnique({
|
|
77
|
+
where: { id },
|
|
78
|
+
include: { author: { select: { name: true, email: true } } }
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if (!post) {
|
|
82
|
+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return NextResponse.json(post)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// PATCH /api/posts/:id
|
|
89
|
+
export async function PATCH(request: NextRequest, { params }: Params) {
|
|
90
|
+
const { userId } = await auth()
|
|
91
|
+
if (!userId) {
|
|
92
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { id } = await params
|
|
96
|
+
const body = await request.json()
|
|
97
|
+
|
|
98
|
+
const post = await prisma.post.findUnique({ where: { id } })
|
|
99
|
+
if (!post) {
|
|
100
|
+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
101
|
+
}
|
|
102
|
+
if (post.authorId !== userId) {
|
|
103
|
+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const updated = await prisma.post.update({
|
|
107
|
+
where: { id },
|
|
108
|
+
data: body
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
return NextResponse.json(updated)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// DELETE /api/posts/:id
|
|
115
|
+
export async function DELETE(request: NextRequest, { params }: Params) {
|
|
116
|
+
const { userId } = await auth()
|
|
117
|
+
if (!userId) {
|
|
118
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { id } = await params
|
|
122
|
+
|
|
123
|
+
const post = await prisma.post.findUnique({ where: { id } })
|
|
124
|
+
if (!post || post.authorId !== userId) {
|
|
125
|
+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await prisma.post.delete({ where: { id } })
|
|
129
|
+
|
|
130
|
+
return new NextResponse(null, { status: 204 })
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Error Handling Wrapper
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
// lib/api-utils.ts
|
|
138
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
139
|
+
import { ZodError } from 'zod'
|
|
140
|
+
|
|
141
|
+
type Handler = (req: NextRequest, context?: any) => Promise<NextResponse>
|
|
142
|
+
|
|
143
|
+
export function withErrorHandling(handler: Handler): Handler {
|
|
144
|
+
return async (req, context) => {
|
|
145
|
+
try {
|
|
146
|
+
return await handler(req, context)
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.error('API Error:', error)
|
|
149
|
+
|
|
150
|
+
if (error instanceof ZodError) {
|
|
151
|
+
return NextResponse.json(
|
|
152
|
+
{ error: 'Validation failed', details: error.errors },
|
|
153
|
+
{ status: 400 }
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (error instanceof Error) {
|
|
158
|
+
if (error.message === 'UNAUTHORIZED') {
|
|
159
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
160
|
+
}
|
|
161
|
+
if (error.message === 'NOT_FOUND') {
|
|
162
|
+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return NextResponse.json(
|
|
167
|
+
{ error: 'Internal server error' },
|
|
168
|
+
{ status: 500 }
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Rate Limiting Pattern
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
// lib/rate-limit.ts
|
|
179
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
180
|
+
|
|
181
|
+
const rateLimit = new Map<string, { count: number; resetTime: number }>()
|
|
182
|
+
|
|
183
|
+
export function checkRateLimit(
|
|
184
|
+
identifier: string,
|
|
185
|
+
limit: number = 10,
|
|
186
|
+
windowMs: number = 60000
|
|
187
|
+
): boolean {
|
|
188
|
+
const now = Date.now()
|
|
189
|
+
const record = rateLimit.get(identifier)
|
|
190
|
+
|
|
191
|
+
if (!record || now > record.resetTime) {
|
|
192
|
+
rateLimit.set(identifier, { count: 1, resetTime: now + windowMs })
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (record.count >= limit) {
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
record.count++
|
|
201
|
+
return true
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Usage in route
|
|
205
|
+
export async function POST(request: NextRequest) {
|
|
206
|
+
const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'
|
|
207
|
+
|
|
208
|
+
if (!checkRateLimit(ip, 10, 60000)) {
|
|
209
|
+
return NextResponse.json(
|
|
210
|
+
{ error: 'Too many requests' },
|
|
211
|
+
{ status: 429 }
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Continue with handler...
|
|
216
|
+
}
|
|
217
|
+
```
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# Next.js Server Action Patterns
|
|
2
|
+
|
|
3
|
+
Battle-tested patterns for Server Actions in Next.js App Router.
|
|
4
|
+
|
|
5
|
+
## Basic Server Action
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// actions/posts.ts
|
|
9
|
+
'use server'
|
|
10
|
+
|
|
11
|
+
import { prisma } from '@/lib/db'
|
|
12
|
+
import { auth } from '@/lib/auth'
|
|
13
|
+
import { revalidatePath } from 'next/cache'
|
|
14
|
+
import { redirect } from 'next/navigation'
|
|
15
|
+
import { z } from 'zod'
|
|
16
|
+
|
|
17
|
+
const CreatePostSchema = z.object({
|
|
18
|
+
title: z.string().min(1, 'Title is required').max(200),
|
|
19
|
+
content: z.string().optional()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export async function createPost(formData: FormData) {
|
|
23
|
+
const { userId } = await auth()
|
|
24
|
+
if (!userId) throw new Error('UNAUTHORIZED')
|
|
25
|
+
|
|
26
|
+
const validated = CreatePostSchema.parse({
|
|
27
|
+
title: formData.get('title'),
|
|
28
|
+
content: formData.get('content')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const post = await prisma.post.create({
|
|
32
|
+
data: { ...validated, authorId: userId }
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
revalidatePath('/posts')
|
|
36
|
+
redirect(`/posts/${post.id}`)
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Server Action with Return Value
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
// actions/posts.ts
|
|
44
|
+
'use server'
|
|
45
|
+
|
|
46
|
+
type ActionResult<T> =
|
|
47
|
+
| { success: true; data: T }
|
|
48
|
+
| { success: false; error: string }
|
|
49
|
+
|
|
50
|
+
export async function updatePost(
|
|
51
|
+
postId: string,
|
|
52
|
+
formData: FormData
|
|
53
|
+
): Promise<ActionResult<{ id: string }>> {
|
|
54
|
+
try {
|
|
55
|
+
const { userId } = await auth()
|
|
56
|
+
if (!userId) {
|
|
57
|
+
return { success: false, error: 'Not authenticated' }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const post = await prisma.post.findUnique({ where: { id: postId } })
|
|
61
|
+
if (!post || post.authorId !== userId) {
|
|
62
|
+
return { success: false, error: 'Not authorized' }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const title = formData.get('title') as string
|
|
66
|
+
if (!title || title.length < 1) {
|
|
67
|
+
return { success: false, error: 'Title is required' }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const updated = await prisma.post.update({
|
|
71
|
+
where: { id: postId },
|
|
72
|
+
data: { title }
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
revalidatePath(`/posts/${postId}`)
|
|
76
|
+
return { success: true, data: { id: updated.id } }
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error('Update post error:', error)
|
|
79
|
+
return { success: false, error: 'Failed to update post' }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Using with useActionState (React 19)
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// components/create-post-form.tsx
|
|
88
|
+
'use client'
|
|
89
|
+
|
|
90
|
+
import { useActionState } from 'react'
|
|
91
|
+
import { createPost } from '@/actions/posts'
|
|
92
|
+
|
|
93
|
+
const initialState = { error: null as string | null }
|
|
94
|
+
|
|
95
|
+
export function CreatePostForm() {
|
|
96
|
+
const [state, formAction, isPending] = useActionState(
|
|
97
|
+
async (prevState: typeof initialState, formData: FormData) => {
|
|
98
|
+
const result = await createPost(formData)
|
|
99
|
+
if (!result.success) {
|
|
100
|
+
return { error: result.error }
|
|
101
|
+
}
|
|
102
|
+
return { error: null }
|
|
103
|
+
},
|
|
104
|
+
initialState
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<form action={formAction}>
|
|
109
|
+
{state.error && (
|
|
110
|
+
<div className="text-red-500 mb-4">{state.error}</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
<input
|
|
114
|
+
name="title"
|
|
115
|
+
placeholder="Post title"
|
|
116
|
+
required
|
|
117
|
+
disabled={isPending}
|
|
118
|
+
className="w-full p-2 border rounded"
|
|
119
|
+
/>
|
|
120
|
+
|
|
121
|
+
<textarea
|
|
122
|
+
name="content"
|
|
123
|
+
placeholder="Content (optional)"
|
|
124
|
+
disabled={isPending}
|
|
125
|
+
className="w-full p-2 border rounded mt-2"
|
|
126
|
+
/>
|
|
127
|
+
|
|
128
|
+
<button
|
|
129
|
+
type="submit"
|
|
130
|
+
disabled={isPending}
|
|
131
|
+
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
|
|
132
|
+
>
|
|
133
|
+
{isPending ? 'Creating...' : 'Create Post'}
|
|
134
|
+
</button>
|
|
135
|
+
</form>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Delete Action with Confirmation
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
// actions/posts.ts
|
|
144
|
+
'use server'
|
|
145
|
+
|
|
146
|
+
export async function deletePost(postId: string): Promise<ActionResult<null>> {
|
|
147
|
+
const { userId } = await auth()
|
|
148
|
+
if (!userId) {
|
|
149
|
+
return { success: false, error: 'Not authenticated' }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const post = await prisma.post.findUnique({ where: { id: postId } })
|
|
153
|
+
if (!post || post.authorId !== userId) {
|
|
154
|
+
return { success: false, error: 'Not authorized' }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await prisma.post.delete({ where: { id: postId } })
|
|
158
|
+
|
|
159
|
+
revalidatePath('/posts')
|
|
160
|
+
return { success: true, data: null }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// components/delete-button.tsx
|
|
164
|
+
'use client'
|
|
165
|
+
|
|
166
|
+
import { deletePost } from '@/actions/posts'
|
|
167
|
+
import { useTransition } from 'react'
|
|
168
|
+
import { useRouter } from 'next/navigation'
|
|
169
|
+
|
|
170
|
+
export function DeleteButton({ postId }: { postId: string }) {
|
|
171
|
+
const [isPending, startTransition] = useTransition()
|
|
172
|
+
const router = useRouter()
|
|
173
|
+
|
|
174
|
+
const handleDelete = () => {
|
|
175
|
+
if (!confirm('Are you sure you want to delete this post?')) return
|
|
176
|
+
|
|
177
|
+
startTransition(async () => {
|
|
178
|
+
const result = await deletePost(postId)
|
|
179
|
+
if (result.success) {
|
|
180
|
+
router.push('/posts')
|
|
181
|
+
} else {
|
|
182
|
+
alert(result.error)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<button
|
|
189
|
+
onClick={handleDelete}
|
|
190
|
+
disabled={isPending}
|
|
191
|
+
className="text-red-500 disabled:opacity-50"
|
|
192
|
+
>
|
|
193
|
+
{isPending ? 'Deleting...' : 'Delete'}
|
|
194
|
+
</button>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Optimistic Updates
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
// components/like-button.tsx
|
|
203
|
+
'use client'
|
|
204
|
+
|
|
205
|
+
import { useOptimistic, useTransition } from 'react'
|
|
206
|
+
import { toggleLike } from '@/actions/posts'
|
|
207
|
+
|
|
208
|
+
export function LikeButton({ postId, initialLiked, initialCount }: {
|
|
209
|
+
postId: string
|
|
210
|
+
initialLiked: boolean
|
|
211
|
+
initialCount: number
|
|
212
|
+
}) {
|
|
213
|
+
const [isPending, startTransition] = useTransition()
|
|
214
|
+
const [optimistic, setOptimistic] = useOptimistic(
|
|
215
|
+
{ liked: initialLiked, count: initialCount },
|
|
216
|
+
(state, newLiked: boolean) => ({
|
|
217
|
+
liked: newLiked,
|
|
218
|
+
count: newLiked ? state.count + 1 : state.count - 1
|
|
219
|
+
})
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const handleClick = () => {
|
|
223
|
+
startTransition(async () => {
|
|
224
|
+
setOptimistic(!optimistic.liked)
|
|
225
|
+
await toggleLike(postId)
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<button onClick={handleClick} disabled={isPending}>
|
|
231
|
+
{optimistic.liked ? '❤️' : '🤍'} {optimistic.count}
|
|
232
|
+
</button>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## When to Use Server Actions vs Route Handlers
|
|
238
|
+
|
|
239
|
+
**Use Server Actions for:**
|
|
240
|
+
- Form submissions
|
|
241
|
+
- Mutations from client components
|
|
242
|
+
- Actions that need revalidation
|
|
243
|
+
- Progressive enhancement (works without JS)
|
|
244
|
+
|
|
245
|
+
**Use Route Handlers for:**
|
|
246
|
+
- Webhooks from external services
|
|
247
|
+
- Public APIs consumed by other apps
|
|
248
|
+
- Long-running operations
|
|
249
|
+
- File uploads/downloads
|