Skip to content

[Feat]: Email verification has no result page between verify and login redirect #9

Description

@FireTable

Problem

After a new user clicks the verification link in their inbox:

  1. Browser hits GET /api/auth/verify-email?token=...
  2. Better Auth consumes the token and 302s to the configured callbackURL.
  3. With no callbackURL set, the redirect lands on /, which has its own useEffect that bounces to /chat if there's a session, otherwise to /login.
  4. The user lands on the bare sign-in form with zero indication that verification just succeeded. Confusing.

We want a friendly "verified, redirecting in 5s" result page in between, so the user knows what happened and what to do next.

Investigation

What I checked:

  • app/auth-shell.tsx:19-23 — sets requireEmailVerification: true but no callbackURL.
  • lib/auth/config.ts:71-83emailVerification.sendVerificationEmail is wired to Resend. No server-side callbackURL override.
  • app/login/[[...path]]/page.tsx — handles sign-in / sign-up / forgot-password segments only. No verified segment.
  • @better-auth-ui/react@1.6.27 (the package actually installed, despite docs/AUTH.md still saying @daveyplate/better-auth-ui — separate doc-drift bug, low priority) — dist/index.d.ts does NOT export a built-in verifyEmail view. The available views are signIn, signUp, forgotPassword, magicLink, otp, signOut, etc. No out-of-the-box "you are verified" component.

So: there's no prop on AuthProvider or <Auth> we can flip to make this happen. We have to ship a tiny result page of our own.

Proposed fix

Smallest version that works:

  1. Server sidelib/auth/config.ts emailVerification block: override the verify URL so the email link points at our new page:

    emailVerification: {
      sendVerificationEmail: async ({ user, url }) => {
        // Re-point the link Better Auth generates — by default it 302s back to
        // the configured callbackURL (here `/`), which gives the user no
        // feedback. Route through `/login/verified` instead.
        const result = await sendVerificationEmail({
          to: user.email,
          url: url.replace(/\/(login|api\/auth\/verify-email).*$/, "/login/verified"),
        });
        // ...existing error handling
      },
    },

    (Alternative: set a server-side callbackURL via the better-auth API once we confirm it accepts one in the emailVerification block — file a follow-up if the regex route turns out to be too brittle for Better Auth's token-shape changes.)

  2. Client side — new route app/login/verified/page.tsx:

    • Server component reads ?token=... from searchParams; if the token's missing or already consumed, redirect to /login with a friendly toast/error (FR-025-friendly).
    • Client subcomponent renders <CheckCircle2 /> + "Email verified" + "Redirecting to sign-in in 5s..." + manual "Go to sign in now" link.
    • useEffect fires router.replace("/login") after setTimeout(..., 5000).
    • Clears the countdown if the user moves the mouse or focuses the manual link (small a11y nicety, optional).
  3. Docs — update docs/AUTH.md Routes table to include /login/verified and the redirect flow.

Why this approach

  • One new file + one URL rewrite inside the existing callback. No new dependency, no AuthProvider API change, no breaking change to other flows.
  • The page is a page.tsx, so pnpm dev / next build picks it up automatically — no auth/route table to maintain.
  • Keeps the AuthProvider props unchanged; the rest of the auth UI (sign-in, sign-up, forgot password) stays on the better-auth-ui <Auth> component.

Acceptance criteria

  • New user signs up, gets email, clicks link → lands on /login/verified, sees a "Verified" page.
  • After 5 seconds the page redirects to /login (or to /chat if a session is already established via autoSignInAfterVerification: true).
  • Manual "Go to sign in" link works immediately.
  • An invalid / expired token still results in a sensible redirect (no infinite spinner).
  • docs/AUTH.md reflects the new route.

Out of scope

TDD note

Per rule #2, write the failing test first:

  • tests/api/auth/verify-email.test.ts — mock sendVerificationEmail and assert the URL passed in points to /login/verified (not /).
  • tests/frontend/login/verified.test.tsx — render the page with ?token=..., assert the success copy + that the manual link is present + that router.replace is called after 5s (use vitest fake timers).

Skip TDD only for the trivial CSS / copy.

Related

  • app/auth-shell.tsxrequireEmailVerification: true lives here.
  • lib/auth/config.tssendVerificationEmail callback.
  • lib/email/send-verification.ts — Resend send (URL transformation happens upstream).
  • docs/AUTH.md — operator guide; needs the new route added once shipped.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions