next-action-forge 0.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/README.md +434 -0
- package/dist/core/index.d.mts +3 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +312 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/index.mjs +283 -0
- package/dist/core/index.mjs.map +1 -0
- package/dist/hooks/index.d.mts +101 -0
- package/dist/hooks/index.d.ts +101 -0
- package/dist/hooks/index.js +278 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/index.mjs +249 -0
- package/dist/hooks/index.mjs.map +1 -0
- package/dist/index-BJx3beLJ.d.mts +30 -0
- package/dist/index-BJx3beLJ.d.ts +30 -0
- package/dist/index.d.mts +71 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.js +312 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +283 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +91 -0
package/README.md
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
# Next Action Forge
|
|
2
|
+
|
|
3
|
+
A powerful, type-safe toolkit for Next.js server actions with Zod validation and class-based API design.
|
|
4
|
+
|
|
5
|
+
## ✨ Features
|
|
6
|
+
|
|
7
|
+
- 🚀 **Type-safe server actions** with full TypeScript support
|
|
8
|
+
- 🎯 **Zod validation** built-in with perfect type inference
|
|
9
|
+
- 🏗️ **Class-based API** with intuitive method chaining
|
|
10
|
+
- 🪝 **React hooks** for seamless client-side integration
|
|
11
|
+
- 🔄 **Optimistic updates** support
|
|
12
|
+
- 🔐 **Middleware system** with context propagation
|
|
13
|
+
- ⚡ **Zero config** - works out of the box
|
|
14
|
+
- 🎨 **Custom error handling** with flexible error transformation
|
|
15
|
+
- 🦆 **Duck-typed errors** - Any error with `toServerActionError()` method is automatically handled
|
|
16
|
+
- 📋 **Smart FormData parsing** - Handles arrays, checkboxes, and files correctly
|
|
17
|
+
- ✅ **React 19 & Next.js 15** compatible
|
|
18
|
+
|
|
19
|
+
## 📦 Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install next-action-forge
|
|
23
|
+
# or
|
|
24
|
+
yarn add next-action-forge
|
|
25
|
+
# or
|
|
26
|
+
pnpm add next-action-forge
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Requirements
|
|
30
|
+
- Next.js 14.0.0 or higher
|
|
31
|
+
- React 18.0.0 or higher
|
|
32
|
+
- Zod 4.0.0 or higher
|
|
33
|
+
|
|
34
|
+
## 🚀 Quick Start
|
|
35
|
+
|
|
36
|
+
### 1. Create Server Actions
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// app/actions/user.ts
|
|
40
|
+
"use server";
|
|
41
|
+
|
|
42
|
+
import { createActionClient } from "next-action-forge";
|
|
43
|
+
import { z } from "zod";
|
|
44
|
+
|
|
45
|
+
// Create a reusable client
|
|
46
|
+
const actionClient = createActionClient();
|
|
47
|
+
|
|
48
|
+
// Define input schema
|
|
49
|
+
const userSchema = z.object({
|
|
50
|
+
name: z.string().min(2),
|
|
51
|
+
email: z.string().email(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Create an action with method chaining
|
|
55
|
+
export const createUser = actionClient
|
|
56
|
+
.inputSchema(userSchema)
|
|
57
|
+
.onError((error) => {
|
|
58
|
+
console.error("Failed to create user:", error);
|
|
59
|
+
return {
|
|
60
|
+
code: "USER_CREATE_ERROR",
|
|
61
|
+
message: "Failed to create user. Please try again.",
|
|
62
|
+
};
|
|
63
|
+
})
|
|
64
|
+
.action(async ({ name, email }) => {
|
|
65
|
+
// Your server logic here
|
|
66
|
+
const user = await db.user.create({
|
|
67
|
+
data: { name, email },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return user;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Action without input
|
|
74
|
+
export const getServerTime = actionClient
|
|
75
|
+
.action(async () => {
|
|
76
|
+
return { time: new Date().toISOString() };
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 2. Use in Client Components
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
// app/users/create-user-form.tsx
|
|
84
|
+
"use client";
|
|
85
|
+
|
|
86
|
+
import { useServerAction } from "next-action-forge/hooks";
|
|
87
|
+
import { createUser } from "@/app/actions/user";
|
|
88
|
+
import { toast } from "sonner";
|
|
89
|
+
|
|
90
|
+
export function CreateUserForm() {
|
|
91
|
+
const { execute, isLoading } = useServerAction(createUser, {
|
|
92
|
+
onSuccess: (data) => {
|
|
93
|
+
toast.success(`User ${data.name} created!`);
|
|
94
|
+
},
|
|
95
|
+
onError: (error) => {
|
|
96
|
+
toast.error(error.message);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const handleSubmit = async (formData: FormData) => {
|
|
101
|
+
const name = formData.get("name") as string;
|
|
102
|
+
const email = formData.get("email") as string;
|
|
103
|
+
|
|
104
|
+
await execute({ name, email });
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<form action={handleSubmit}>
|
|
109
|
+
<input name="name" required />
|
|
110
|
+
<input name="email" type="email" required />
|
|
111
|
+
<button disabled={isLoading}>
|
|
112
|
+
{isLoading ? "Creating..." : "Create User"}
|
|
113
|
+
</button>
|
|
114
|
+
</form>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## 🔧 Advanced Features
|
|
120
|
+
|
|
121
|
+
### Middleware System
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
const authClient = createActionClient()
|
|
125
|
+
.use(async ({ context, input }) => {
|
|
126
|
+
const session = await getSession();
|
|
127
|
+
if (!session) {
|
|
128
|
+
return {
|
|
129
|
+
error: {
|
|
130
|
+
code: "UNAUTHORIZED",
|
|
131
|
+
message: "You must be logged in",
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
context: {
|
|
138
|
+
...context,
|
|
139
|
+
userId: session.userId,
|
|
140
|
+
user: session.user,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// All actions created from authClient will have authentication
|
|
146
|
+
export const updateProfile = authClient
|
|
147
|
+
.inputSchema(profileSchema)
|
|
148
|
+
.action(async (input, context) => {
|
|
149
|
+
// context.userId is available here
|
|
150
|
+
return db.user.update({
|
|
151
|
+
where: { id: context.userId },
|
|
152
|
+
data: input,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Form Actions
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
const contactSchema = z.object({
|
|
161
|
+
name: z.string(),
|
|
162
|
+
email: z.string().email(),
|
|
163
|
+
message: z.string().min(10),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
export const submitContactForm = actionClient
|
|
167
|
+
.inputSchema(contactSchema)
|
|
168
|
+
.formAction(async ({ name, email, message }) => {
|
|
169
|
+
// Automatically parses FormData
|
|
170
|
+
await sendEmail({ to: email, subject: `Contact from ${name}`, body: message });
|
|
171
|
+
return { success: true };
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Use directly in form action
|
|
175
|
+
<form action={submitContactForm}>
|
|
176
|
+
<input name="name" required />
|
|
177
|
+
<input name="email" type="email" required />
|
|
178
|
+
<textarea name="message" required />
|
|
179
|
+
<button type="submit">Send</button>
|
|
180
|
+
</form>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### React Hook Form Integration
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
"use client";
|
|
187
|
+
|
|
188
|
+
import { useFormAction } from "next-action-forge/hooks";
|
|
189
|
+
import { updateProfileAction } from "@/app/actions/profile";
|
|
190
|
+
import { z } from "zod";
|
|
191
|
+
|
|
192
|
+
const profileSchema = z.object({
|
|
193
|
+
name: z.string().min(2, "Name must be at least 2 characters"),
|
|
194
|
+
bio: z.string().max(500).optional(),
|
|
195
|
+
website: z.string().url("Invalid URL").optional().or(z.literal("")),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
export function ProfileForm({ user }: { user: User }) {
|
|
199
|
+
const { form, onSubmit, isSubmitting, actionState } = useFormAction({
|
|
200
|
+
action: updateProfileAction, // Must be created with .formAction()
|
|
201
|
+
schema: profileSchema,
|
|
202
|
+
defaultValues: {
|
|
203
|
+
name: user.name,
|
|
204
|
+
bio: user.bio || "",
|
|
205
|
+
website: user.website || "",
|
|
206
|
+
},
|
|
207
|
+
resetOnSuccess: false,
|
|
208
|
+
showSuccessToast: "Profile updated successfully!",
|
|
209
|
+
showErrorToast: true,
|
|
210
|
+
onSuccess: (updatedUser) => {
|
|
211
|
+
// Optionally redirect or update local state
|
|
212
|
+
console.log("Profile updated:", updatedUser);
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<form onSubmit={onSubmit} className="space-y-4">
|
|
218
|
+
<div>
|
|
219
|
+
<label htmlFor="name">Name</label>
|
|
220
|
+
<input
|
|
221
|
+
id="name"
|
|
222
|
+
{...form.register("name")}
|
|
223
|
+
className={form.formState.errors.name ? "error" : ""}
|
|
224
|
+
/>
|
|
225
|
+
{form.formState.errors.name && (
|
|
226
|
+
<p className="error-message">{form.formState.errors.name.message}</p>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div>
|
|
231
|
+
<label htmlFor="bio">Bio</label>
|
|
232
|
+
<textarea
|
|
233
|
+
id="bio"
|
|
234
|
+
{...form.register("bio")}
|
|
235
|
+
rows={4}
|
|
236
|
+
placeholder="Tell us about yourself..."
|
|
237
|
+
/>
|
|
238
|
+
{form.formState.errors.bio && (
|
|
239
|
+
<p className="error-message">{form.formState.errors.bio.message}</p>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div>
|
|
244
|
+
<label htmlFor="website">Website</label>
|
|
245
|
+
<input
|
|
246
|
+
id="website"
|
|
247
|
+
{...form.register("website")}
|
|
248
|
+
type="url"
|
|
249
|
+
placeholder="https://example.com"
|
|
250
|
+
/>
|
|
251
|
+
{form.formState.errors.website && (
|
|
252
|
+
<p className="error-message">{form.formState.errors.website.message}</p>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{/* Display global server errors */}
|
|
257
|
+
{form.formState.errors.root && (
|
|
258
|
+
<div className="alert alert-error">
|
|
259
|
+
{form.formState.errors.root.message}
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
<button type="submit" disabled={isSubmitting}>
|
|
264
|
+
{isSubmitting ? "Saving..." : "Save Profile"}
|
|
265
|
+
</button>
|
|
266
|
+
</form>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The server action must be created with .formAction()
|
|
271
|
+
// app/actions/profile.ts
|
|
272
|
+
export const updateProfileAction = actionClient
|
|
273
|
+
.inputSchema(profileSchema)
|
|
274
|
+
.formAction(async ({ name, bio, website }, context) => {
|
|
275
|
+
const updatedUser = await db.user.update({
|
|
276
|
+
where: { id: context.userId },
|
|
277
|
+
data: { name, bio, website },
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return updatedUser;
|
|
281
|
+
});
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Custom Error Classes
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
// Define your error class with toServerActionError method
|
|
288
|
+
class ValidationError extends Error {
|
|
289
|
+
constructor(public field: string, message: string) {
|
|
290
|
+
super(message);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
toServerActionError() {
|
|
294
|
+
return {
|
|
295
|
+
code: "VALIDATION_ERROR",
|
|
296
|
+
message: this.message,
|
|
297
|
+
field: this.field,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The error will be automatically transformed
|
|
303
|
+
export const updateUser = actionClient
|
|
304
|
+
.inputSchema(userSchema)
|
|
305
|
+
.action(async (input) => {
|
|
306
|
+
if (await isEmailTaken(input.email)) {
|
|
307
|
+
throw new ValidationError("email", "Email is already taken");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ... rest of the logic
|
|
311
|
+
});
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Error Handler Adapter
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
// Create a custom error adapter for your error library
|
|
318
|
+
export function createErrorAdapter() {
|
|
319
|
+
return (error: unknown): ServerActionError | undefined => {
|
|
320
|
+
if (error instanceof MyCustomError) {
|
|
321
|
+
return {
|
|
322
|
+
code: error.code,
|
|
323
|
+
message: error.message,
|
|
324
|
+
statusCode: error.statusCode,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Return undefined to use default error handling
|
|
329
|
+
return undefined;
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Use it globally
|
|
334
|
+
const actionClient = createActionClient()
|
|
335
|
+
.onError(createErrorAdapter());
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Optimistic Updates
|
|
339
|
+
|
|
340
|
+
```tsx
|
|
341
|
+
import { useOptimisticAction } from "next-action-forge/hooks";
|
|
342
|
+
|
|
343
|
+
function TodoList({ todos }: { todos: Todo[] }) {
|
|
344
|
+
const { optimisticData, execute } = useOptimisticAction(
|
|
345
|
+
todos,
|
|
346
|
+
toggleTodo,
|
|
347
|
+
{
|
|
348
|
+
updateFn: (currentTodos, { id }) => {
|
|
349
|
+
return currentTodos.map(todo =>
|
|
350
|
+
todo.id === id ? { ...todo, done: !todo.done } : todo
|
|
351
|
+
);
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<ul>
|
|
358
|
+
{optimisticData.map(todo => (
|
|
359
|
+
<li key={todo.id}>
|
|
360
|
+
<input
|
|
361
|
+
type="checkbox"
|
|
362
|
+
checked={todo.done}
|
|
363
|
+
onChange={() => execute({ id: todo.id })}
|
|
364
|
+
/>
|
|
365
|
+
{todo.title}
|
|
366
|
+
</li>
|
|
367
|
+
))}
|
|
368
|
+
</ul>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
## 📚 API Reference
|
|
374
|
+
|
|
375
|
+
### ServerActionClient
|
|
376
|
+
|
|
377
|
+
The main class for creating type-safe server actions with method chaining.
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
const client = createActionClient();
|
|
381
|
+
|
|
382
|
+
// Available methods:
|
|
383
|
+
client
|
|
384
|
+
.use(middleware) // Add middleware
|
|
385
|
+
.inputSchema(zodSchema) // Set input validation schema
|
|
386
|
+
.outputSchema(zodSchema) // Set output validation schema
|
|
387
|
+
.onError(handler) // Set error handler
|
|
388
|
+
.action(serverFunction) // Define the server action
|
|
389
|
+
.formAction(serverFunction) // Define a form action
|
|
390
|
+
|
|
391
|
+
// You can also create a pre-configured client with default error handling:
|
|
392
|
+
const clientWithErrorHandler = createActionClient()
|
|
393
|
+
.onError((error) => {
|
|
394
|
+
console.error("Action error:", error);
|
|
395
|
+
return {
|
|
396
|
+
code: "INTERNAL_ERROR",
|
|
397
|
+
message: "Something went wrong",
|
|
398
|
+
};
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// All actions created from this client will use the error handler
|
|
402
|
+
const myAction = clientWithErrorHandler
|
|
403
|
+
.inputSchema(schema)
|
|
404
|
+
.action(async (input) => {
|
|
405
|
+
// Your logic here
|
|
406
|
+
});
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
### Hooks
|
|
410
|
+
|
|
411
|
+
- `useServerAction` - Execute server actions with loading state and callbacks
|
|
412
|
+
- `useOptimisticAction` - Optimistic UI updates
|
|
413
|
+
- `useFormAction` - Integration with React Hook Form (works with `.formAction()` or form-compatible actions)
|
|
414
|
+
|
|
415
|
+
### Error Handling
|
|
416
|
+
|
|
417
|
+
The library follows a precedence order for error handling:
|
|
418
|
+
|
|
419
|
+
1. Custom error handler (if provided via `onError`)
|
|
420
|
+
2. Duck-typed errors (objects with `toServerActionError()` method)
|
|
421
|
+
3. Zod validation errors (automatically formatted)
|
|
422
|
+
4. Generic errors (with safe error messages in production)
|
|
423
|
+
|
|
424
|
+
## 📄 License
|
|
425
|
+
|
|
426
|
+
MIT
|
|
427
|
+
|
|
428
|
+
## 🤝 Contributing
|
|
429
|
+
|
|
430
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
431
|
+
|
|
432
|
+
## 🙏 Acknowledgments
|
|
433
|
+
|
|
434
|
+
Inspired by [next-safe-action](https://github.com/TheEdoRan/next-safe-action) but with a simpler, more lightweight approach.
|