One page for every emailed link
Firebase sends sign-in, password-reset and verification links to a single URL. Dispatch on mode and hand each one to its hook.
Every link Firebase emails lands on Firebase's own hosted pages by default. To handle them in your app instead, you point Firebase at one URL — every link goes there, with a mode parameter saying which flow it is. This is Firebase's custom email action handler pattern; the page below is that handler, built from the hooks.
So you write one page that dispatches on mode, and a hook handles each branch.
mode | Sent by | Hook |
|---|---|---|
signIn | useEmailLinkSignIn sendLink | useEmailLinkSignIn completeSignIn |
resetPassword | useSendPasswordResetEmail | useConfirmPasswordReset |
verifyEmail | useSendEmailVerification, useSignup | useVerifyEmail |
verifyAndChangeEmail | useUpdateEmail | useVerifyEmail |
recoverEmail | Firebase, unprompted, after an address change | useVerifyEmail |
The last two are easy to miss. useUpdateEmail uses verifyBeforeUpdateEmail, so its link arrives as verifyAndChangeEmail, not verifyEmail — a handler that only knows the first three sends your own users to a dead end. And recoverEmail goes to the old address whenever an email changes, so the owner can undo it; you never send it, but it arrives. Both apply an action code, which is what useVerifyEmail does.
The page
'use client';
import {
useConfirmPasswordReset,
useEmailLinkSignIn,
useVerifyEmail,
} from '@timonwa/firebase-hooks/auth';
import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
export default function AuthActionPage() {
return (
<Suspense fallback={<Spinner />}>
<AuthAction />
</Suspense>
);
}
function AuthAction() {
const params = useSearchParams();
const mode = params.get('mode');
const oobCode = params.get('oobCode');
switch (mode) {
case 'signIn':
return <CompleteSignIn />;
case 'resetPassword':
return <ResetPassword oobCode={oobCode} />;
case 'verifyEmail':
case 'verifyAndChangeEmail':
case 'recoverEmail':
return <VerifyEmail oobCode={oobCode} />;
default:
return <p>This link isn't one we recognise.</p>;
}
}Each branch is then the ordinary hook usage:
function VerifyEmail({ oobCode }: { oobCode: string | null }) {
const { status, error } = useVerifyEmail(oobCode);
if (status === 'pending') return <Spinner />;
if (status === 'error') return <ErrorState message={error} />;
return <p>Email verified.</p>;
}
function ResetPassword({ oobCode }: { oobCode: string | null }) {
const { verifyCode, confirm, isPending, error } = useConfirmPasswordReset();
// verifyCode(oobCode) first — it returns the account email, so the form can
// say whose password is being reset before asking for a new one.
}
function CompleteSignIn() {
const { completeSignIn } = useEmailLinkSignIn();
// Pass the full URL, not the oobCode: the link itself is the credential.
// A failure with needsEmail means the link opened on another device.
}Set the action URL per template
This is the step that silently half-works. The action URL is configured per email template, not once for the project — Authentication → Templates, then edit each one.
Set it for the sign-in template only and password resets keep going to Firebase's hosted page, with no error anywhere to tell you. If two of your three flows work and one doesn't, this is why.
Every mode except signIn needs it — password reset, email verification, and email change each have their own template. signIn is the exception: useEmailLinkSignIn passes its return URL in actionCodeSettings at send time, so it doesn't depend on the console setting.
Add your domain under Authentication → Settings → Authorized domains. localhost is there by default.
In Next.js, wrap it in Suspense
useSearchParams opts the route out of prerendering unless it sits under a <Suspense> boundary — without one the build fails. That's the only reason the page above splits into two components: the outer one exists to hold the boundary.
Testing it
The codes are single-use and expire, so you need a real email each time. Two things make that less painful:
- Verification links can be re-sent from the app with
useSendEmailVerification. - A reset code can be checked without spending it —
verifyCode(oobCode)validates and returns the email; onlyconfirmconsumes it.
useVerifyEmail applies its code on mount and is guarded against React Strict Mode's double effect, which would otherwise spend the code on the first run and report failure on the second.