Firebase Hooksv0.3.0

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.

modeSent byHook
signInuseEmailLinkSignIn sendLinkuseEmailLinkSignIn completeSignIn
resetPassworduseSendPasswordResetEmailuseConfirmPasswordReset
verifyEmailuseSendEmailVerification, useSignupuseVerifyEmail
verifyAndChangeEmailuseUpdateEmailuseVerifyEmail
recoverEmailFirebase, unprompted, after an address changeuseVerifyEmail

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

app/auth/action/page.tsx
'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; only confirm consumes 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.

On this page