The code in this chapter is available on github: branch emailconfirmation.
We have a full sign up form. When using this form a user is added to Strapi
. Because of our setup in the beginning of this series, Strapi
is actually already sending emails but we need to configure this. But first we need to create a page the user gets send to after a successful signup.
Confirmation message
We create a page and a component:
// frontend/src/app/(auth)/confirmation/message/page.tsx
import ConfirmationMessage from '@/components/auth/confirmation/ConfirmationMessage';
export default function page() {
return <ConfirmationMessage />;
}
// frontend/src/components/auth/confirmation/ConfirmationMessage.tsx
export default function ConfirmationMessage() {
return (
<div className='bg-zinc-100 rounded-sm px-4 py-8 mb-8'>
<h2 className='font-bold text-lg mb-4'>Please confirm your email.</h2>
<p>
We sent you an email with a confirmation link. Please open this email
and click the link to confirm your [project name] account and email.
</p>
</div>
);
}
Email setup in Strapi
Strapi
has some preset emails for account/email verification. Go to the Strapi admin
:
Settings > Users & Permissions plugin > Email templates
And click email address verification. We should set all of the fields like sender and name, ... in a real app but our interest now is the message textbox. By default it says:
<p>Thank you for registering!</p>
<p>You have to confirm your email address. Please click on the link below.</p>
<p><%= URL %>?confirmation=<%= CODE %></p>
<p>Thanks.</p>
This, obviously is our email body. We want to update this line: <%= URL %>?confirmation=<%= CODE %>
that resolves into this url:
http://localhost:1337/api/auth/email-confirmation?confirmation=3708c50f7ca98ef2654s89z46
URL
is our backend Strapi
url + the Strapi
email confirmation endpoint: /api/auth/email-confirmation
. Strapi
added a searchParam to this url: ?confirmation=123
. This is the email confirmation token. So this is what Strapi
sends out by default and you should never use this:
- We don't want to expose our backend url to a frontend user.
- There is no error handling on this endpoint. When a user clicks the link but it fails, the user will just get a plain json error object.
That being said, if a user were to click the link, it would confirm the user's email! Let's try it out. We opened the link, got redirect to the route /confirmEmail
. This page doesn't exist so we got a 404. But on checking our Strapi
backend, we did find out that our user is now confirmed: true
. Before it was false.
So, it seems we have working Strapi
endpoint to confirm the user's email but it behaves weirdly. On success, it redirects and on error it returns a Strapi
error message. In any case, we don't want the user to visit this url directly!
Plan
How do we solve this? We will create a new page in our frontend: /confirmation/submit
. We update the confirmation link in Strapi
so it points to this page. We will also add the confirmation token as a searchParam. In this page, we will handle the endpoint call.
Update the email in Strapi
. We just update the actual link in the email template to:
<p><a href="http://localhost:3000/confirmation/submit?confirmation=<%= CODE %>">Confirm your email link</a></p>
And save. There is one more setting:
Settings > Users & Permissions plugin > Advanced Settings > Redirection url
This controls were the Strapi
endpoint redirects to. We're are not going to use this but we will set it to http://localhost:3000
.
And that is the email part taken care of. On sign up, we send the user an email that contains a link to our frontend and will have the confirmation token attached to it as a searchParam. A quick test confirms that everything works. Great, let's make this frontend page.
Submit email confirmation
We make a new page and a component:
// frontend/src/app/(auth)/confirmation/submit/page.tsx
import ConfirmationSubmit from '@/components/auth/confirmation/ConfirmationSubmit';
type Props = {
searchParams: {
confirmation?: string,
},
};
export default async function page({ searchParams }: Props) {
return <ConfirmationSubmit confirmationToken={searchParams?.confirmation} />;
}
Note that we take the confirmation searchParam from the page and pass it to the component! Our component:
// frontend/src/components/auth/confirmation/ConfirmationSubmit.tsx
import Link from 'next/link';
type Props = {
confirmationToken?: string;
};
export function Wrapper({ children }: { children: React.ReactNode }) {
return (
<div className='bg-zinc-100 rounded-sm px-4 py-8 mb-8'>{children}</div>
);
}
export default async function ConfirmationSubmit({ confirmationToken }: Props) {
if (!confirmationToken || confirmationToken === '') {
return (
<Wrapper>
<h2 className='font-bold text-lg mb-4'>Error</h2>
<p>Token is not valid.</p>
</Wrapper>
);
}
// send email validation request to strapi and wait for the response.
try {
const strapiResponse = await fetch(
`${process.env.STRAPI_BACKEND_URL}/api/auth/email-confirmation?confirmation=${confirmationToken}`
);
if (!strapiResponse.ok) {
let error = '';
const contentType = strapiResponse.headers.get('content-type');
if (contentType === 'application/json; charset=utf-8') {
const data = await strapiResponse.json();
error = data.error.message;
} else {
error = strapiResponse.statusText;
}
return (
<Wrapper>
<h2 className='font-bold text-lg mb-4'>Error</h2>
<p>Error: {error}</p>
</Wrapper>
);
}
// success, do nothing
} catch (error: any) {
return (
<Wrapper>
<h2 className='font-bold text-lg mb-4'>Error</h2>
<p>{error.message}</p>
</Wrapper>
);
}
return (
<Wrapper>
<h2 className='font-bold text-lg mb-4'>Email confirmed.</h2>
<p>
Your email was successfully verified. You can now{' '}
<Link href='/login' className='underline'>
login
</Link>
.
</p>
</Wrapper>
);
}
This is actually quite a simple component. Firstly we check if there is a confirmationToken. If not, we just return a simple error. Next, we call the Strapi
endpoint with our token. This is a server component so we can do that inside the functional component.
We listen for an error: !strapiResponse.ok
and return the error to the user. We also wrapped our api call inside a try catch block where we again return the error.
On success, the Strapi
endpoint will still redirect. But, in our strapiResponse the status will be ok. So, a successful call of this endpoint will still be strapiResponse.ok
. When we are outside of the try catch block we know that there weren't any errors so we return a success message.
The success message confirms that the user's email is verified and prompts him to log in. And that is it. We now confirmed our user email + error handling. I'm not too fond of this Strapi
endpoint but it's the flow Strapi
gives us so we use it.
Request a new confirmation email
Things can go wrong. Maybe the user didn't find the email (spam folder?). Or maybe the confirmation token expired (I have no idea how long it is valid). But, this doesn't matter. At some point, you want to give the user the opportunity to request a new email confirmation.
This is a UX thing. How you implement this depends. The issue is where and when you give the user the opportunity to request a new confirmation email. It's delicate because it can be confusing. I chose to show this option in 2 places:
The first confirmation message. When signing up successfully, we redirect the user to a confirmation page (we sent you an email ...) Let's add it there. What do we add? A link to a page where the user can request a new confirmation email. We will build this page in a bit.
// frontend/src/components/auth/confirmation/ConfirmationMessage.tsx
import Link from 'next/link';
export default function ConfirmationMessage() {
return (
<div className='bg-zinc-100 rounded-sm px-4 py-8 mb-8'>
<h2 className='font-bold text-lg mb-4'>Please confirm your email.</h2>
<p>
We sent you an email with a confirmation link. Please open this email
and click the link to confirm your [project name] account and email.
</p>
<h3 className='font-bold my-4'>No email found?</h3>
<p>
If you did not receive an email with a confirmation link please check
your spam folder or wait a couple of minutes.
</p>
<p>
Still no email?{' '}
<Link href='/confirmation/newrequest' className='underline'>
Request a new confirmation email.
</Link>
</p>
</div>
);
}
This won't suffice. Maybe the user closed this page already. So I added an extra option to the sign in page. Let's say the user didn't find or open the confirmation email yet tries to sign in. Strapi
has a specific error for this: "Your account email is not confirmed". So we can listen for this error and then display a 'request a new confirmation message'.
Updated <SignInForm />
link
Request a new confirmation email page
This is the Strapi
endpoint:
const strapiResponse: any = await fetch(
process.env.STRAPI_BACKEND_URL + '/api/auth/send-email-confirmation',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
cache: 'no-cache',
}
);
It's a post request and we need to send an email in the body object. The response is pretty interesting. Strapi
will never confirm that a user with this email exists in the database. If an email was sent in the request, then this will be the response:
{
"email": "bob@example.com",
"sent": true
}
Regardless of the email being in the Strapi
database or not this will be the response. This is good. Strapi
will send back an error if there was no email in the request or the email was invalid. So, it is still possible to get an error.
We need a form where the user enters an email and submits. It needs error handling and loading state. On success it redirects to the confirm/message
page we created earlier of sign up. We don't need NextAuth
for this so we can work with server actions and useFormState
again. We need a page, a component and a server action:
// frontend/src/app/(auth)/confirmation/newrequest/page.tsx
import ConfirmationNewRequest from '@/components/auth/confirmation/ConfirmationNewRequest';
export default function page() {
return <ConfirmationNewRequest />;
}
// frontend/src/components/auth/confirmation/NewRequest.tsx
'use client';
import { useFormState } from 'react-dom';
import confirmationNewRequestAction from './confirmationNewRequestAction';
import PendingSubmitButton from '../PendingSubmitButton';
type InputErrorsT = {
email?: string[];
};
type InitialFormStateT = {
error: false;
};
type ErrorFormStateT = {
error: true;
message: string;
inputErrors?: InputErrorsT;
};
export type ConfirmationNewRequestFormStateT =
| InitialFormStateT
| ErrorFormStateT;
const initialState: InitialFormStateT = {
error: false,
};
export default function ConfirmationNewRequest() {
const [state, formAction] = useFormState<
ConfirmationNewRequestFormStateT,
FormData
>(confirmationNewRequestAction, initialState);
return (
<div className='mx-auto my-8 p-8 max-w-lg bg-zinc-100 rounded-sm'>
<h2 className='text-center text-2xl text-blue-400 mb-8 font-bold'>
Confirmation request
</h2>
<p className='mb-4'>
Request a new confirmation email. Maybe some info about token expiry or
limited request here.
</p>
<form action={formAction} className='my-8'>
<div className='mb-3'>
<label htmlFor='email' className='block mb-1'>
Email *
</label>
<input
type='email'
id='email'
name='email'
required
className='bg-white border border-zinc-300 w-full rounded-sm p-2'
/>
{state.error && state?.inputErrors?.email ? (
<div className='text-red-700' aria-live='polite'>
{state.inputErrors.email[0]}
</div>
) : null}
</div>
<div className='mb-3'>
<PendingSubmitButton />
</div>
{state.error && state.message ? (
<div className='text-red-700' aria-live='polite'>
{state.message}
</div>
) : null}
</form>
</div>
);
}
And finally our server action:
// frontend/src/components/auth/confirmation/ConfirmationNewrequestAction.ts
'use server';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { ConfirmationNewRequestFormStateT } from './ConfirmationNewRequest';
const formSchema = z.object({
email: z.string().email('Enter a valid email.').trim(),
});
export default async function confirmNewRequestAction(
prevState: ConfirmationNewRequestFormStateT,
formData: FormData
) {
const validatedFields = formSchema.safeParse({
email: formData.get('email'),
});
if (!validatedFields.success) {
return {
error: true,
inputErrors: validatedFields.error.flatten().fieldErrors,
message: 'Please verify your data.',
};
}
const { email } = validatedFields.data;
try {
const strapiResponse: any = await fetch(
process.env.STRAPI_BACKEND_URL + '/api/auth/send-email-confirmation',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
cache: 'no-cache',
}
);
// handle strapi error
if (!strapiResponse.ok) {
const response = {
error: true,
message: '',
};
// check if response in json-able
const contentType = strapiResponse.headers.get('content-type');
if (contentType === 'application/json; charset=utf-8') {
const data = await strapiResponse.json();
// we don't ever want to confirm that an email exists inside strapi DB
// but we can't redirect inside a try catch block
// return response only is this is not the case
// if it is the case we will fall through to the redirect
if (data.error.message !== 'Already confirmed') {
response.message = data.error.message;
return response;
}
} else {
response.message = strapiResponse.statusText;
return response;
}
}
// we redirect on success outside try catch block
} catch (error: any) {
// network error or something
return {
error: true,
message: 'message' in error ? error.message : error.statusText,
};
}
redirect('/confirmation/message');
}
There is nothing new here. I explained working with server actions and useFormState
in the previous chapter.
First note: While testing this flow I stumbled upon this error message:
What happened here is that the user is already verified and Strapi
then returned an error object with message: 'Already confirmed'. This is not good because it confirms that there is a user with this email in the DB and we won't want that.
To fix it, we listen for this error and do nothing when it occurs. The function will then finish the try catch block and continue with the redirect. In other words, when the user is already confirmed, we will redirect the user to /confirmation/message
even though we didn't actually send a new email.
This isn't optimal but it should suffice. It is very unlikely that a user would stumble upon this page when already confirmed. We could even guard this component so signed in users get a already confirmed message or something. I will leave this up to you.
Second note: this route could get spammed. Somebody continuously submitting here, causing you system to send emails. This is something you may want to guard against also.
Summary
That is all we need for account/email confirmation flow. Upon signing up:
- The user is send an email with a frontend url + confirmation token.
/confirmation/submit?confirmation=***
- The user is redirected to a confirmation message: 'validate your email'.
/confirmation/message
- Upon clicking the email link the user visits:
/confirmation/submit?confirmation=***
- This server component call
Strapi
with the token. It handles errors and on success asks the user to login.
We also added an option to request a new confirmation email. The user enters his email, Strapi
sends an email (if the email is in the DB) and the user goes to step 2. again.
At this point, we've handled sign in and up and email confirmation. The next chapters will handle the forgotten password. After that we will have to implement change password and lastly edit user flows.
If you want to support my writing, you can donate with paypal.