@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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +255 -0
  3. package/agents/README.md +93 -0
  4. package/agents/api-expert/context.md +416 -0
  5. package/agents/architecture-expert/context.md +454 -0
  6. package/agents/backend-expert/context.md +483 -0
  7. package/agents/code-review-expert/context.md +365 -0
  8. package/agents/database-expert/context.md +250 -0
  9. package/agents/devops-expert/context.md +446 -0
  10. package/agents/frontend-expert/context.md +364 -0
  11. package/agents/index.js +140 -0
  12. package/agents/performance-expert/context.md +377 -0
  13. package/agents/security-expert/context.md +343 -0
  14. package/agents/testing-expert/context.md +414 -0
  15. package/agents/ui-ux-expert/context.md +448 -0
  16. package/agents/vercel-expert/context.md +426 -0
  17. package/bin/bootspring.js +310 -0
  18. package/cli/agent.js +337 -0
  19. package/cli/context.js +194 -0
  20. package/cli/dashboard.js +150 -0
  21. package/cli/generate.js +294 -0
  22. package/cli/init.js +410 -0
  23. package/cli/loop.js +421 -0
  24. package/cli/mcp.js +241 -0
  25. package/cli/memory.js +303 -0
  26. package/cli/orchestrator.js +400 -0
  27. package/cli/plugin.js +451 -0
  28. package/cli/quality.js +332 -0
  29. package/cli/skill.js +369 -0
  30. package/cli/task.js +628 -0
  31. package/cli/telemetry.js +114 -0
  32. package/cli/todo.js +614 -0
  33. package/cli/update.js +312 -0
  34. package/core/config.js +245 -0
  35. package/core/context.js +329 -0
  36. package/core/entitlements.js +209 -0
  37. package/core/index.js +43 -0
  38. package/core/policies.js +68 -0
  39. package/core/telemetry.js +247 -0
  40. package/core/utils.js +380 -0
  41. package/dashboard/server.js +818 -0
  42. package/docs/integrations/claude-code.md +42 -0
  43. package/docs/integrations/codex.md +42 -0
  44. package/docs/mcp-api-platform.md +102 -0
  45. package/generators/generate.js +598 -0
  46. package/generators/index.js +18 -0
  47. package/hooks/context-detector.js +177 -0
  48. package/hooks/index.js +35 -0
  49. package/hooks/prompt-enhancer.js +289 -0
  50. package/intelligence/git-memory.js +551 -0
  51. package/intelligence/index.js +59 -0
  52. package/intelligence/orchestrator.js +964 -0
  53. package/intelligence/prd.js +447 -0
  54. package/intelligence/recommendation-weights.json +18 -0
  55. package/intelligence/recommendations.js +234 -0
  56. package/mcp/capabilities.js +71 -0
  57. package/mcp/contracts/mcp-contract.v1.json +497 -0
  58. package/mcp/registry.js +213 -0
  59. package/mcp/response-formatter.js +462 -0
  60. package/mcp/server.js +99 -0
  61. package/mcp/tools/agent-tool.js +137 -0
  62. package/mcp/tools/capabilities-tool.js +54 -0
  63. package/mcp/tools/context-tool.js +49 -0
  64. package/mcp/tools/dashboard-tool.js +58 -0
  65. package/mcp/tools/generate-tool.js +46 -0
  66. package/mcp/tools/loop-tool.js +134 -0
  67. package/mcp/tools/memory-tool.js +180 -0
  68. package/mcp/tools/orchestrator-tool.js +232 -0
  69. package/mcp/tools/plugin-tool.js +76 -0
  70. package/mcp/tools/quality-tool.js +47 -0
  71. package/mcp/tools/skill-tool.js +233 -0
  72. package/mcp/tools/telemetry-tool.js +95 -0
  73. package/mcp/tools/todo-tool.js +133 -0
  74. package/package.json +98 -0
  75. package/plugins/index.js +141 -0
  76. package/quality/index.js +380 -0
  77. package/quality/lint-budgets.json +19 -0
  78. package/skills/index.js +787 -0
  79. package/skills/patterns/README.md +163 -0
  80. package/skills/patterns/api/route-handler.md +217 -0
  81. package/skills/patterns/api/server-action.md +249 -0
  82. package/skills/patterns/auth/clerk.md +132 -0
  83. package/skills/patterns/database/prisma.md +180 -0
  84. package/skills/patterns/payments/stripe.md +272 -0
  85. package/skills/patterns/security/validation.md +268 -0
  86. package/skills/patterns/testing/vitest.md +307 -0
  87. package/templates/bootspring.config.js +83 -0
  88. 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