15/ Sending email confirmations in Strapi
Tags: strapi, nextjs
Published: 2024-04-01 16:34:20

The code in this chapter is available on [github: branch emailconfirmation](https://github.com/peterlidee/NNAS/tree/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](https://dev.to/peterlidee/next-nextauth-strapi-setting-up-our-project-4jc7), `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:

```jsx
// frontend/src/app/(auth)/confirmation/message/page.tsx

import ConfirmationMessage from '@/components/auth/confirmation/ConfirmationMessage';

export default function page() {
  return <ConfirmationMessage />;
}
```

```jsx
// 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:

```html
<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:

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>
```

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:

```jsx
// 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:

```jsx
// 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.

```jsx
// 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'.

![unconfirmed signin error](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1bmta4830ulwx5vttxke.png)

Updated [`<SignInForm />` link](https://github.com/peterlidee/NNAS/blob/emailconfirmation/frontend/src/components/auth/signIn/SignInForm.tsx)

## Request a new confirmation email page

This is the `Strapi` endpoint:

```jsx
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:

```ts
{
  "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:

```jsx
// frontend/src/app/(auth)/confirmation/newrequest/page.tsx

import ConfirmationNewRequest from '@/components/auth/confirmation/ConfirmationNewRequest';

export default function page() {
  return <ConfirmationNewRequest />;
}
```

```jsx
// 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:

```jsx
// 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](https://dev.to/peterlidee/signing-up-with-nextauth-credentialsprovider-using-server-actions-and-useformstate-3d3p).

First note: While testing this flow I stumbled upon this error message:

![Strapi error, already confirmed](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/heepwuf200omh11ehxem.png)

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](https://www.paypal.com/donate/?hosted_button_id=4D78YQU4V5NEJ).