Andrew G Bliss

NextJS User Signup Server Action

2025-04-13
NextJS User Signup Server Action

3. Server Action

Now let's create our server action.

"use server";

import { redirect } from "next/navigation";
import { updateUserVerificationToken, createUser } from "../_data/credentials";
import { sendSignupVerificationEmail } from "../_data/notifications/emails";

export async function handleSignupForm(email: string, password: string) {
  const user = await createUser(email, password);
  await sendSignupVerificationEmail(email, user.verificationToken);
  redirect("/login?credentials=true");
}

export async function handleResendVerification(token: string) {
  try {
    const user = await updateUserVerificationToken(token);
    await sendSignupVerificationEmail(user.email, user.verificationToken);
  } catch (error) {
    console.error(error);
    redirect("/signup/error");
  }
}

4. Data Layer

Create User

Let's create the user.

import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { createHash, randomBytes } from "crypto";

export async function createUser(email: string, password: string) {
  const existingUser = await db
    .select()
    .from(users)
    .where(eq(users.email, email))
    .limit(1)
    .then((rows) => rows[0]);

  if (existingUser) {
    throw new Error("Email already registered");
  }

  const hashedPassword = createHash("sha256").update(password).digest("hex");
  const verificationToken = randomBytes(32).toString("hex");

  const user = await db.insert(users).values({
    email,
    password: hashedPassword,
    verificationToken,
    verificationTokenExpiresAt: new Date(Date.now() + 1000 * 60 * 15), // 15 minutes
    verified: false,
  }).returning();

  return user[0];
}

Verify User

Now let's create a function to verify the user.

import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";

export async function verifyUser(verificationToken: string) {
  if (!verificationToken) {
    throw new Error("No verification token provided");
  }

  const user = await db
    .select()
    .from(users)
    .where(eq(users.verificationToken, verificationToken))
    .limit(1)
    .then((rows) => rows[0]);

  if (!user) {
    throw new Error("Invalid verification token");
  }

  if (user.verified) {
    throw new Error("User already verified");
  }

  if (
    user.verificationTokenExpiresAt &&
    user.verificationTokenExpiresAt < new Date()
  ) {
    throw new Error("Verification token expired");
  }

  const updatedUser = await db
    .update(users)
    .set({
      verified: true,
    })
    .where(eq(users.id, user.id))
    .returning();

  return updatedUser[0];
}

Setting the auth cookie

"use server";

import { cookies } from "next/headers";
import { sign } from "../_data/jwt";
import { JwtPayload } from "../_data/jwt/types";

export async function handleAuthJwt(payload: JwtPayload) {
  const token = await sign(payload);
  const cookieStore = await cookies();
  cookieStore.set(process.env.AUTH_COOKIE_NAME || "user-session", token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "strict",
    maxAge: parseInt(process.env.AUTH_COOKIE_MAX_AGE || "604800"),
  });
}

Signing the JWT

import { SignJWT, jwtVerify } from "jose";
import { JwtPayload } from "./types";

const JWT_MAX_AGE = process.env.JWT_MAX_AGE || 604800;
const JWT_SECRET = new TextEncoder().encode(
  process.env.JWT_SECRET || "default-secret-key"
);

export async function sign(payload: JwtPayload) {
  const token = await new SignJWT(payload)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime(JWT_MAX_AGE + "s")
    .sign(JWT_SECRET);
  return token;
}

5. Route Handler

The email being sent will contain the verification token. Once the user clicks on the button in the email to verify they will be brought to this route handler.

import { NextRequest, NextResponse } from "next/server";
import { handleAuthJwt } from "../../../actions/login";
import { verifyUser } from "../../../_data/credentials";

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ verificationToken: string }> }
) {
  const { verificationToken } = await params;
  try {
    const payload = await verifyUser(verificationToken);
    await handleAuthJwt(payload);
    return NextResponse.redirect(new URL("/dashboard", request.url));
  } catch (error) {
    console.error("Verification error:", error);
    if (
      error instanceof Error &&
      error.message === "Verification token expired"
    ) {
      return NextResponse.redirect(
        new URL(`/signup/resend/${verificationToken}`, request.url)
      );
    }
    return NextResponse.redirect(new URL(`/signup/error`, request.url));
  }
}

Verify the token

This will check the database and make sure we have a valid token. If not it will kick it back out to an error page. This will also handle a page where if they need to resend the email.

export async function verifyUser(verificationToken: string) {
  if (!verificationToken) {
    throw new Error("No verification token provided");
  }

  const user = await db
    .select()
    .from(users)
    .where(eq(users.verificationToken, verificationToken))
    .limit(1)
    .then((rows) => rows[0]);

  if (!user) {
    throw new Error("Invalid verification token");
  }

  if (user.verified) {
    throw new Error("User already verified");
  }

  if (
    user.verificationTokenExpiresAt &&
    user.verificationTokenExpiresAt < new Date()
  ) {
    throw new Error("Verification token expired");
  }

  const updatedUser = await db
    .update(users)
    .set({
      verified: true,
    })
    .where(eq(users.id, user.id))
    .returning();

  return updatedUser[0];
}

Conclusion

Continue to the next article about user login here:

NextJS User Login


More Articles

3 Kinds of Spam

3 Kinds of Spam

Check your spam folder to see if my services are working

Creating a Confirm Dialog in React and Tailwind CSS

Creating a Confirm Dialog in React and Tailwind CSS

In this article we will go over how to create a confirm dialog with React and Tailwind CSS.