We already build a form and a custom Strapi
endpoint to update the user. We also established that we need to call this endpoint in a server action. But, till now, we've been using useFormState
to handle server actions. We can't do so here.
The code for this chapter is available on github (branch changeusername).
Another complication
Why not? Because on success, when our server action returns a success, we will have to handle some stuff client-side. This has to do with updating our NextAuth
token, we will see this in a bit. So, client side, we have to listen to the actionResponse. On success we do one thing, on error another. But where do we listen for this?
I will illustrate this with a simple example. Suppose we have a form, useFormState
and a server action and we want to log the return value of the server action in our browser console but only on success.
const [state, formAction] = useFormState(serverAction, initialState)
// form
<form action={formAction}></form>
How would you do this? How do you get the successful state into the browser console? You may be tempted to listen for the state inside the function component:
if (!state.error && state.message === 'Success') {
console.log(state);
}
But this will also log when there is another piece of state that updates or when f.e. the parent rerenders. So, it's not good. The solution is quite simple:
Server Actions are not limited to
<form>
and can be invoked from event handlers, useEffect, third-party libraries, and other form elements like<button>
. (Next docs)
This means that we can just do this:
function handleSubmit(){
// do some stuff
const res = await serverAction()
}
<form submit={handleSubmit}></form>
or this using action:
<form action={handleSubmit}></form>
The difference here would lie in the fact that when using action, you won't have to save form state in a useState
hook.
To conclude this section: we will use a server action but we can't use useFormState
. We will call our server action from inside a normal handleSubmit function. This will allow us to listen for the response and do something client-side, based upon the response. Downside is that it will require us to handle state and loading manually.
editUsernameAction
Let's write our server action first. Remember, as we won't call it using useFormState
nor directly using the form action attribute, it won't automatically receive any arguments like formData or prevState. We will pass it one argument: username (the new username from our form) For the rest it's what were used to: calling Strapi
and handling error and success.
// frontend/src/components/auth/account/editUsernameAction.ts
'use server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/authOptions';
import { StrapiErrorT } from '@/types/strapi/StrapiError';
type ActionErrorT = {
error: true;
message: string;
};
type ActionSuccessT = {
error: false;
message: 'Success';
data: {
username: string;
};
};
export type EditUsernameActionT = ActionErrorT | ActionSuccessT;
export default async function editUsernameAction(
username: string
): Promise<EditUsernameActionT> {
const session = await getServerSession(authOptions);
try {
const strapiResponse = await fetch(
process.env.STRAPI_BACKEND_URL + '/api/user/me',
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session?.strapiToken}`,
},
body: JSON.stringify({
username,
}),
cache: 'no-cache',
}
);
// handle strapi error
if (!strapiResponse.ok) {
const response: ActionErrorT = {
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: StrapiErrorT = await strapiResponse.json();
response.message = data.error.message;
} else {
response.message = strapiResponse.statusText;
}
return response;
}
// handle strapi success
const data = await strapiResponse.json();
return {
error: false,
message: 'Success',
data: {
username: data.username as string,
},
};
} catch (error: any) {
return {
error: true,
message: 'message' in error ? error.message : error.statusText,
};
}
}
So, this action returns an error or a success object. Note that the success object has a data attribute with the username on it. Also note that we take the username from the strapiResponse, not from our function argument. This means that we uphold the single state principle. Our data comes from our database, not from an input form. This shouldn't be a problem here but it's something you should keep in mind.
One tiny detail missing in our server action but let's first rig up our from component with the action.
handleSubmit
In this component, I will use the onsubmit form attribute. This is a bit more complex but I want to show how it's done. In the next chapter we will have a similar flow where we call handleSubmit with the action attribute.
Since we use onSubmit, we have to manually put some things in state: newUsername, message, error and loading. We also attach our input to the newUsername state, making it a controlled input.
Next, our handleSubmit function:
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setLoading(true);
// validate newUsername
if (newUsername === '' || newUsername.length < 4) {
setError('Username is too short.');
setLoading(false);
return;
}
// call server action
const actionResponse: EditUsernameActionT = await editUsernameAction(
newUsername
);
// handle error
if (actionResponse.error) {
setError(actionRes.message);
setMessage(actionRes.message);
setLoading(false);
return;
}
// handle success
if (!actionResponse.error && actionResponse.message === 'Success') {
// inform user of success
setError(null);
setMessage('Updated username.');
setLoading(false);
// TODO update session and token?
}
}
This should all be clear. We set loading. Do some basic validation of the form input. Then we call the server action. We await it because it is an async function and we pass it the newUsername from state. We check our actionResponse and set state accordingly.
Update form
We update our form component to show the message, error and loading states:
<form onSubmit={handleSubmit}>
<label htmlFor='username' className='block italic'>
Username:
</label>
<div className='flex gap-1'>
{!edit && <div>{username}</div>}
{edit && (
<>
<input
{/*...*/}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
/>
<button
type='submit'
{/*...*/}
disabled={loading}
aria-disabled={loading}
>
{loading ? 'saving' : 'save'}
</button>
</>
)}
<button
type='button'
onClick={() => {
setEdit((prev) => !prev);
setError(null);
setMessage(null);
setNewUsername(username);
}}
className='underline text-sky-700 ml-1'
>
{edit ? 'close' : 'edit'}
</button>
</div>
{edit && error && (
<div className='text-red-700' aria-live='polite'>
Something went wrong: {error}
</div>
)}
{edit && !error && message ? (
<div className='text-green-700' aria-live='polite'>
{message}
</div>
) : null}
</form>
Note that we also updated the toggle button. On toggle close, we reset error and message and reset newUsername to the username prop (the original).
Everything that is in here now works. If we run our app, sign in a go to the /account
page, we can toggle open the edit username component and change the name. It will display a success or error message the button has a loading state.
However, as it stands now, there is a problem. When we toggle our edit username component closed, the old username is still displayed. We talked about this problem before but haven't implemented the solution yet.
We display props.username. This comes from or <Account />
component that gets it's data from our api fetch. When we updated our username we should also revalidate said fetch. This is easy. We go back to our editUsernameAction and add this one line before we return our success object:
// handle strapi success
revalidateTag('strapi-users-me');
const data = await strapiResponse.json();
return {
error: false,
message: 'Success',
data: {
username: data.username as string,
},
};
We test it out again and it works like a charm. When updating the username and toggling the component closed, it now shows the updated username because Next
revalidated our query.
Small issue here. There is a UI flicker. But, don't worry about this, it is a dev mode issue. It goes away in production mode.
New problems
Are we done now? No! We are yet to update our NextAuth
session and token.
Notice our updated username John, while our navbar user and <LoggedInClient />
and <LoggedInServer />
components (the green ones) still show the old username Frank. That's what we need to solve now.
NextAuth update function
Remember the NextAuth
useSession
hook? We haven't actually used it, only in <LoggedInClient />
. But we do need it here. Besides data, useSession
returns 2 more properties: status and a function called update
.
'use client';
const { data: session, status, update } = useSession();
The update
function will be our hero for now. It allows you to hook into the NextAuth
callback flow that we discussed in detail in earlier chapters. The jwt callback
serves to put items onto the NextAuth
token. Until now, we've used jwt
to take items from the GoogleProvider or CredentialsProvider and put them on the token: strapiToken, strapiUserId, provider and blocked. We only did this on sign in, when the jwt callback
account argument is populated.
Another argument of the jwt callback
is called trigger. It has 3 potential values: signUp
, signIn
and update
. When calling the update function, trigger will equal 'update'. You should be able to figure out the flow now. We need to update the token (and the session). To update the token, you use the jwt callback
. Calling update
lets you hook into the jwt callback
.
So, we will call the update
function in our <EditUsername />
component and pass it the new username that the server action updateUsernameAction
returned: update(actionResponse.data.username)
. We then listen for the trigger argument in our jwt
and when it equals 'update' we update the token.
// inside jwt callback
if (trigger === 'update') {
// update token
}
We passed the new username into our update
function but where do we retrieve it in our jwt callback
?
When using SessionOptions.strategy "jwt", this is the data sent from the client via the useSession().update method. (source: TypeScript tooltip when hovering the session argument)
So, it's on the session argument of the jwt callback
. We can then update our code like this:
// inside jwt callback
if (trigger === 'update' && session.username) {
// update token
token.name = session.username;
}
And that is it. We don't need to do anything else. The jwt callback
has now updated our token. We don't need to alter the session callback
.
Note 1: notice how we added a conditional: && session.username
. This allows us to have multiple uses for update
. F.e. next time we call it with update({foo: 'bar'})
and then listen for && session.foo
.
Note 2: naming this jwt callback
argument 'session' seems confusing to me. Don't we already have a session? (returned from useSession
or getServerSession
). But, that is what NextAuth
called it and there is nothing I can do about that.
Note 3: in case you want to do something to the token on sign in, listen for the trigger to equal signIn
inside the jwt
.
Quick recap
- We fetch the current user data in our
<Account />
component using theStrapi
endpointusers/me
. - A form allows the user to enter a new username.
- We had to write a custom
Strapi
api endpoint/user/me
to be able to update the current user. - The endpoint is called inside a server action editUsernameAction.
- We have to use a server action because on successfully updating the username, we also need to update our fetch (step 1). We use
revalidateTag
inside the server action. - The
update
function lets us update ourNextAuth
token/session. This is a client-side function. - To switch from the server action (server side) to the update function (client side) we call the server action as a function inside a submit handler. We cannot use
useFormState
. - Inside the submit handler, our server action will return us the updated username from the database.
- We then call update and pass it this username.
- To catch this update call, we listen for
trigger="update"
andsession.username
inside thejwt callback
. We can then update our token with the new username.
How to update getServerSession
Let's test this out to see if everything works. We have a user Frank and we update the name to Franky:
No success. The navbar username is still Frank as is the <LoggedInServer />
component ("Server: logged in as Frank."). The <LoggedInClient />
did update: "Client: logged in as Franky." So, useSession
updated but getServerSession
did not. Note: when we do a page refresh, do username does show 'Franky' in all components!
Good news, we encountered this problem before in the <SignInForm />
component. We solved it by calling router.refresh()
which refreshes all data request and rerenders all server components. Great, we add it in our submit handler after we call update:
// update NextAuth token
await update({ username: actionResponse.data.username });
// refresh server components
router.refresh();
And everything works! Note that we have to await update. Else, router.refresh
would be called before update has finished doing its work. As a reminder, calling router.refresh
causes a UI flicker but only in dev mode.
You can see the final version of the editUsernameAction, <EditUsername />
component and the jwt callback
on github.
Conclusion
This was a massive amount of code for such a small component. The main culprit is the switching we have to do between client-side and server-side code. NextAuth v4
does not work well with server components. I hope NextAuth v5
which is still in beta, will solve these problems.
One last thing is left in our auth flow: letting a signed in user change his or hers password. That is what we will do in the next chapter.
If you want to support my writing, you can donate with paypal.