Build a custom flow for handling email links
Email links can be used to sign up new users, sign in existing users, or allow existing users to verify newly added email addresses to their user profiles.
The email link flow works as follows:
- The user enters their email address and asks for an email link.
- Clerk sends an email to the user, containing a link to the verification URL.
- The user visits the email link, either on the same device where they entered their email address or on a different device, .
- Clerk verifies the user's identity and advances any sign-up or sign-in attempt that might be in progress.
- If the verification is successful, the user is authenticated or their email address is verified, depending on the reason for the email link.
This guide demonstrates how to use Clerk's API to build a custom flow for handling email links. It covers the following scenarios:
Enable email link authentication
To allow your users to sign up or sign in using email links, you must first configure the appropriate settings in the Clerk Dashboard. For the examples in this guide to work, you must enable the following settings. If your application requires more settings to be enabled, you'll need to combine this custom flow with the other appropriate custom flows.
- In the Clerk Dashboard, navigate to the User & authentication page.
- Enable Sign-up with email. Enable Verify at sign-up and enable Email verification link.
- Enable Sign-in with email. Enable Email verification link. By default, setting is enabled. This guide includes the code necessary to handle this setting, but you can remove it if you don't want to enforce this setting; there will be a comment in the code to indicate where to remove the code.
Sign-up flow
- When the form is submitted, initiate the sign-up process by collecting the user's email address with the
signUp.create()method. This creates a new sign-up attempt. - Call the
signUp.verifications.sendEmailLink()method to send a link to the provided email address so the user can verify their email address. This method requires specifying averificationUrl, which is the destination that the link will redirect the user to. - Call
signUp.verifications.waitForEmailLinkVerification()to await the result of the verification. - On your verification page (the URL you set as the
verificationUrlparameter), use thesignUp.verifications.emailLinkVerificationobject to access the result of the verification. Use thestatusproperty to check the result of the verification and handle it accordingly.
'use client'
import * as React from 'react'
import { useSignUp } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'
export default function SignUpPage() {
const { signUp, errors } = useSignUp()
const router = useRouter()
const [emailAddress, setEmailAddress] = React.useState('')
const [verifying, setVerifying] = React.useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setVerifying(true)
// Start the sign-up process by creating the sign-up attempt
const { error: createError } = await signUp.create({ emailAddress })
if (createError) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
console.error(JSON.stringify(createError, null, 2))
setVerifying(false)
return
}
// Dynamically set the host domain for dev and prod
// You could instead use an environment variable or other source for the host domain
const protocol = window.location.protocol
const host = window.location.host
// Send the user an email with the email link
const { error: sendLinkError } = await signUp.verifications.sendEmailLink({
// URL to navigate to after the user visits the link in their email
verificationUrl: `${protocol}//${host}/sign-up/verify`,
})
if (sendLinkError) {
console.error(JSON.stringify(sendLinkError, null, 2))
setVerifying(false)
return
}
// If email link sent successfully, wait for the user to visit the link in their email
const { error: waitForVerificationError } =
await signUp.verifications.waitForEmailLinkVerification()
if (waitForVerificationError) {
console.error(JSON.stringify(waitForVerificationError, null, 2))
setVerifying(false)
return
}
if (signUp.status === 'complete') {
const { error: finalizeError } = await signUp.finalize({
navigate: ({ session, decorateUrl }) => {
if (session?.currentTask) {
// Handle session tasks
// See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
console.log(session.currentTask)
return
}
const url = decorateUrl('/')
if (url.startsWith('http')) {
window.location.href = url
} else {
router.push(url)
}
},
})
if (finalizeError) {
console.error(JSON.stringify(finalizeError, null, 2))
}
} else {
// Check why the sign-up is not complete
console.error('Sign-up attempt not complete:', signUp)
}
}
async function reset(e: React.FormEvent) {
e.preventDefault()
setVerifying(false)
}
if (verifying) {
return (
<div>
<p>Check your email and visit the link that was sent to you.</p>
<form onSubmit={reset}>
<button type="submit">Restart</button>
</form>
{/* Required for sign-up flows. Clerk's bot sign-up protection is enabled by default */}
<div id="clerk-captcha" />
</div>
)
}
return (
<div>
<h1>Sign up</h1>
<form onSubmit={handleSubmit}>
<label htmlFor="emailAddress">Enter email address</label>
<input
id="emailAddress"
name="emailAddress"
type="email"
value={emailAddress}
onChange={(e) => setEmailAddress(e.target.value)}
/>
{errors.fields.emailAddress && <p>{errors.fields.emailAddress.message}</p>}
<button type="submit">Continue</button>
</form>
{/* For your debugging purposes. You can just console.log errors, but we put them in the UI for convenience */}
{errors && <p>{JSON.stringify(errors, null, 2)}</p>}
{/* Required for sign-up flows. Clerk's bot sign-up protection is enabled by default */}
<div id="clerk-captcha" />
</div>
)
}'use client'
import { useSignUp } from '@clerk/nextjs'
import Link from 'next/link'
export default function VerifyEmailLink() {
const { signUp } = useSignUp()
const verification = signUp.verifications.emailLinkVerification
if (!verification) {
return <div>Loading...</div>
}
if (verification.status === 'failed') {
return (
<div>
<h1>Verify your email</h1>
<p>The email link verification failed.</p>
<Link href="/sign-up">Sign up</Link>
</div>
)
}
if (verification.status === 'expired') {
return (
<div>
<h1>Verify your email</h1>
<p>The email link has expired.</p>
<Link href="/sign-up">Sign up</Link>
</div>
)
}
// OPTIONAL: This check is only required if you have
// the 'Require the same device and browser' setting
// enabled in the Clerk Dashboard
if (verification.status === 'client_mismatch') {
return (
<div>
<h1>Verify your email</h1>
<p>
You must complete the email link sign-up on the same device and browser that you started
it on.
</p>
<Link href="/sign-up">Sign up</Link>
</div>
)
}
return (
<div>
<h1>Verify your email</h1>
<p>Successfully verified email. You can close this tab.</p>
</div>
)
}- When the form is submitted, initiate the sign-in process by calling the
signIn.emailLink.sendLink()method to send a link to the provided email address so the user can use it to sign in. This method requires specifying averificationUrl, which is the destination that the link will redirect the user to. - Call
signIn.emailLink.waitForVerification()to await the result of the verification. - On your verification page (the URL you set as the
verificationUrlparameter), use thesignIn.emailLink.verificationobject to access the result of the verification. Use thestatusproperty to check the result of the verification and handle it accordingly.
'use client'
import * as React from 'react'
import { useSignIn } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'
export default function SignInPage() {
const { signIn, errors } = useSignIn()
const router = useRouter()
const [emailAddress, setEmailAddress] = React.useState('')
const [verified, setVerified] = React.useState(false)
const [verifying, setVerifying] = React.useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
// Reset states in case user resubmits form mid sign-in
setVerified(false)
// Dynamically set the host domain for dev and prod
// You could instead use an environment variable or other source for the host domain
const protocol = window.location.protocol
const host = window.location.host
// Start the sign-in process by sending the user an email with the email link
const { error } = await signIn.emailLink.sendLink({
emailAddress,
// URL to navigate to after the user visits the link in their email
verificationUrl: `${protocol}//${host}/sign-in/verify`,
})
if (error) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
console.error(JSON.stringify(error, null, 2))
return
}
setVerifying(true)
// If email link sent successfully, wait for the user to visit the link in their email
const { error: waitForVerificationError } = await signIn.emailLink.waitForVerification()
if (waitForVerificationError) {
console.error(JSON.stringify(waitForVerificationError, null, 2))
setVerifying(false)
return
}
// Check the verification result
const verification = signIn.firstFactorVerification
// Handle if verification expired
if (verification.status === 'expired') {
console.error('Verification expired')
}
if (signIn.status === 'complete') {
const { error: finalizeError } = await signIn.finalize({
navigate: ({ session, decorateUrl }) => {
if (session?.currentTask) {
console.log(session.currentTask)
return
}
const url = decorateUrl('/')
if (url.startsWith('http')) {
window.location.href = url
} else {
router.push(url)
}
},
})
if (finalizeError) {
console.error(JSON.stringify(finalizeError, null, 2))
}
} else {
// Check why the sign-in is not complete
console.error('Sign-in attempt not complete:', signIn)
}
}
async function reset(e: React.FormEvent) {
e.preventDefault()
setVerifying(false)
}
const verification = signIn.firstFactorVerification
if (verification.status === 'expired') {
return (
<div>
<p>The email link has expired.</p>
<form onSubmit={reset}>
<button type="submit">Restart</button>
</form>
{/* For your debugging purposes. You can just console.log errors, but we put them in the UI for convenience */}
{errors && <p>{JSON.stringify(errors, null, 2)}</p>}
</div>
)
}
if (verifying) {
return (
<div>
<p>Check your email and visit the link that was sent to you.</p>
<form onSubmit={reset}>
<button type="submit">Restart</button>
</form>
{/* For your debugging purposes. You can just console.log errors, but we put them in the UI for convenience */}
{errors && <p>{JSON.stringify(errors, null, 2)}</p>}
</div>
)
}
if (verified) {
return <div>Signed in successfully!</div>
}
return (
<div>
<h1>Sign in</h1>
<form onSubmit={handleSubmit}>
<label htmlFor="emailAddress">Enter email address</label>
<input
id="emailAddress"
name="emailAddress"
type="email"
value={emailAddress}
onChange={(e) => setEmailAddress(e.target.value)}
/>
{errors.fields.identifier && <p>{errors.fields.identifier.message}</p>}
<button type="submit">Continue</button>
</form>
{/* For your debugging purposes. You can just console.log errors, but we put them in the UI for convenience */}
{errors && <p>{JSON.stringify(errors, null, 2)}</p>}
</div>
)
}'use client'
import { useSignIn } from '@clerk/nextjs'
import Link from 'next/link'
export default function VerifyEmailLink() {
const { signIn } = useSignIn()
const verification = signIn.emailLink.verification
if (!verification) {
return <div>Loading...</div>
}
if (verification.status === 'failed') {
return (
<div>
<h1>Verify your email</h1>
<p>The email link verification failed.</p>
<Link href="/sign-in">Sign in</Link>
</div>
)
}
if (verification.status === 'expired') {
return (
<div>
<h1>Verify your email</h1>
<p>The email link has expired.</p>
<Link href="/sign-in">Sign in</Link>
</div>
)
}
// OPTIONAL: This check is only required if you have
// the 'Require the same device and browser' setting
// enabled in the Clerk Dashboard
if (verification.status === 'client_mismatch') {
return (
<div>
<h1>Verify your email</h1>
<p>
You must complete the email link sign-in on the same device and browser that you started
it on.
</p>
<Link href="/sign-in">Sign in</Link>
</div>
)
}
return (
<div>
<h1>Verify your email</h1>
<p>Successfully verified email. You can close this tab.</p>
</div>
)
}Add new email flow
When a user adds an email address to their account, you can use email links to verify the email address.
- Every user has a
Userobject that represents their account. TheUserobject has aemailAddressesproperty that contains all the email addresses associated with the user. Use the useUser() hook to access theUserobject. - Pass the
User.createEmailAddress()method to the useReverification() hook to require the user to reverify their credentials before being able to add an email address to their account. - If the
createEmailAddress()function is successful, a newEmailAddressobject is created and stored inUser.emailAddresses. Use the newly createdEmailAddressobject to access thecreateEmailLinkFlow()method. - Use the
createEmailLinkFlow()method to access thestartEmailLinkFlow()method. Call thestartEmailLinkFlow()method to send an email with a verification link to the user. This method requires specifying aredirectUrl, which is the URL that the user will be redirected to after visiting the link in their email. - On your verification page (the URL you set as the
redirectUrlparameter), call the useClerk() hook to access thehandleEmailLinkVerification()method. - Call the
handleEmailLinkVerification()method to verify the email address.
'use client'
import * as React from 'react'
import { useUser, useReverification } from '@clerk/nextjs'
export default function Page() {
const { isLoaded, isSignedIn, user } = useUser()
const createEmailAddress = useReverification((email: string) =>
user?.createEmailAddress({ email }),
)
const [email, setEmail] = React.useState('')
const [verifying, setVerifying] = React.useState(false)
const [error, setError] = React.useState('')
// Handle loading state
if (!isLoaded) <p>Loading...</p>
// Handle signed-out state
if (!isSignedIn) <p>You must be signed in to access this page</p>
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
setVerifying(true)
// Add an unverified email address to user
const res = await createEmailAddress(email)
// Reload user to get updated User object
await user?.reload()
// Find the email address that was just added
const emailAddress = user?.emailAddresses.find((a) => a.id === res?.id)
if (!emailAddress) {
setError('Email address not found')
return
}
const { startEmailLinkFlow } = emailAddress.createEmailLinkFlow()
// Dynamically set the host domain for dev and prod
// You could instead use an environment variable or other source for the host domain
const protocol = window.location.protocol
const host = window.location.host
// Send the user an email with the verification link
startEmailLinkFlow({ redirectUrl: `${protocol}//${host}/account/add-email/verify` })
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
setError('An error occurred.')
}
}
async function reset(e: React.FormEvent) {
e.preventDefault()
setVerifying(false)
}
if (error) {
return (
<div>
<p>Error: {error}</p>
<button onClick={() => setError('')}>Try again</button>
</div>
)
}
if (verifying) {
return (
<div>
<p>Check your email and visit the link that was sent to you.</p>
<form onSubmit={reset}>
<button type="submit">Restart</button>
</form>
</div>
)
}
// Display the initial form to capture the email address
return (
<>
<h1>Add email</h1>
<div>
<form onSubmit={(e) => handleSubmit(e)}>
<label htmlFor="email">Enter email address</label>
<input
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button type="submit">Continue</button>
</form>
</div>
</>
)
}'use client'
import * as React from 'react'
import { useClerk } from '@clerk/nextjs'
import { EmailLinkErrorCodeStatus, isEmailLinkError } from '@clerk/nextjs/errors'
import Link from 'next/link'
export type VerificationStatus =
| 'expired'
| 'failed'
| 'loading'
| 'verified'
| 'verified_switch_tab'
| 'client_mismatch'
export default function VerifyEmailLink() {
const [verificationStatus, setVerificationStatus] = React.useState('loading')
const { handleEmailLinkVerification, loaded } = useClerk()
async function verify() {
try {
await handleEmailLinkVerification({})
setVerificationStatus('verified')
} catch (err: any) {
let status: VerificationStatus = 'failed'
if (isEmailLinkError(err)) {
// If link expired, set status to expired
if (err.code === EmailLinkErrorCodeStatus.Expired) {
status = 'expired'
} else if (err.code === EmailLinkErrorCodeStatus.ClientMismatch) {
// OPTIONAL: This check is only required if you have
// the 'Require the same device and browser' setting
// enabled in the Clerk Dashboard
status = 'client_mismatch'
}
}
setVerificationStatus(status)
return
}
}
React.useEffect(() => {
if (!loaded) return
verify()
}, [handleEmailLinkVerification, loaded])
if (verificationStatus === 'loading') {
return <div>Loading...</div>
}
if (verificationStatus === 'failed') {
return (
<div>
<h1>Verify your email</h1>
<p>The email link verification failed.</p>
<Link href="/account/add-email">Return to add email</Link>
</div>
)
}
if (verificationStatus === 'expired') {
return (
<div>
<h1>Verify your email</h1>
<p>The email link has expired.</p>
<Link href="/account/add-email">Return to add email</Link>
</div>
)
}
// OPTIONAL: This check is only required if you have
// the 'Require the same device and browser' setting
// enabled in the Clerk Dashboard
if (verificationStatus === 'client_mismatch') {
return (
<div>
<h1>Verify your email</h1>
<p>
You must complete the email link verification on the same device and browser as you
started it on.
</p>
<Link href="/account/add-email">Return to add email</Link>
</div>
)
}
return (
<div>
<h1>Verify your email</h1>
<p>Successfully added email!</p>
</div>
)
}Feedback
Last updated on