Featured image of post Next.js Server Actions 最佳实践

Next.js Server Actions 最佳实践

为什么用 Server Actions 而不是 API Route

对比项Server ActionsAPI Route
类型安全全栈类型推断需要手动定义
调用方式直接调用函数fetch + JSON
错误处理统一封装每个 route 单独处理
认证在 action 内部处理在 route 内部处理
代码量

Server Actions 的最大优势是类型安全:前端调用时,TypeScript 直接知道返回值的类型,不需要手动定义接口。

next-safe-action 封装

直接用 Next.js 原生 Server Actions 缺少统一的错误处理和输入校验。next-safe-action 提供了一个更好的封装:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// src/lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";

export const actionClient = createSafeActionClient({
  handleServerError(error) {
    if (error instanceof ActionError) {
      return {
        code: error.code,
        message: error.message,
        details: error.details,
      };
    }
    console.error("Unexpected error:", error);
    return {
      code: "INTERNAL_SERVER_ERROR",
      message: "An unexpected error occurred",
    };
  },
});

输入校验

所有 Server Actions 都用 Zod 做输入校验:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// src/schemas/image-gen.schema.ts
import { z } from "zod";

export const generateImageSchema = z.object({
  prompt: z
    .string()
    .min(3, "Prompt must be at least 3 characters")
    .max(1000, "Prompt must be at most 1000 characters"),
});

export type GenerateImageSchema = z.infer<typeof generateImageSchema>;
1
2
3
4
5
6
7
8
// src/sites/image-gen/site.actions.ts
export const generateImageAction = actionClient
  .inputSchema(generateImageSchema)
  .action(async ({ parsedInput }) => {
    // parsedInput 已经过 Zod 校验,类型安全
    const { prompt } = parsedInput;
    // ...
  });

错误处理

统一的错误类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// src/lib/action-error.ts
export class ActionError extends Error {
  constructor(
    public code: string,
    message: string,
    public details?: unknown
  ) {
    super(message);
  }
}

在 action 中抛出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
export const generateImageAction = actionClient
  .inputSchema(generateImageSchema)
  .action(async ({ parsedInput }) => {
    const session = await requireVerifiedEmail();
    if (!session) {
      throw new ActionError("NOT_AUTHORIZED", "You must be signed in");
    }

    const hasCredits = await checkCredits(session.user.id, 10);
    if (!hasCredits) {
      throw new ActionError("PAYMENT_REQUIRED", "Insufficient credits", {
        billingPath: "/dashboard/billing",
      });
    }
    // ...
  });

客户端调用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
"use client";

import { useAction } from "next-safe-action/hooks";
import { generateImageAction } from "./site.actions";

export function ImageGenClient() {
  const { execute, status } = useAction(generateImageAction, {
    onSuccess: ({ data }) => {
      toast.success("Generation started!");
    },
    onError: ({ error }) => {
      const serverError = error.serverError;

      if (serverError?.code === "PAYMENT_REQUIRED") {
        toast.error(serverError.message);
        const billingPath = serverError.details?.billingPath;
        if (billingPath) router.push(billingPath);
        return;
      }

      toast.error(serverError?.message ?? "Something went wrong");
    },
  });

  return (
    <Button
      onClick={() => execute({ prompt })}
      disabled={status === "executing"}
    >
      {status === "executing" ? "Generating..." : "Generate"}
    </Button>
  );
}

AI 任务轮询模式

AI 生成任务需要轮询状态,这是一个常见的异步模式:

提交任务

1
2
3
4
5
6
export const generateImageAction = actionClient
  .inputSchema(generateImageSchema)
  .action(async ({ parsedInput }) => {
    // 扣积分 + 请求大模型 + 存 DB
    return { taskId };
  });

轮询状态

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const checkImageStatusAction = actionClient
  .inputSchema(z.object({ taskId: z.string() }))
  .action(async ({ parsedInput }) => {
    const task = await db.query.generatedImageTable.findFirst({
      where: eq(generatedImageTable.id, parsedInput.taskId),
    });

    if (task.status === "completed") {
      return { status: "completed", r2Key: task.r2Key };
    }

    if (task.status === "processing") {
      // 查看最新状态
      const prediction = await CheckPrediction(task.predictionId);
      if (prediction.status === "completed") {
        // 存 R2 + 更新 DB
        return { status: "completed", r2Key };
      }
    }

    return { status: task.status };
  });

前端轮询

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const { execute: pollStatus } = useAction(checkImageStatusAction, {
  onSuccess: ({ data }) => {
    if (data?.status === "completed") {
      setResult(data);
      clearInterval(intervalRef.current);
    }
  },
});

useEffect(() => {
  if (!taskId) return;
  const interval = setInterval(() => {
    pollStatus({ taskId });
  }, 3000);
  return () => clearInterval(interval);
}, [taskId]);

总结

Server Actions 的最佳实践:

  1. next-safe-action 统一封装,避免重复的错误处理代码
  2. 所有输入用 Zod 校验,Schema 前后端共享
  3. ActionError 统一错误类型,前端可以根据 code 做不同处理
  4. 异步任务用"提交 + 轮询"模式,不要在 action 里等待结果

参考资源

使用 Hugo 构建
主题 StackJimmy 设计