DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on • Updated on

15/ Sending email confirmations in Strapi

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 />;
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Email setup in Strapi

Strapi has some preset emails for account/email verification. Go to the Strapi admin:

Settings > Users & Permissions plugin > Email templates
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. We don't want to expose our backend url to a frontend user.
  2. 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>
Enter fullscreen mode Exit fullscreen mode

And save. There is one more setting:

Settings > Users & Permissions plugin > Advanced Settings > Redirection url
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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'.

unconfirmed signin error

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',
  }
);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 />;
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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:

Strapi error, already confirmed

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:

  1. The user is send an email with a frontend url + confirmation token. /confirmation/submit?confirmation=***
  2. The user is redirected to a confirmation message: 'validate your email'. /confirmation/message
  3. Upon clicking the email link the user visits: /confirmation/submit?confirmation=***
  4. 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.

Top comments (0)