14/ Signing up with NextAuth CredentialsProvider using server actions and useFormState
Peter Jacxsens
Posted on April 1, 2024
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:
- Validate the input fields using
Zod
(server side) - 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:
- prevState: current state of
useFormState
. - 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
}
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:
- This will error because there is no SignUpFormStateT type yet, we will handle this later.
- 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,
};
}
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');
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>;
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'
}
Only the Zod
error has an extra property, the Zod
error object:
export type InputErrorsT = {
username?: string[],
email?: string[],
password?: string[],
};
Since signUpAction
does not return on success, it's return Type looks like this:
{
error: true,
message: string,
inputErrors?: InputErrorsT
}
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:
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
) {
//...
}
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
);
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
);
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
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;
}
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>
);
}
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.
- Entering a username or email that is already in the DB leads to a
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.
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
April 1, 2024