Skills
server-actions-patterns
server-actions-patterns
npx @loomcraft/cli add skill server-actions-patternsFrontmatter
Name
server-actions-patterns
Description
Next.js Server Actions with safe wrappers, validation, and error handling. Use when implementing mutations, creating form handlers, or building 'use server' functions.
Content
# Server Actions Patterns
## Critical Rules
- **Always validate inputs** — never trust client data.
- **Always check auth** — every action must verify the user session.
- **Never expose internal errors** — return generic messages to the client.
- **Revalidate after mutations** — stale UI is a bug.
- **Keep actions thin** — delegate to facades/services for business logic.
- **One action per mutation** — avoid multi-purpose actions.
- **Never import server-only code in client files** — pass actions via props or use wrappers.
## File Organization
- Server Actions are defined in `src/actions/` directory.
- One file per domain entity: `src/actions/{entity}.actions.ts`.
- Every action file starts with `"use server"` directive at the top.
```
src/actions/
user.actions.ts
post.actions.ts
organization.actions.ts
```
## Safe Server Action Wrapper
Use a `safeAction` wrapper for consistent validation, auth, and error handling:
```ts
// src/lib/safe-action.ts
"use server";
import { z } from "zod";
import { getCurrentUser } from "@/lib/auth";
type ActionResult<T> = { success: true; data: T } | { success: false; error: string };
export function createSafeAction<TInput extends z.ZodType, TOutput>(
schema: TInput,
handler: (input: z.infer<TInput>, user: AuthUser) => Promise<TOutput>
) {
return async (input: z.infer<TInput>): Promise<ActionResult<TOutput>> => {
try {
const user = await getCurrentUser();
if (!user) return { success: false, error: "Unauthorized" };
const parsed = schema.safeParse(input);
if (!parsed.success) {
return { success: false, error: parsed.error.errors[0].message };
}
const data = await handler(parsed.data, user);
return { success: true, data };
} catch (error) {
console.error("Action error:", error);
return { success: false, error: "An unexpected error occurred" };
}
};
}
```
## Action Implementation
```ts
// src/actions/user.actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { createSafeAction } from "@/lib/safe-action";
import { updateUserProfile } from "@/facades/user.facade";
const UpdateProfileSchema = z.object({
name: z.string().min(2).max(100),
bio: z.string().max(500).optional(),
});
export const updateProfile = createSafeAction(
UpdateProfileSchema,
async (input, user) => {
const result = await updateUserProfile(user.id, input);
revalidatePath("/profile");
return result;
}
);
```
## Server-Only Modules
- Mark server-only modules with the `server-only` package:
```ts
import "server-only";
```
- Use `"use server"` only in action files — never in utility or service files.
## Integration with Forms
### With useActionState (React 19)
```tsx
"use client";
import { useActionState } from "react";
import { updateProfile } from "@/actions/user.actions";
export function ProfileForm() {
const [state, formAction, isPending] = useActionState(updateProfile, null);
return (
<form action={formAction}>
<input name="name" />
{state?.error && <p className="text-destructive">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</button>
</form>
);
}
```
### With react-hook-form
```tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTransition } from "react";
import { updateProfile } from "@/actions/user.actions";
import { toast } from "sonner";
export function ProfileForm() {
const [isPending, startTransition] = useTransition();
const form = useForm({ resolver: zodResolver(UpdateProfileSchema) });
const onSubmit = form.handleSubmit((data) => {
startTransition(async () => {
const result = await updateProfile(data);
if (result.success) toast.success("Profile updated");
else toast.error(result.error);
});
});
return <form onSubmit={onSubmit}>{/* fields */}</form>;
}
```
## Revalidation
- Always call `revalidatePath()` or `revalidateTag()` after mutations.
- Use path revalidation for simple cases: `revalidatePath("/users")`.
- Use tag revalidation for granular cache control: `revalidateTag("user-list")`.
- Redirect with `redirect()` after create operations when appropriate.
## Do
- Wrap every action with `createSafeAction` (or equivalent) for consistent auth, validation, and error handling.
- Validate all inputs with Zod on the server -- even if the client already validated.
- Call `revalidatePath()` or `revalidateTag()` after every mutation so the UI stays fresh.
- Keep actions thin -- delegate to facades/services for actual business logic.
- Define one action per mutation (e.g., `createUser`, `updateUser`, `deleteUser`).
- Return a typed `ActionResult<T>` from every action for predictable client-side handling.
- Use `useTransition` or `useActionState` to track pending state and disable the submit button.
- Mark server-only utilities with `import "server-only"` to prevent accidental client bundling.
## Don't
- Don't trust client data -- always validate and parse with Zod on the server side.
- Don't expose internal error messages or stack traces to the client -- return generic messages.
- Don't put `"use server"` on utility or service files -- only action files.
- Don't call services or DAL directly from actions -- go through the facade layer.
- Don't create multi-purpose actions that handle create, update, and delete in one function.
- Don't forget to revalidate after mutations -- stale UI is a bug.
- Don't import server action files into client components -- pass actions via props or use dynamic imports.
- Don't perform side effects (emails, webhooks) inside the action -- delegate to services.
## Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| **Unvalidated input** | Action uses `input` directly without parsing, exposing the app to injection or malformed data. | Always run `schema.safeParse(input)` before processing. |
| **Leaked errors** | Catching an error and returning `error.message` to the client, exposing internals. | Log the real error server-side; return a generic `"An unexpected error occurred"` to the client. |
| **Fat action** | Action contains business logic, database calls, and auth checks all in one function. | Keep actions thin -- call a facade, which calls a service, which calls the DAL. |
| **Missing revalidation** | Data is mutated but the page still shows stale cached content. | Call `revalidatePath()` or `revalidateTag()` after every successful mutation. |
| **Shared mega-action** | One `handleFormAction` that switches on an `action` field to handle multiple operations. | Create separate named actions: `createUser`, `updateUser`, `deleteUser`. |
| **No pending state** | Submit button stays enabled during the request, causing double submissions. | Use `useTransition` or `useActionState` and disable the button while `isPending` is true. |Files
No additional files