14/ Signing up with NextAuth CredentialsProvider using server actions and useFormState

peterlidee

Peter Jacxsens

Posted on April 1, 2024

14/ Signing up with NextAuth CredentialsProvider using server actions and useFormState

This code for this example is available on github: branch credentialssignup.

We have our form, what's next? What are all the steps we still have to do?

  • Call Strapi sign up endpoint.
  • Handle error and success.
  • Validate the fields.
  • Display errors to the user.
  • Send confirmation email.
  • Set loading state.

In our sign in form that we made in a previous chapter we set our entire data, error and loading state up ourselves. We had to because the NextAuth signIn function is a client-side only function.

However, we don't need NextAuth now. The only thing we need to do is call a Strapi endpoint and we can do that server side. How? Either setup a custom api endpoint in Next (a route handler) or even easier we're going to use a server action. What goes well with server actions? The useFormState and useFormStatus hooks to handle our data, error and loading states.

So, we will create a server action signUpAction to:

  1. Validate the input fields using Zod (server side)
  2. Call the Strapi sign up endpoint and handle error or success.

If everything goes as planned without errors, signUpAction will redirect the user to another page with a feedback message: 'we sent you an email...'. When there is an error (either Zod or Strapi), our server action send back a state with an error property to useFormState.

signUpAction

Let's write this out. Since we use useFormState, our server action will receive 2 parameters:

  1. prevState: current state of useFormState.
  2. formData: interface that lets you read out and manipulate all the fields of your form.

We start by validating our fields using Zod:



// frontend/src/components/auth/signup/signUpAction.ts

'use server';

import { z } from 'zod';

const formSchema = z.object({
  username: z.string().min(2).max(30).trim(),
  email: z.string().email('Enter a valid email.').trim(),
  password: z.string().min(6).max(30).trim(),
});

export default async function signUpAction(
  prevState: SignUpFormStateT,
  formData: FormData
) {
  const validatedFields = formSchema.safeParse({
    username: formData.get('username'),
    email: formData.get('email'),
    password: formData.get('password'),
  });

  if (!validatedFields.success) {
    return {
      error: true,
      fieldErrors: validatedFields.error.flatten().fieldErrors,
      message: 'Please verify your data.',
    };
  }

  const { username, email, password } = validatedFields.data;
  // do more stuff
}


Enter fullscreen mode Exit fullscreen mode

The Zod validation should be clear. We created a schema, check if our input fields match the criteria. If they don't we return an object with an error property to useFormState. 2 Notes:

  1. This will error because there is no SignUpFormStateT type yet, we will handle this later.
  2. If you want more advanced password requirements, add them.

Next, we call Strapi with the validated fields. We wrap the entire call inside a try catch block.



// do more stuff
try {
  const strapiResponse = await fetch(
    process.env.STRAPI_BACKEND_URL + '/api/auth/local/register',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ username, email, password }),
      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();
      response.message = data.error.message;
    } else {
      response.message = strapiResponse.statusText;
    }
    return response;
  }
} catch (error) {
  // network error or something
  return {
    error: true,
    message: 'message' in error ? error.message : error.statusText,
  };
}


Enter fullscreen mode Exit fullscreen mode

The fetch we covered earlier. We then check strapiResponse for an error. Note, not all Strapi error will be json, that if why we make the check if (contentType === 'application/json; charset=utf-8'). If there is an error, we return an object with some properties to useFormState. Same goes for the catch.

There is something missing here: success handling. On success, we will redirect the user to a message page using the next/navigation redirect function. But you shouldn't call redirect inside a try catch block. So, we let ourself fall through the try catch block and only after it, we add the redirect:



// page yet to be made
redirect('/confirmation/message');


Enter fullscreen mode Exit fullscreen mode

I hope this makes sense. When the strapiResponse was successful, we want to redirect the user to another page. We don't need data from this response. When there is no error, javascript will just move to the next line in our server action and that is the redirect.

And we are done, except for the SignUpFormStateT type that we will cover in a bit.

useFormState

We go back to our form now that we have our server action. We need to setup our useFormState hook. useFormState is a hook that couples server actions to state. Using useFormState means that you don't have to set state manually.

We initiate useFormState with a server action (signUpAction) and an initial state. It returns this state and a formAction. Basically a wrapper around the server action that we passed. Anything that we return from our server action will become our new state. When calling our server action, useFormState also passes this state and formData to our server action so we have access to this. We've seen this above in signUpAction: the prevState and formData parameters.

Let's add it to our form:



const initialState = {
  error: false,
};
const [state, formAction] = useFormState(signUpAction, initialState);
// ...
<form className='my-8' action={formAction}></form>;


Enter fullscreen mode Exit fullscreen mode

How to set Types to useFormState and server actions

Setting Types on useFormState can be a bit tricky because you need to account to every possible return value of the server action as well as the initialState. It's something you have to think out and put effort into.

In case of our signUpAction: we know it only returns when there is an error: Zod error, Strapi error or a catch error. In case of success, it won't return but redirect. What did we return in these cases?



{
  error: true,
  message: 'some error message'
}


Enter fullscreen mode Exit fullscreen mode

Only the Zod error has an extra property, the Zod error object:



export type InputErrorsT = {
  username?: string[],
  email?: string[],
  password?: string[],
};


Enter fullscreen mode Exit fullscreen mode

Since signUpAction does not return on success, it's return Type looks like this:



{
  error: true,
  message: string,
  inputErrors?: InputErrorsT
}


Enter fullscreen mode Exit fullscreen mode

With inputErrors (the Zod errors) being optional. Our initialState only has one property: error: false. We could then update our type by making message optional.

TypeScript will correctly infer this in the useFormState hook, without explicitly setting a Type:

useFormState inferred type

But, we do need to set an explicit style in our signUpAction action because that is how TypeScript works:



export default async function signUpAction(
  prevState: SignUpFormStateT,
  formData: FormData
) {
  //...
}


Enter fullscreen mode Exit fullscreen mode

Since we explicitly have to set it in the server action, we might as well set it explicitly in our useFormState hook as well. This is how we do that:




export type InputErrorsT = {
  username?: string[];
  email?: string[];
  password?: string[];
};

export type RegisterFormStateT = {
  error: boolean;
  InputErrors?: InputErrorsT;
  message?: string;
};

// ...

const [state, formAction] = useFormState<SignUpFormStateT, FormData>(
  signUpAction,
  initialState
);


Enter fullscreen mode Exit fullscreen mode

This setup works, it's correct and there are no TS errors. But we can do better. We are going to update our Type to a discriminated union. This makes sense: it will be either an error or the initialState. This is our update:



type SignUpFormInitialStateT = {
  error: false; // not boolean
};
type SignUpFormErrorStateT = {
  error: true; // not boolean
  message: string; // not optional
  inputErrors?: InputErrorsT;
};
// discriminated union
export type SignUpFormStateT = SignUpFormInitialStateT | SignUpFormErrorStateT;
// explicitly set type here
const initialState: SignUpFormInitialStateT = {
  error: false,
};

// ...
const [state, formAction] = useFormState<SignUpFormStateT, FormData>(
  signUpAction,
  initialState
);


Enter fullscreen mode Exit fullscreen mode

I hope this makes sense. This is a simple case and it's already getting complex. This is something that requires some effort in other words.

Using state

We haven't actually used the state to display feedback to the user. Let's add that now. For each input we check for a corresponding Zod error and display it.



<div className='mb-3'>
  <label htmlFor='username' className='block mb-1'>
    Username *
  </label>
  <input
    type='text'
    id='username'
    name='username'
    required
    className='border border-gray-300 w-full rounded-sm px-2 py-1'
  />
  {state.error && state?.inputErrors?.username ? (
    <div className='text-red-700' aria-live='polite'>
      {state.inputErrors.username[0]}
    </div>
  ) : null}
</div>

// do the other inputs too


Enter fullscreen mode Exit fullscreen mode

Finally, on the bottom of the form we display the error.message:



{
  state.error && state.message ? (
    <div className='text-red-700' aria-live='polite'>
      {state.message}
    </div>
  ) : null;
}


Enter fullscreen mode Exit fullscreen mode

Handling loading states with useFormStatus

This leaves us with one last task: setting loading state. For this we will of course use the useFormStatus hook:

useFormStatus is a Hook that gives you status information of the last form submission.

In signIn, we manually set loading states. Since we use useFormState in this form, we can also use useFormStatus to handle the loading state for us. These is one catch, we have to call useFormStatus in a separate component from where we call useFormState. Create a new component:



// frontend/src/components/auth/PendingSubmitButton.tsx

import { useFormStatus } from 'react-dom';

export default function PendingSubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button
      type='submit'
      className={`bg-blue-400 px-4 py-2 rounded-md disabled:bg-sky-200 disabled:text-gray-400 disabled:cursor-wait`}
      disabled={pending}
      aria-disabled={pending}
    >
      send
    </button>
  );
}


Enter fullscreen mode Exit fullscreen mode

And we insert it into our form component. And that's it. Sign up is complete. We do some quick tests:

  • Entering inputs that are too short lead to Zod errors.

Zod error on sign up

  • Entering a username or email that is already in the DB leads to a Strapi error.

sign up Strapi error

  • The submit button has a (very fast) loading state when submitting.
  • On success we are redirected to a page that we haven't build yet.

Everything works!

Conclusion

We just build our sign up page. We started with an issue with the sign in with Google button. Because the error handling on this button only works on the sign in page, we had to leave it out of the sign up page. This isn't optimal but there are ways around it.

Next, we looked into how we would setup the sign up flow for this test app. There are many ways to do this and each way has it's own challenges and solutions. We chose a classic method of not immediately signing in a user.

As a consequence of this flow, we were able to leave NextAuth out of the sign up flow. This in turn meant we could use useFormState combined with a server action. This is great because it made our job easier.

The last part we handled was setting up Types for useFormState which can be a bit complex and requires some effort.

Next step will be sending emails and setting up an email verification page.


If you want to support my writing, you can donate with paypal.

💖 💪 🙅 🚩
peterlidee
Peter Jacxsens

Posted on April 1, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related