DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on • Updated on

19/ Updating the userdata in Strapi and NextAuth

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>
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

or this using action:

<form action={handleSubmit}></form>
Enter fullscreen mode Exit fullscreen mode

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,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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?
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

edit username success

edit username error

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,
    },
  };
Enter fullscreen mode Exit fullscreen mode

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.

NextAuth session not updated

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();
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

  1. We fetch the current user data in our <Account /> component using the Strapi endpoint users/me.
  2. A form allows the user to enter a new username.
  3. We had to write a custom Strapi api endpoint /user/me to be able to update the current user.
  4. The endpoint is called inside a server action editUsernameAction.
  5. 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.
  6. The update function lets us update our NextAuth token/session. This is a client-side function.
  7. 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.
  8. Inside the submit handler, our server action will return us the updated username from the database.
  9. We then call update and pass it this username.
  10. To catch this update call, we listen for trigger="update" and session.username inside the jwt 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:

NextAuth getServerSession not updated

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();
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)