Problem
After a new user clicks the verification link in their inbox:
- Browser hits
GET /api/auth/verify-email?token=...
- Better Auth consumes the token and 302s to the configured
callbackURL.
- 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.
- 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-83 — emailVerification.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:
-
Server side — lib/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.)
-
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).
-
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
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.tsx — requireEmailVerification: true lives here.
lib/auth/config.ts — sendVerificationEmail callback.
lib/email/send-verification.ts — Resend send (URL transformation happens upstream).
docs/AUTH.md — operator guide; needs the new route added once shipped.
Problem
After a new user clicks the verification link in their inbox:
GET /api/auth/verify-email?token=...callbackURL.callbackURLset, the redirect lands on/, which has its ownuseEffectthat bounces to/chatif there's a session, otherwise to/login.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— setsrequireEmailVerification: truebut nocallbackURL.lib/auth/config.ts:71-83—emailVerification.sendVerificationEmailis wired to Resend. No server-sidecallbackURLoverride.app/login/[[...path]]/page.tsx— handlessign-in/sign-up/forgot-passwordsegments only. Noverifiedsegment.@better-auth-ui/react@1.6.27(the package actually installed, despitedocs/AUTH.mdstill saying@daveyplate/better-auth-ui— separate doc-drift bug, low priority) —dist/index.d.tsdoes NOT export a built-inverifyEmailview. The available views aresignIn,signUp,forgotPassword,magicLink,otp,signOut, etc. No out-of-the-box "you are verified" component.So: there's no prop on
AuthProvideror<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:
Server side —
lib/auth/config.tsemailVerificationblock: override the verify URL so the email link points at our new page:(Alternative: set a server-side
callbackURLvia thebetter-authAPI 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.)Client side — new route
app/login/verified/page.tsx:?token=...fromsearchParams; if the token's missing or already consumed, redirect to/loginwith a friendly toast/error (FR-025-friendly).<CheckCircle2 />+ "Email verified" + "Redirecting to sign-in in 5s..." + manual "Go to sign in now" link.useEffectfiresrouter.replace("/login")aftersetTimeout(..., 5000).Docs — update
docs/AUTH.mdRoutes table to include/login/verifiedand the redirect flow.Why this approach
AuthProviderAPI change, no breaking change to other flows.page.tsx, sopnpm dev/next buildpicks it up automatically — no auth/route table to maintain.AuthProviderprops unchanged; the rest of the auth UI (sign-in, sign-up, forgot password) stays on the better-auth-ui<Auth>component.Acceptance criteria
/login/verified, sees a "Verified" page./login(or to/chatif a session is already established viaautoSignInAfterVerification: true).docs/AUTH.mdreflects the new route.Out of scope
TDD note
Per rule #2, write the failing test first:
tests/api/auth/verify-email.test.ts— mocksendVerificationEmailand 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 + thatrouter.replaceis called after 5s (use vitest fake timers).Skip TDD only for the trivial CSS / copy.
Related
app/auth-shell.tsx—requireEmailVerification: truelives here.lib/auth/config.ts—sendVerificationEmailcallback.lib/email/send-verification.ts— Resend send (URL transformation happens upstream).docs/AUTH.md— operator guide; needs the new route added once shipped.