You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Next.js 14中Next Auth凭证登录结合Server Actions的错误处理问题

解决Next.js 14 Server Action登录错误捕获问题

你的问题核心在于Server Action里的错误都是通过返回对象的方式传递,而非抛出异常,所以客户端的try/catch无法捕获这些错误,全部进入了try块。要让错误被catch捕获,需要把Server Action中的错误改为抛出异常,而非返回错误对象。

步骤1:修改Server Action,抛出错误而非返回错误对象

改造/actions/login.ts,将所有验证错误、凭证错误改为抛出异常,同时为字段验证错误自定义一个错误类型,方便客户端区分处理:

"use server";

import { signIn } from "@/auth";
import { DEFAULT_REDIRECT } from "@/routes";
import { LoginFormSchema, LoginFormSchemaType } from "@/schemas";
import { getUserByEmail } from "@/utils/db/data/user";
import bcrypt from "bcryptjs";
import { toast } from "sonner";

// 自定义字段验证错误类,携带字段错误信息
class FieldValidationError extends Error {
  fieldErrors: Record<string, string[]>;
  constructor(fieldErrors: Record<string, string[]>) {
    super("请验证您的输入数据");
    this.name = "FieldValidationError";
    this.fieldErrors = fieldErrors;
  }
}

export const login = async (values: LoginFormSchemaType) => {
  const validatedFields = LoginFormSchema.safeParse(values);
  if (!validatedFields.success) {
    // 抛出字段验证错误,替代返回对象
    throw new FieldValidationError(validatedFields.error.flatten().fieldErrors);
  }

  const { email, password } = validatedFields.data;

  const existingUser = await getUserByEmail(email);

  if (!existingUser || !existingUser.email || !existingUser.password) {
    // 抛出凭证错误
    throw new Error("邮箱或密码无效");
  }

  const passwordsMatch = await bcrypt.compare(password, existingUser.password);
  if (!passwordsMatch) {
    // 抛出凭证错误
    throw new Error("邮箱或密码无效");
  }
  
  try {
    await signIn("credentials", {
      email,
      password,
      redirectTo: DEFAULT_REDIRECT,
    });

    toast(`Welcome back ${existingUser.name} `);
    return { success: true, message: "登录成功" };
  } catch (error: any) {
    // 重新抛出Auth.js的错误
    throw new Error(error.message || "登录失败");
  }
};

步骤2:修改客户端表单,捕获并处理不同类型的错误

在/app/(auth)/login/form.tsx中,修改onSubmit方法,区分捕获到的错误类型,分别处理字段错误和通用错误:

// -----------------------------
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { LoginFormSchema, LoginFormSchemaType } from "@/schemas";
import { login } from "@/actions/login";
import { useState } from "react";
import { Form, FormField, FormItem, FormLabel, FormControl, Input, FormMessage } from "@/components/ui/form";
import { Button } from "@/components/ui/button";

// -----------------------------
const loginForm = useForm<LoginFormSchemaType>({
  resolver: zodResolver(LoginFormSchema),
  defaultValues: {
    email: "",
    password: "",
  },
});

// ------------------------

const [formMessage, setFormMessage] = useState<{
  message: string;
  type: "error" | "success";
} | null>(null);
const [loadingToLogin, setLoadingToLogin] = useState(false);

const onSubmit = async (values: LoginFormSchemaType) => {
  setFormMessage(null);
  setLoadingToLogin(true);
  try {
    const response = await login(values);
    // 登录成功时处理提示
    setFormMessage({
      message: response.message,
      type: "success",
    });
  } catch (error: any) {
    // 区分错误类型,针对性处理
    if (error.name === "FieldValidationError") {
      // 字段验证错误:绑定到对应表单字段显示
      Object.entries(error.fieldErrors).forEach(([field, messages]) => {
        loginForm.setError(field as keyof LoginFormSchemaType, {
          message: messages.join(", "),
        });
      });
    } else {
      // 通用错误:显示在表单顶部
      setFormMessage({
        message: error.message,
        type: "error",
      });
    }
  } finally {
    // 无论成功失败,重置加载状态
    setLoadingToLogin(false);
  }
};

return (
  <Form {...loginForm}>
    <form onSubmit={loginForm.handleSubmit(onSubmit)} className="grid gap-4">
      {/* 表单全局提示 */}
      {formMessage && (
        <div className={`text-sm ${formMessage.type === "error" ? "text-red-500" : "text-green-500"}`}>
          {formMessage.message}
        </div>
      )}
      
      <FormField
        control={loginForm.control}
        name="email"
        render={({ field }) => (
          <FormItem>
            <FormLabel>邮箱</FormLabel>
            <FormControl>
              <Input
                {...field}
                autoComplete="email"
                disabled={loadingToLogin}
              />
            </FormControl>
            <FormMessage />
          </FormItem>
        )}
      />
      
      <FormField
        control={loginForm.control}
        name="password"
        render={({ field }) => (
          <FormItem>
            <FormLabel>密码</FormLabel>
            <FormControl>
              <Input
                type="password"
                {...field}
                autoComplete="current-password"
                disabled={loadingToLogin}
              />
            </FormControl>
            <FormMessage />
          </FormItem>
        )}
      />
      
      <Button type="submit" disabled={loadingToLogin} className="w-full">
        {loadingToLogin ? "登录中..." : "登录"}
      </Button>
    </form>
  </Form>
);

关键说明

  1. 自定义错误类:通过FieldValidationError区分字段验证错误和其他通用错误,让客户端可以针对性处理——字段错误绑定到对应表单字段显示,通用错误显示在表单顶部。
  2. 抛出异常替代返回错误对象:Server Action中所有错误场景都改为throw,确保客户端的try/catch能捕获到错误。
  3. finally块处理加载状态:保证无论登录成功还是失败,加载状态都会被重置,避免按钮一直处于禁用状态。

内容的提问来源于stack exchange,提问作者barhoom

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.21 14:56:10