DEV Community

Cover image for How to build a register user flow in Next.js 15 (frontend, backend, database, email)
GDS K S
GDS K S

Posted on

How to build a register user flow in Next.js 15 (frontend, backend, database, email)

This is part 3 of the series on building an auth system from scratch. Today: the register user flow. By the end you will have a working signup page, a server endpoint, a users table, and a verification email getting delivered.

The flow, end to end

Here is what happens when a user hits "Create account":

+-------+        +------------+       +-----------+      +--------+
|Browser|  POST  |  Next.js   | INSERT| Postgres  | SEND |Resend  |
|       |------->|/api/       |------>|  users    |----->|        |
|       |        |auth/       |       |  (verify=F|      |        |
|       |<-------|register    |<------|  id=123)  |<-----|        |
|       |  200   |            |       +-----------+      +--------+
+-------+        +------------+
   |                   |
   | GET /dashboard    | 302 to /check-email
   v                   v
(not signed in yet)    (shows "check your inbox" screen)
Enter fullscreen mode Exit fullscreen mode

And then, separately, when the user clicks the verification link:

+-------+          +------------+       +-----------+
|Email  |  GET     |  Next.js   | UPDATE| Postgres  |
|client |--token-->|/api/       |------>|  users    |
|       |          |auth/verify |       |  verify=T |
|       |<---------|            |<------|           |
|       |  302 to  +------------+       +-----------+
|       |  /login          |
+-------+                   |
                            | set session cookie
                            v
                       (now signed in)
Enter fullscreen mode Exit fullscreen mode

Note the two separate round trips. We do not sign the user in until the email is verified. If you sign them in at registration, you effectively have no email verification because a bot can register a throwaway address, get a session, and cause damage before the email lands.

The database table

A quick note before the SQL. The DIY sections of this series show the schema you would build yourself. If you are using kavachOS, you will never write this SQL by hand. pnpm kavachos migrate ships a vetted version of these tables and keeps them upgraded. I am still showing the schema because reading it is how you understand what is happening under your auth library, whether that library is mine or someone else's.

From article 02, for reference:

create table users (
  id            bigserial primary key,
  email         citext not null unique,
  password_hash text not null,
  email_verified boolean not null default false,
  created_at    timestamptz not null default now(),
  updated_at    timestamptz not null default now()
);

create table email_verification_tokens (
  id         bigserial primary key,
  user_id    bigint not null references users(id) on delete cascade,
  token_hash text not null unique,
  expires_at timestamptz not null,
  used_at    timestamptz,
  created_at timestamptz not null default now()
);

create index idx_evt_user_unused
  on email_verification_tokens(user_id) where used_at is null;
Enter fullscreen mode Exit fullscreen mode

Three things worth calling out:

citext for email. Postgres has a case-insensitive text type. With it, Alice@Example.com and alice@example.com collide on the unique constraint. Without it, you get duplicate accounts. Enable the extension once with CREATE EXTENSION citext;.

on delete cascade on the token. If a user is deleted, their verification tokens go with them. You do not want orphaned rows.

Partial index on unused tokens. The lookup is always "find the unused token for this user". A partial index is smaller and faster than a full one.

The frontend form

// app/auth/register/page.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";

export default function Register() {
  const router = useRouter();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const rules = {
    minLength: password.length >= 12,
    hasNumber: /[0-9]/.test(password),
    hasLower: /[a-z]/.test(password),
    hasUpper: /[A-Z]/.test(password),
  };
  const valid = Object.values(rules).every(Boolean);

  async function submit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    setPending(true);

    const res = await fetch("/api/auth/register", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ email, password }),
    });

    setPending(false);

    if (res.status === 429) {
      setError("Too many attempts. Try again in a minute.");
      return;
    }
    if (!res.ok) {
      setError("Something went wrong. Try again.");
      return;
    }
    router.push("/auth/check-email");
  }

  return (
    <form onSubmit={submit} className="max-w-sm mx-auto mt-16 space-y-4">
      <h1 className="text-2xl font-semibold">Create account</h1>

      <label className="block">
        <span className="text-sm">Email</span>
        <input
          type="email"
          required
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="w-full rounded border p-2 mt-1"
          autoComplete="email"
        />
      </label>

      <label className="block">
        <span className="text-sm">Password</span>
        <input
          type="password"
          required
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          className="w-full rounded border p-2 mt-1"
          autoComplete="new-password"
        />
      </label>

      <ul className="text-xs space-y-1">
        <Rule ok={rules.minLength}>At least 12 characters</Rule>
        <Rule ok={rules.hasLower}>A lowercase letter</Rule>
        <Rule ok={rules.hasUpper}>An uppercase letter</Rule>
        <Rule ok={rules.hasNumber}>A number</Rule>
      </ul>

      {error && <p className="text-red-600 text-sm">{error}</p>}

      <button
        type="submit"
        disabled={!valid || pending}
        className="w-full rounded bg-black text-white py-2 disabled:opacity-50"
      >
        {pending ? "Creating..." : "Create account"}
      </button>
    </form>
  );
}

function Rule({ ok, children }: { ok: boolean; children: React.ReactNode }) {
  return (
    <li className={ok ? "text-green-600" : "text-zinc-500"}>
      {ok ? "" : ""} {children}
    </li>
  );
}
Enter fullscreen mode Exit fullscreen mode

A few notes on the form itself.

The autoComplete="new-password" tells browsers and password managers that this is a new password, which prevents them from autofilling an existing password and also prompts 1Password / Bitwarden / the browser to offer to generate a strong password.

The password rules are rendered as you type. I know there is a whole debate about password rules (NIST now says long is better than complex), but shipping "12 chars minimum, mixed case, a number" keeps the bulk of obvious bad passwords out without being annoying.

Never return a specific error for "email already exists" from the server, and never show it on the frontend. That is account enumeration.

The server endpoint

// app/api/auth/register/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import bcrypt from "bcrypt";
import { randomBytes, createHash } from "crypto";
import { db } from "@/db";
import { users, emailVerificationTokens } from "@/db/schema";
import { eq } from "drizzle-orm";
import { sendVerificationEmail } from "@/lib/email";
import { rateLimit } from "@/lib/rate-limit";

const body = z.object({
  email: z.string().email().max(254),
  password: z
    .string()
    .min(12)
    .max(256)
    .regex(/[a-z]/)
    .regex(/[A-Z]/)
    .regex(/[0-9]/),
});

export async function POST(req: NextRequest) {
  const ip = req.headers.get("x-forwarded-for") ?? "unknown";
  if (await rateLimit(`register:${ip}`, { max: 3, window: 60 })) {
    return NextResponse.json({ ok: true });
  }

  const parsed = body.safeParse(await req.json());
  if (!parsed.success) {
    return NextResponse.json({ ok: true });
  }

  const email = parsed.data.email.toLowerCase().trim();
  const passwordHash = await bcrypt.hash(parsed.data.password, 12);

  const existing = await db.query.users.findFirst({
    where: eq(users.email, email),
  });

  if (existing) {
    return NextResponse.json({ ok: true });
  }

  const [user] = await db
    .insert(users)
    .values({ email, passwordHash, emailVerified: false })
    .returning({ id: users.id });

  const raw = randomBytes(32).toString("base64url");
  const tokenHash = createHash("sha256").update(raw).digest("hex");

  await db.insert(emailVerificationTokens).values({
    userId: user.id,
    tokenHash,
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
  });

  await sendVerificationEmail(email, raw);

  return NextResponse.json({ ok: true });
}
Enter fullscreen mode Exit fullscreen mode

The thing people miss on this endpoint: every branch returns the same 200. Rate limit hit? 200. Email malformed? 200. User already exists? 200. The only thing that changes is whether an email gets sent.

This is uncomfortable the first time you write it. You feel like you are hiding errors. You are. From attackers. The legitimate user gets the experience they expect: they click "Create account", they get told to check their email, and either they registered a new account or they already had one (in which case they can go log in or use the password reset flow).

Put an "if you already have an account, log in" link on the confirmation page and this flow handles both paths gracefully.

The verification email

// lib/email.ts
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY!);
const APP_URL = process.env.APP_URL ?? "https://example.com";

export async function sendVerificationEmail(to: string, rawToken: string) {
  const link = `${APP_URL}/auth/verify?token=${rawToken}`;

  await resend.emails.send({
    from: "hello@example.com",
    to,
    subject: "Confirm your email",
    html: `
      <p>Hi,</p>
      <p>Click the link below to confirm your email and activate your account.</p>
      <p><a href="${link}">Confirm email</a></p>
      <p>This link expires in 24 hours. If you did not create an account, ignore this email.</p>
    `,
    text: `Confirm your email: ${link}\n\nThis link expires in 24 hours. If you did not create an account, ignore this email.`,
  });
}
Enter fullscreen mode Exit fullscreen mode

Always include a plain text part. Gmail scores emails that only have HTML a little worse for spam, and some corporate clients only show the text version.

The "If you did not create an account" line is critical. If someone else used this email to register, the real owner needs a way to know and a reason to ignore it.

The verification endpoint

// app/api/auth/verify/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createHash } from "crypto";
import { db } from "@/db";
import { users, emailVerificationTokens } from "@/db/schema";
import { and, eq, gt, isNull } from "drizzle-orm";

export async function GET(req: NextRequest) {
  const token = req.nextUrl.searchParams.get("token");
  if (!token) {
    return NextResponse.redirect(
      new URL("/auth/verify-failed", req.url),
    );
  }

  const tokenHash = createHash("sha256").update(token).digest("hex");

  const row = await db.query.emailVerificationTokens.findFirst({
    where: and(
      eq(emailVerificationTokens.tokenHash, tokenHash),
      gt(emailVerificationTokens.expiresAt, new Date()),
      isNull(emailVerificationTokens.usedAt),
    ),
  });

  if (!row) {
    return NextResponse.redirect(
      new URL("/auth/verify-failed", req.url),
    );
  }

  await db.transaction(async (tx) => {
    await tx
      .update(users)
      .set({ emailVerified: true })
      .where(eq(users.id, row.userId));
    await tx
      .update(emailVerificationTokens)
      .set({ usedAt: new Date() })
      .where(eq(emailVerificationTokens.id, row.id));
  });

  return NextResponse.redirect(new URL("/auth/login?verified=1", req.url));
}
Enter fullscreen mode Exit fullscreen mode

Same pattern as the reset flow. Hash the incoming token, look it up, check expiry and used state, update in a transaction.

We redirect to /login?verified=1 rather than signing the user in automatically. You can argue either way. Signing them in is more convenient but means holding a valid verification link becomes equivalent to holding a password. Redirecting to login makes them prove they know the password. I default to the redirect.

Testing from the terminal

Before you ship, smoke test with curl:

# Register a new user
$ curl -i -X POST http://localhost:3000/api/auth/register \
  -H 'content-type: application/json' \
  -d '{"email":"alice@example.com","password":"Correct-Horse-1"}'

HTTP/1.1 200 OK
content-type: application/json

{"ok":true}

# Try to register the same email again. Same 200.
$ curl -i -X POST http://localhost:3000/api/auth/register \
  -H 'content-type: application/json' \
  -d '{"email":"alice@example.com","password":"Different-Horse-2"}'

HTTP/1.1 200 OK
content-type: application/json

{"ok":true}

# Try a malformed email. Still 200.
$ curl -i -X POST http://localhost:3000/api/auth/register \
  -H 'content-type: application/json' \
  -d '{"email":"not-an-email","password":"Correct-Horse-1"}'

HTTP/1.1 200 OK
content-type: application/json

{"ok":true}

# Fire the rate limit.
$ for i in $(seq 1 10); do
    curl -s -X POST http://localhost:3000/api/auth/register \
      -H 'content-type: application/json' \
      -d "{\"email\":\"burst${i}@example.com\",\"password\":\"Correct-Horse-1\"}"
    echo
  done
Enter fullscreen mode Exit fullscreen mode

If any of those return anything other than 200, you have an enumeration leak. Fix it before moving on.

Check the database:

$ psql $DATABASE_URL -c "select id, email, email_verified from users;"
 id |      email       | email_verified
----+------------------+----------------
  1 | alice@example.com| f
Enter fullscreen mode Exit fullscreen mode

And the tokens:

$ psql $DATABASE_URL -c "select user_id, expires_at, used_at from email_verification_tokens;"
 user_id |        expires_at        | used_at
---------+--------------------------+---------
       1 | 2026-04-19 14:30:22+00   | null
Enter fullscreen mode Exit fullscreen mode

Now click the link in your email (or look in the Resend dashboard if you have not hooked up a real inbox):

$ curl -i "http://localhost:3000/api/auth/verify?token=THE_RAW_TOKEN_FROM_THE_EMAIL"

HTTP/1.1 302 Found
location: /auth/login?verified=1
Enter fullscreen mode Exit fullscreen mode

Check the database again:

$ psql $DATABASE_URL -c "select email_verified from users where id = 1;"
 email_verified
----------------
 t
Enter fullscreen mode Exit fullscreen mode

That is the whole flow.

The same thing in kavachOS

If you do not want to write any of the code above, the kavachOS version is:

Install

pnpm add kavachos @kavachos/nextjs
pnpm kavachos migrate
Enter fullscreen mode Exit fullscreen mode

Configure

// auth.ts
import { kavachos } from "kavachos";
import { nextjsAdapter } from "@kavachos/nextjs";

export const auth = kavachos({
  adapter: nextjsAdapter(),
  database: process.env.DATABASE_URL!,
  providers: {
    password: {
      minLength: 12,
      rules: { uppercase: true, lowercase: true, number: true },
    },
  },
  emailVerification: {
    required: true,
    tokenTTL: "24h",
  },
  email: {
    provider: "resend",
    apiKey: process.env.RESEND_API_KEY!,
    from: "hello@example.com",
  },
});
Enter fullscreen mode Exit fullscreen mode

Mount the routes

// app/api/auth/[...kavachos]/route.ts
import { auth } from "@/auth";
export const { GET, POST } = auth.handlers;
Enter fullscreen mode Exit fullscreen mode

Use the register hook

// app/auth/register/page.tsx
"use client";
import { useAuth } from "@kavachos/nextjs";

export default function Register() {
  const { register, pending, error } = useAuth();
  return (
    <form action={register}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button disabled={pending}>Create account</button>
      {error && <p>{error.message}</p>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

That is the whole thing. Email verification is on by default. The rate limits, the constant-time responses, the one-time-use tokens, all there. Docs at kavachos.com/docs/register.

Three things I see go wrong in review

Returning different statuses for known vs unknown emails. The fix is always 200. I know it feels wrong. Do it anyway.

Using Math.random() for the token. It is not cryptographic. It is a deterministic PRNG seeded from process start time. Use crypto.randomBytes(32).

No rate limit on register. Register is one of the most abused endpoints because it costs you money (email sends) and attracts spam accounts. A hard limit of 3 per IP per minute is reasonable for humans and expensive for bots.

What is next

Article 04: the login flow. Session cookies, CSRF, remember me, and how to measure whether your timing is leaking. If you have been following along with the starter repo, run git checkout 04-login after article 4 ships.


Comment with your hardest register bug. I will write it up as a bonus piece.


Gagan Deep Singh builds open source tools at Glincker. Currently working on kavachOS (open source auth for AI agents and humans) and AskVerdict (multi-model AI verdicts).

If this was useful, follow me on Dev.to or X where I post weekly.

Top comments (0)