关于 nextjs-server-actions
nextjs-server-actions 是用于在 Next.js 中实现服务端数据变更的编码技能,涵盖表单处理、验证、授权、乐观更新和重新验证模式。
主要功能
- 01定义内联或独立的 Server Actions
- 02使用 React Hooks 处理表单
- 03使用 Zod 验证输入
- 04检查授权和角色权限
- 05重新验证路径和标签
适用场景
- 01无需 API 路由处理表单提交
- 02创建、更新或删除数据库记录
- 03数据变更期间显示乐观界面
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ josiahsiegel/ claude-plugin-marketplace --skill nextjs-server-actions安装后在 Claude Code 中输入/nextjs-server-actions确认已加载。
详细介绍
快速参考
| 模式 | 代码 | 用途 |
|---|---|---|
| 内联操作 | async function action() { 'use server'; } | 在组件中定义 |
| 操作文件 | 文件顶部添加 'use server' | 专用操作文件 |
| 表单操作 | <form action={serverAction}> | 原生表单集成 |
| 绑定参数 | action.bind(null, id) | 传递其他参数 |
| Hook | 导入 | 用途 |
|---|---|---|
useActionState | react | 表单状态 + 待处理状态 |
useFormStatus | react-dom | 子组件中的待处理状态 |
useOptimistic | react | 乐观 UI 更新 |
| 重新验证 | 函数 | 范围 |
|---|---|---|
revalidatePath('/posts') | 基于路径 | 特定路由 |
revalidateTag('posts') | 基于标签 | 所有带标签的 fetch |
redirect('/success') | 导航 | 修改后 |
何时使用此技能
用于服务端修改:
- 无需 API 路由的表单提交
- 数据库修改(创建、更新、删除)
- 将文件上传到服务端
- 实现乐观更新
- 显示错误信息的表单验证
相关技能:
- 数据获取:参见
nextjs-data-fetching - 缓存/重新验证:参见
nextjs-caching - 操作中的身份验证:参见
nextjs-authentication
Next.js 服务端操作
定义服务端操作
内联服务端操作
// app/posts/page.tsx
export default function PostsPage() {
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.posts.create({
data: { title, content },
});
revalidatePath('/posts');
}
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
);
}
独立操作文件
// app/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
const PostSchema = z.object({
title: z.string().min(1, 'Title is required').max(100),
content: z.string().min(1, 'Content is required'),
});
export async function createPost(formData: FormData) {
const validatedFields = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
try {
await db.posts.create({
data: validatedFields.data,
});
} catch (error) {
return { error: 'Failed to create post' };
}
revalidatePath('/posts');
redirect('/posts');
}
export async function deletePost(id: string) {
await db.posts.delete({ where: { id } });
revalidateTag('posts');
}
export async function updatePost(id: string, formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.posts.update({
where: { id },
data: { title, content },
});
revalidatePath(`/posts/${id}`);
return { success: true };
}
在表单中使用服务端操作
基本表单
// app/contact/page.tsx
import { submitContact } from './actions';
export default function ContactPage() {
return (
<form action={submitContact}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
<button type="submit">Send</button>
</form>
);
}
使用 useActionState 的表单
// app/signup/page.tsx
'use client';
import { useActionState } from 'react';
import { signup } from './actions';
const initialState = {
errors: {} as Record<string, string[]>,
message: '',
};
export default function SignupPage() {
const [state, formAction, isPending] = useActionState(signup, initialState);
return (
<form action={formAction}>
<div>
<input name="email" type="email" placeholder="Email" />
{state.errors?.email && (
<p className="error">{state.errors.email[0]}</p>
)}
</div>
<div>
<input name="password" type="password" placeholder="Password" />
{state.errors?.password && (
<p className="error">{state.errors.password[0]}</p>
)}
</div>
{state.message && <p className="message">{state.message}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Signing up...' : 'Sign Up'}
</button>
</form>
);
}
// app/signup/actions.ts
'use server';
import { z } from 'zod';
const SignupSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export async function signup(prevState: any, formData: FormData) {
const validatedFields = SignupSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: '',
};
}
try {
await createUser(validatedFields.data);
return { errors: {}, message: 'Account created successfully!' };
} catch (error) {
return { errors: {}, message: 'Failed to create account' };
}
}
使用 useFormStatus 获取待处理状态
// components/SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : children}
</button>
);
}
// Usage in form
import { SubmitButton } from '@/components/SubmitButton';
export default function Form() {
return (
<form action={submitForm}>
<input name="title" />
<SubmitButton>Submit</SubmitButton>
</form>
);
}
乐观更新
useOptimistic Hook
// app/posts/[id]/comments.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { addComment } from './actions';
interface Comment {
id: string;
text: string;
pending?: boolean;
}
export function Comments({ initialComments }: { initialComments: Comment[] }) {
const [isPending, startTransition] = useTransition();
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(state, newComment: Comment) => [...state, { ...newComment, pending: true }]
);
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
startTransition(async () => {
addOptimisticComment({
id: `temp-${Date.now()}`,
text,
});
await addComment(formData);
});
}
return (
<div>
<ul>
{optimisticComments.map((comment) => (
<li
key={comment.id}
style={{ opacity: comment.pending ? 0.5 : 1 }}
>
{comment.text}
{comment.pending && ' (posting...)'}
</li>
))}
</ul>
<form action={handleSubmit}>
<input name="text" placeholder="Add comment" required />
<button type="submit" disabled={isPending}>
Add
</button>
</form>
</div>
);
}
乐观点赞按钮
// components/LikeButton.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleLike } from '@/app/actions';
interface LikeButtonProps {
postId: string;
initialLiked: boolean;
initialCount: number;
}
export function LikeButton({
postId,
initialLiked,
initialCount,
}: LikeButtonProps) {
const [isPending, startTransition] = useTransition();
const [optimistic, setOptimistic] = useOptimistic(
{ liked: initialLiked, count: initialCount },
(state, liked: boolean) => ({
liked,
count: liked ? state.count + 1 : state.count - 1,
})
);
const handleClick = () => {
startTransition(async () => {
setOptimistic(!optimistic.liked);
await toggleLike(postId);
});
};
return (
<button onClick={handleClick} disabled={isPending}>
{optimistic.liked ? '❤️' : '🤍'} {optimistic.count}
</button>
);
}
绑定参数
使用 bind 传递参数
// components/DeleteButton.tsx
'use client';
import { deletePost } from '@/app/actions';
export function DeleteButton({ postId }: { postId: string }) {
const deletePostWithId = deletePost.bind(null, postId);
return (
<form action={deletePostWithId}>
<button type="submit">Delete</button>
</form>
);
}
// app/actions.ts
'use server';
export async function deletePost(postId: string, formData: FormData) {
await db.posts.delete({ where: { id: postId } });
revalidatePath('/posts');
}
隐藏输入框替代方案
// components/EditForm.tsx
export function EditForm({ post }: { post: Post }) {
return (
<form action={updatePost}>
<input type="hidden" name="id" value={post.id} />
<input name="title" defaultValue={post.title} />
<textarea name="content" defaultValue={post.content} />
<button type="submit">Update</button>
</form>
);
}
非表单调用
按钮点击
// components/LogoutButton.tsx
'use client';
import { logout } from '@/app/actions';
export function LogoutButton() {
return (
<button onClick={() => logout()}>
Log Out
</button>
);
}
事件处理程序
// components/TodoItem.tsx
'use client';
import { toggleTodo, deleteTodo } from '@/app/actions';
export function TodoItem({ todo }: { todo: Todo }) {
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.title}</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
);
}
验证
Zod 模式验证
// app/actions.ts
'use server';
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.coerce.number().min(18, 'Must be at least 18 years old'),
});
export type CreateUserState = {
errors?: {
name?: string[];
email?: string[];
age?: string[];
};
message?: string;
};
export async function createUser(
prevState: CreateUserState,
formData: FormData
): Promise<CreateUserState> {
const validatedFields = CreateUserSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
age: formData.get('age'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
try {
await db.users.create({ data: validatedFields.data });
return { message: 'User created successfully!' };
} catch (error) {
return { message: 'Database error: Failed to create user.' };
}
}
授权
检查用户权限
// app/actions.ts
'use server';
import { auth } from '@/lib/auth';
import { forbidden, unauthorized } from 'next/navigation';
export async function deletePost(postId: string) {
const session = await auth();
if (!session) {
unauthorized();
}
const post = await db.posts.findUnique({ where: { id: postId } });
if (post?.authorId !== session.user.id && session.user.role !== 'admin') {
forbidden();
}
await db.posts.delete({ where: { id: postId } });
revalidatePath('/posts');
}
基于角色的访问控制
// app/actions.ts
'use server';
import { auth } from '@/lib/auth';
export async function updateSettings(formData: FormData) {
const session = await auth();
if (!session) {
return { error: 'Not authenticated' };
}
if (session.user.role !== 'admin') {
return { error: 'Not authorized' };
}
// Update settings
await updateSystemSettings(formData);
revalidatePath('/settings');
return { success: true };
}
错误处理
Try-Catch 模式
// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
try {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.posts.create({ data: { title, content } });
revalidatePath('/posts');
return { success: true };
} catch (error) {
console.error('Failed to create post:', error);
return { error: 'Failed to create post. Please try again.' };
}
}
操作后重定向
// app/actions.ts
'use server';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
const post = await db.posts.create({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
},
});
revalidatePath('/posts');
redirect(`/posts/${post.slug}`);
}
文件上传
基本文件上传
// app/upload/page.tsx
import { uploadFile } from './actions';
export default function UploadPage() {
return (
<form action={uploadFile}>
<input type="file" name="file" accept="image/*" required />
<button type="submit">Upload</button>
</form>
);
}
// app/upload/actions.ts
'use server';
import { writeFile } from 'fs/promises';
import { join } from 'path';
export async function uploadFile(formData: FormData) {
const file = formData.get('file') as File;
if (!file || file.size === 0) {
return { error: 'No file provided' };
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name}`;
const path = join(process.cwd(), 'public/uploads', filename);
await writeFile(path, buffer);
return { success: true, path: `/uploads/${filename}` };
}
最佳实践
| 实践 | 描述 |
|---|---|
| 验证所有输入 | 使用 Zod 或类似工具进行验证 |
| 检查授权 | 在每个操作中验证用户权限 |
| 妥善处理错误 | 返回错误消息,不要抛出异常 |
| 使用乐观更新 | 改善修改操作的用户体验 |
| 正确重新验证 | 使用 revalidatePath 或 revalidateTag |
| 聚焦操作 | 每次修改使用一个操作 |
| 为返回值添加类型 | 为 useActionState 定义状态类型 |
评论
支持 Markdown