All the code for this chapter is available on github: branch credentialssignin.
We have some things left to handle:
- Form input validation
- Handle errors returned by signIn
- Handle a successful sign in
- Handle loading states
Form validation
We will validate our input using client-side zod. Zod handles TypeScript-first schema validation with static type inference. This means that it will not only validate your fields, it will also set types on validated fields.
I'm not very familiar with Zod myself but it seems to work great with server actions and the useFormState
hook. We will be using it in this context later on but for now we will use it client side. Install Zod:
npm install zod
We need to validate the username/email and password input fields. These are both inside our data useState
hook that we set up earlier. We start by creating a formSchema:
const formSchema = z.object({
identifier: z.string().min(2).max(30),
password: z
.string()
.min(6, { message: 'Password must be at least 6 characters long.' })
.max(30),
});
This is pretty self explanatory. We want both fields to be strings and have a minimum and maximum length. We provide a custom error message when the password is too short. Note, there is plenty of password validation articles and opinions out there. Handle this yourself.
In our handleSubmit, before we call signIn
, we call this formSchema. It will check if our input field values matches the conditions we set in our formSchema. By using the validatedFields.success
property, we can handle what needs to happen next.
On error, Zod will generate error messages for us that we save in state. On success,we can simply call the signIn
function as our inputs are valid.
type FormErrorsT = {
identifier?: undefined | string[],
password?: undefined | string[],
};
const [errors, setErrors] = useState < FormErrorsT > {};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validatedFields = formSchema.safeParse(data);
if (!validatedFields.success) {
setErrors(validatedFields.error.formErrors.fieldErrors);
} else {
// no zod errors
// call signIn
}
};
Finally, update our form jsx to display the error messages. for each input field we do something like this:
{
errors?.identifier ? (
<div className='text-red-700' aria-live='polite'>
{errors.identifier[0]}
</div>
) : null;
}
and a general error below the form:
{
errors.password || errors.identifier ? (
<div className='text-red-700' aria-live='polite'>
Something went wrong. Please check your data.
</div>
) : null;
}
To summarize: on submit we use Zod
to validate our fields. If there is no error, we call signIn
. If there is an error, Zod
gives us an error object, mirroring our form state. We save this error inside an error state and use it to display user feedback.
Handle errors returned by NextAuth signIn function
In the previous chapter we handled Strapi
errors by throwing errors in our authorize
function. In our frontend, we added a property redirect: false
to the options object of our signIn
function. Doing this makes signIn
return an object with an error property:
{
error?: string;
status: number;
ok: boolean;
url?: string;
}
Now we will use this object to provider feedback to the user. We check if there is an error and then put this error in our error state that we already used for Zod
errors. We extend our error Type with a strapiError property:
type FormErrorsT = {
identifier?: undefined | string[];
password?: undefined | string[];
strapiError?: string;
};
Listen for an error on the signInResponse and put it in the error state:
if (signInResponse && !signInResponse?.ok) {
setErrors({
strapiError: signInResponse.error
? signInResponse.error
: 'Something went wrong.',
});
} else {
// handle success
}
Below our form we display the strapiError:
{
errors.strapiError ? (
<div className='text-red-700' aria-live='polite'>
Something went wrong: {errors.strapiError}
</div>
) : null;
}
We are now displaying custom errors we threw inside authorize
in our form component!
Handle a successful sign in
We haven't actually handled a successful sign in process with credentials. We know we have successfully signed in when we have no errors. But what to do next? Previously, we set up our GoogleProvider to redirect to the previous page on successful sign in.
This isn't a solution I would adopt in production. For example, if the previous page was the sign up page then it would be bad UX to send them back to register page after signing in. But, I will leave this to you and stick with simply redirecting.
As before, we simply get our url from the callbackUrl searchParam:
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl') || '/';
const router = useRouter();
And then we just push our new route:
router.push(callbackUrl);
Note here: the signIn
function options object has a callbackUrl property. You can use this if you like to redirect to a fixed page.
Let's test what we just wrote. Run our app and try logging in with (correct) credentials. Everything works, we are redirected but:
Client versus server session
Well, something went wrong! The obvious problem here is that our <LoggedInClient />
component (uses useSession hook
) says we're logged in but our server component <LoggedInServer />
(uses getServerSession
) says we're not. Our <NavbarUser />
component (uses getServerSession
) is also failing. It should show the username followed by the <SignOutButton />
yet it shows no username and the sign in
link.
So, there seems to be a problem with getServerSession
. It doesn't seem to update. And yes, we are signed in, so useSession
is correct here. Turning to the NextAuth
docs gave no answers. There is no NextAuth
way to manually trigger a getServerSession
refresh.
I spend quite some time trying to figure this out. The breaking change we made was adding the redirect: false
option. It's been a while since I mentioned this but when we temporarily remove this redirect option and try logging in we notice something. There is a full page refresh! That means that Next
and NextAuth
get fresh, updated data.
With the redirect: false
option back, when successfully logging in with credentials we do a router.push
. This does not trigger a page refresh, as we would expect from a single page app. But this also means that our server components like <LoggedInServer />
and <NavbarUser />
aren't refreshed and display a stale state.
That's what is causing our problem. How do we solve this? I just looked around, googled and read a lot until somebody mentioned router.refresh
. From the Next docs:
router.refresh(): Refresh the current route. Making a new request to the server, re-fetching data requests, and re-rendering Server Components. The client will merge the updated React Server Component payload without losing unaffected client-side React (e.g. useState) or browser state (e.g. scroll position).
And this solves the problem. We add the line and test again:
// handle success
router.push(callbackUrl);
router.refresh();
Couple of remarks here:
- This is a solid solution.
NextAuth
maybe dropped the ball here but usingrouter.refresh
solves this perfectly. This is not a hack and may be the only way to solve this. - When actually doing this, there is a slight delay in the server component. The client component will update first, followed by the server component. Just noting.
-
router.refresh
may cause a screen flicker. Don't worry, this is a dev mode thing. It disappears when you run the app in production mode (next build
+next start
). (This took me some time to figure out :/)
Setting loading states
Let's account for furious button smashing on a slower connection by disabling our button while we're doing our sign in thing.
Create a state for loading and initiate it to false. When we submit we set loading to true. On Zod
error or strapi error we set it to false. Update our button with the disabled attribute and some disabled styles:
<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={loading}
aria-disabled={loading}
>
sign in
</button>
Done! Take a look at the finished <SignIn />
component on github.
Conclusion
We just set up a credentials sign in flow and it took some work (3 chapters)! Here is a run-down of what we did:
- Adding the CredentialsProvider to the
NextAuth
providers. - Setting up a form with controlled inputs.
- Calling
signIn
client side with the credentials. - Explaining and writing out the
authorize
function/callback. - Calling the
Strapi
sign in endpoint. - Returning a response from
authorize
. - Updating the
jwt callback
. - Fixing our callback Types.
- Exploring
NextAuth
default error handling for theauthorize
function. - Handling
Strapi
errors inauthorize
. - Updating
signIn
withredirect: false
. - Adding form validation with
Zod
. - Handling the return value from
signIn
. - Redirecting the user on a successful sign in.
- Fixing
getServerSession
not reloading. - Adding loading states.
Auth flows are difficult. You should by now start getting a real good feel for NextAuth
and how you can use it in your own projects. Hopefully I was able to clearly explain everything and help you save some time. In the next chapter, we will be implementing a sign up flow for the CredentialsProvider.
If you want to support my writing, you can donate with paypal.