DEV Community 👩‍💻👨‍💻

Cover image for Implementing Strapi v4 Authentication with Remix
Strapi for Strapi

Posted on • Originally published at strapi.io

Implementing Strapi v4 Authentication with Remix

In this article, we'll create a Remix application with authentication and authorization using Strapi.

Author: Miracle Onyenma

Authentication is an integral part of any application with users, but setting up a complete authentication and authorization workflow from scratch could be incredibly time-consuming and unproductive except for some unique cases.

With Strapi, we have access to multiple authentication providers like Google, Twitter, etc., enabling us to set up authenticated requests to our Headless CMS API to fetch data easily and perform actions only available to authenticated and authorized users. With Strapi authentication, we can quickly set up a robust authentication system for our application and focus on building.

Let's look at how we can set up a simple Remix application and implement user authorization and authentication with Strapi.

A Brief introduction to Headless CMS

A Content Management System (CMS) is a software or service that helps you create and manage content for your website and applications.

For a traditional CMS, the front-end of the website or application gets built into the CMS. The only customizations available are pre-made themes and custom code. WordPress, Joomla, and Drupal are good examples of a traditional CMS that merges the website front-end with the back-end.

Unlike a traditional CMS, a headless CMS gives you the freedom to build out your client or front-end, connecting it to the CMS via APIs. Also, with a headless CMS, the application frontend can be built using any technology, allowing multiple clients to connect and pull data from one CMS.

What is Strapi?

Strapi is leading JavaScript open-source headless CMS. Strapi makes it very easy to build custom APIs, REST or GraphQL, that can be consumed by any client or front-end framework of choice.

It sounds interesting, especially since we’ll be consuming our Strapi API and building out Authentication and Authorization with Remix.

Authentication in Strapi

Strapi uses token-based authentication to authenticate its users by providing a JWT token to a user on successful user registration and login. Strapi also supports multiple authentication providers like Auth0, Google, etc. We’ll be using the local auth in this tutorial.

What is Remix?

Remix is a full-stack web framework that focuses on the user interface and works back through web fundamentals to deliver a fast, sleek, and resilient user experience. Remix includes React Router, server-side rendering, TypeScript support, production server, and backend optimization.

Goal

At the end of this tutorial, we would have covered how to add authentication to our Remix application with Strapi.

Prerequisites

  • Basic knowledge of JavaScript
  • Basic knowledge of Remix
  • A code editor like VSCode
  • Node.js version (^12.22.0, ^14.17.0, or >=16.0.0). You can download Node.js from Node.js official site if you haven't already.
  • npm 7 or greater installed

What We’re Building

We’ll build a simple Remix application where users can register, log in, and edit their profiles. Here’s the live example hosted on Netlify

Step 1: Set up Backend with Strapi

To kick off the creation process, we'll begin by setting up the backend with Strapi.

  1. First, we’ll create a new Strapi app. Navigate to the directory of your choice and run one of the commands below:
    yarn create strapi-app profile-api
    #or
    npx create-strapi-app@latest profile-api
Enter fullscreen mode Exit fullscreen mode
  1. Next, choose the installation type. Quickstart uses the default database SQLite and is recommended. Once the installation is complete, the Strapi admin dashboard should automatically open in your browser.
  2. Fill out the form to create an admin account. Strapi admin account form This will allow us to access the admin dashboard.

Create Collection Types

Let's modify our collection type for Users. Navigate to CONTENT-TYPE BUILDER > COLLECTION TYPES > USER. Here, we’ll see the structure of the user type in Strapi.

Strapi default user collection type

We’re just going to add a few more fields here. Click on the + ADD ANOTHER FIELD button at the top right corner to add the following fields:

  • twitterUsername - Text (Short Text) and under Advanced settings, select Unique field.
  • websiteUrl - Text (Short text)
  • title - Text (Short Text) and under Advanced settings, Select Required field.
  • bio - Text (Long Text)
  • profilePic - Media (Single media)
  • color - Enumeration: Values picked from Tailwind colors):
    • Red
    • Orange
    • Amber etc. Under Advanced settings, set Default Value to Cyan and enable Required field.
  • slug - UID: Attached field - username

Now, we should end up with something like this:

Updated user collection type

Click on SAVE. This will save the changes to the collection type and restart the server.

Configure Permissions for Public and Authenticated Users

Strapi is secure by default, so we won't be able to access any data from the API unless we set the permissions. To set the permissions,

  1. Navigate to SETTINGS > USERS & PERMISSIONS PLUGIN > ROLES.
  2. Go to PUBLIC and enable the following actions for the following under Users-permissions.
  3. USER
    • Count
    • find
    • findOne

We should have something like this:
Enable user action for public roles

  1. Click SAVE.
  2. Now, go to AUTHENTICATED and enable the following under User-permissions
  3. AUTH - ✅ Select All
  4. PERMISSIONS - ✅ Select All
  5. USER ✅ - Select All
    Enable multiple actions for authenticated role

  6. Click SAVE.

Also, we’ll quickly create a few user profiles for our application. To create users, navigate to CONTENT MANAGER > USER. Then, click on CREATE NEW ENTRY, fill out all the necessary info and save the entries.

Here are my users for example:
User entries

Step 2: Setting up the Remix application

To create our Remix frontend, run:

    npx create-remix@latest
Enter fullscreen mode Exit fullscreen mode

If this is your first time installing Remix, it’ll ask whether you want to install create-remix@latest. Enter y to install

Once the setup script runs, it'll ask you a few questions.

? Where would you like to create your app? remix-profiles
? What type of app do you want to create? Just the basics
? Where do you want to deploy? Choose Remix App Server if you're unsure; it's easy to change deployment targets. Remix App Server
? TypeScript or JavaScript? TypeScript
? Do you want me to run `npm install`? Yes
Enter fullscreen mode Exit fullscreen mode

Here we call the app "remix-profiles", then choose "Just the basics" for the app type and for the deploy target, we choose "Remix App Server", we’ll also be using TypeScript for this project and let Remix run npm install for us.

The "Remix App Server" is a full-featured Node.js server based on Express. It's the simplest option and we’ll go with it for this tutorial.

Once the npm install is successful, we'll navigate to the remix-profiles directory:

    cd remix-jokes
Enter fullscreen mode Exit fullscreen mode

Set up TailwindCSS

Install tailwindcss, its peer dependencies, and concurrently via npm, and then run the init command to generate our tailwind.config.js file.

    npm install tailwindcss postcss autoprefixer concurrently @tailwindcss/forms @tailwindcss/aspect-ratio
    npx tailwindcss init
Enter fullscreen mode Exit fullscreen mode

Now, configure ./tailwind.config.js:

    // ./tailwind.config.js

    module.exports = {
      content: [
        "./app/**/*.{js,ts,jsx,tsx}",
      ],
      theme: {
        extend: {},
      },
      corePlugins: {
        aspectRatio: false,
      },
      plugins: [
        require('@tailwindcss/forms'),
        require('@tailwindcss/aspect-ratio')
      ],
    }
Enter fullscreen mode Exit fullscreen mode

Now, we have to update the scripts in our package.json file to build both the development and production CSS.

    // ./package.json

    {
      "scripts": {
        "build": "npm run build:css && remix build",
        "build:css": "tailwindcss -m -i ./styles/app.css -o app/styles/app.css",
        "dev": "concurrently \"npm run dev:css\" \"remix dev\"",
        "dev:css": "tailwindcss -w -i ./styles/app.css -o app/styles/app.css",
      }
    }
Enter fullscreen mode Exit fullscreen mode

With that, we can add the @tailwind directives for each of Tailwind’s layers to our css file. Create a new file ./styles/app.css:

    // ./styles/app.css/

    @tailwind base;
    @tailwind components;
    @tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

To apply this to our application, we have to import the compiled ./app/styles/app.css file into our project in our ./app/root.tsx file:

    // ./app/root.tsx

    import type { MetaFunction, LinksFunction } from "@remix-run/node";

    // import tatilwind styles
    import styles from "./styles/app.css"
    import {
      Links,
      LiveReload,
      Meta,
      Outlet,
      Scripts,
      ScrollRestoration,
    } from "@remix-run/react";
    export const meta: MetaFunction = () => ({
      charset: "utf-8",
      title: "New Remix App",
      viewport: "width=device-width,initial-scale=1",
    });
    export const links: LinksFunction = () => {
      return [{ rel: "stylesheet", href: styles }];
    };

    export default function App() {
      return (
        <html lang="en">
          <head>
            <Meta />
            <Links />
          </head>
          <body>
            <Outlet />
            <ScrollRestoration />
            <Scripts />
            <LiveReload />
          </body>
        </html>
      );
    }
Enter fullscreen mode Exit fullscreen mode

Awesome!

Let’s take a quick look at our project structure at this point. It should look something like this:

remix-profiles
├─ .eslintrc
├─ .gitignore
├─ app
│  ├─ entry.client.tsx
│  ├─ entry.server.tsx
│  ├─ root.tsx
│  └─ routes
│     └─ index.tsx
├─ package-lock.json
├─ package.json
├─ public
│  └─ favicon.ico
├─ README.md
├─ remix.config.js
├─ styles
│  └─ app.css
├─ tailwind.config.js
└─ tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Now, let’s run our build process and start our application with:

    npm run dev
Enter fullscreen mode Exit fullscreen mode

This runs the dev scripts we added to package.json and runs the Tailwind alongside Remix:

Run tailwind and Remix

We should be greeted with this:

Remix index page

Alright! Let's get into the juicy stuff and build out our Remix application.

Note:

  • 🚩 All the styles added to this application are in a single ./styles/app.css file (not compiled) which you can access in the project's GitHub repository.

  • 🚩 I’ll be using TypeScript for this project, I’ve kept all the custom type declarations I created for this project in the ./app/utils/types.ts file. You can get it from GitHub and use it to follow along if you’re working with TypeScript.

However, if you prefer to use JavaScript, you can ignore all that and also use .js and .jsx files instead.

Add Strapi URL to Environment Variable

Create an ./.env file in the root of the project and add the following:

STRAPI_API_URL="http://localhost:1337/api"
STRAPI_URL="http://localhost:1337"
Enter fullscreen mode Exit fullscreen mode

Create SiteHeader Component

Let’s add a nice and simple header with basic navigation to our application.

  1. Create a new file in ./app/components/SiteHeader.tsx
    // ./app/components/SiteHeader.tsx

    // import Remix's link component
    import { Link } from "@remix-run/react";

    // import type definitions
    import { Profile } from "~/utils/types";

    // component accepts `user` prop to determine if user is logged in
    const SiteHeader = ({user} : {user?: Profile | undefined}) => {
      return (
        <header className="site-header">
          <div className="wrapper">
            <figure className="site-logo"><Link to="/"><h1>Profiles</h1></Link></figure>
            <nav className="site-nav">
              <ul className="links">
                {/* show sign out link if user is logged in */}
                {user?.id ?
                  <>
                    {/* link to user profile */}
                    <li>
                      <Link to={`/${user?.slug}`}> Hey, {user?.username}! </Link>
                    </li>
                    <li className="link"><Link to="/sign-out">Sign out</Link></li>
                  </> :
                  <>
                    {/* show sign in and register link if user is not logged in */}
                    <li className="link"><Link to="/sign-in">Sign In</Link></li>
                    <li className="link"><Link to="/register">Register</Link></li>
                  </>
                }
              </ul>
            </nav>
          </div>
        </header>
      );
    };
    export default SiteHeader;
Enter fullscreen mode Exit fullscreen mode
  1. We want this to always be visible in the application, regardless of the route. So we simply add it to our ./app/routes/root.tsx file:
    // ./app/root.jsx
    import type { MetaFunction, LinksFunction } from "@remix-run/node";

    // import compiled styles
    import styles from "./styles/app.css";
    import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";

    // import site header component
    import SiteHeader from "./components/SiteHeader";

    // add site meta
    export const meta: MetaFunction = () => ({
      charset: "utf-8",
      title: "Profiles | Find & connect with people",
      viewport: "width=device-width,initial-scale=1",
    });

    // add links to site head
    export const links: LinksFunction = () => {
      return [{ rel: "stylesheet", href: styles }];
    };

    export default function App() {
      return (
        <html lang="en">
          <head>
            <Meta />
            <Links />
          </head>
          <body>
            <main className="site-main">
              {/* place site header above app outlet */}
              <SiteHeader />
              <Outlet />
              <ScrollRestoration />
              <Scripts />
              <LiveReload />
            </main>
          </body>
        </html>
      );
    }
Enter fullscreen mode Exit fullscreen mode

Create ProfileCard Component

We’ll create a ProfileCard component that will be used to display the user information. Create a new file ./app/components/ProfileCard.tsx:

    // ./app/components/ProfileCard.tsx

    import { Link } from "@remix-run/react";

    // type definitions for Profile response
    import { Profile } from "~/utils/types";

    // strapi url from environment variables
    const strapiUrl = `http://localhost:1337`;

    // helper function to get image url for user
    // we're also using https://ui-avatars.com api to generate images
    // the function appends the image url returned
    const getImgUrl = ({ url, username }: { url: string | undefined; username: string | "A+N" }) =>
      url ? `${strapiUrl}${url}` : `https://ui-avatars.com/api/?name=${username?.replace(" ", "+")}&background=2563eb&color=fff`;

    // component accepts `profile` prop which contains the user profile data and
    // `preview` prop which indicates whether the card is used in a list or
    // on its own in a dynamic page
    const ProfileCard = ({ profile, preview }: { profile: Profile; preview: boolean }) => {
      return (
        <>
          {/* add the .preview class if `preview` == true */}
          <article className={`profile ${preview ? "preview" : ""}`}>
            <div className="wrapper">
              <div className="profile-pic-cont">
                <figure className="profile-pic img-cont">
                  <img
                    src={getImgUrl({ url: profile.profilePic?.formats.small.url, username: profile.username })}
                    alt={`A photo of ${profile.username}`}
                    className="w-full"
                  />
                </figure>
              </div>
              <div className="profile-content">
                <header className="profile-header ">
                  <h3 className="username">{profile.username}</h3>
                  {/* show twitter name if it exists */}
                  {profile.twitterUsername && (
                    <a href="https://twitter.com/miracleio" className="twitter link">
                      @{profile.twitterUsername}
                    </a>
                  )}
                  {/* show bio if it exists */}
                  {profile.bio && <p className="bio">{profile.bio}</p>}
                </header>
                <ul className="links">
                  {/* show title if it exists */}
                  {profile.title && (
                    <li className="w-icon">
                      <svg className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
                        />
                      </svg>
                      <span> {profile.title} </span>
                    </li>
                  )}
                  {/* show website url if it exists */}
                  {profile.websiteUrl && (
                    <li className="w-icon">
                      <svg className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
                        />
                      </svg>
                      <a href="http://miracleio.me" target="_blank" rel="noopener noreferrer" className="link">
                        {profile.websiteUrl}
                      </a>
                    </li>
                  )}
                </ul>
                {/* hide footer in preview mode */}
                {!preview && (
                  <footer className="grow flex items-end justify-end pt-4">
                    {/* hide link if no slug is present for the  user */}
                    {profile?.slug && (
                      <Link to={profile?.slug}>
                        <button className="cta w-icon">
                          <span>View profile</span>
                          <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                            <path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
                          </svg>
                        </button>
                      </Link>
                    )}
                  </footer>
                )}
              </div>
            </div>
          </article>
        </>
      );
    };
    export default ProfileCard;
Enter fullscreen mode Exit fullscreen mode

Before this component can work, we have to get the profile data. We can easily do that by creating a module that deals with fetching, creating and updating profiles using the Strapi API.

Set up Server Module to Connect with Strapi API

Let’s set up a module that exports a getProfiles and getProfileBySlug function. Create ./app/models/profiles.server.ts:

    // ./app/models/profiles.server.tsx

    // import types
    import { Profile, ProfileData } from "~/utils/types"

    // Strapi API URL from environment varaibles
    const strapiApiUrl = process.env.STRAPI_API_URL


    // function to fetch all profiles
    export const getProfiles = async (): Promise<Array<Profile>> => {
      const profiles = await fetch(`${strapiApiUrl}/users/?populate=profilePic`)
      let response = await profiles.json()

      return response
    }

    // function to get a single profile by it's slug
    export const getProfileBySlug = async (slug: string | undefined): Promise<Profile> => {
      const profile = await fetch(`${strapiApiUrl}/users?populate=profilePic&filters[slug]=${slug}`)
      let response = await profile.json()

      // since the request is a filter, it returns an array
      // here we return the first itm in the array
      // since the slug is unique, it'll only return one item
      return response[0]
    }
Enter fullscreen mode Exit fullscreen mode

Now, on our index page, ./app/routes/index.tsx, we’ll add the following:

    import { json } from "@remix-run/node";
    import { useLoaderData } from "@remix-run/react";

    // import profile card component
    import ProfileCard from "~/components/ProfileCard";

    // import get profiles function
    import { getProfiles } from "~/models/profiles.server";

    // loader data type definition
    type Loaderdata = {
      // this implies that the "profiles type is whatever type getProfiles resolves to"
      profiles: Awaited<ReturnType<typeof getProfiles>>;
    }

    // loader for route
    export const loader = async () => {
      return json<Loaderdata>({
        profiles: await getProfiles(),
      });
    };

    export default function Index() {
      const { profiles } = useLoaderData() as Loaderdata;
      return (
        <section className="site-section profiles-section">
          <div className="wrapper">
            <header className="section-header">
              <h2 className="text-4xl">Explore profiles</h2>
              <p>Find and connect with amazing people all over the world!</p>
            </header>
            {profiles.length > 0 ? (
              <ul className="profiles-list">
                {profiles.map((profile) => (
                  <li key={profile.id} className="profile-item">
                    <ProfileCard profile={profile} preview={false} />
                  </li>
                ))}
              </ul>
            ) : (
              <p>No profiles yet 🙂</p>
            )}{" "}
          </div>
        </section>
      );
    }
Enter fullscreen mode Exit fullscreen mode

Here, we create a loader function that calls the getProfiles function we created earlier and loads the response into our route. To use that data, we import useLoaderData and call it within Index() and obtain the profiles data by destructuring.

We should have something like this:
Index page showing profile cards

Next, we’ll create a dynamic route to display individual profiles.

Create Dynamic Profile Routes

In the ./app/routes/$slug.tsx file, we’ll use the loader params to get the slug from the route and run the getProfileBySlug() function with the value to get the profile data.

    // ./app/routes/$slug.tsx

    import { json, LoaderFunction, ActionFunction, redirect } from "@remix-run/node";
    import { useLoaderData, useActionData } from "@remix-run/react";
    import { useEffect, useState } from "react";
    import ProfileCard from "~/components/ProfileCard";
    import { getProfileBySlug } from "~/models/profiles.server";
    import { Profile } from "~/utils/types";

    // type definition of Loader data
    type Loaderdata = {
      profile: Awaited<ReturnType<typeof getProfileBySlug>>;
    };

    // loader function to get posts by slug
    export const loader: LoaderFunction = async ({ params }) => {
      return json<Loaderdata>({
        profile: await getProfileBySlug(params.slug),
      });
    };

    const Profile = () => {
      const { profile } = useLoaderData() as Loaderdata;
      const errors = useActionData();
      const [profileData, setprofileData] = useState(profile);
      const [isEditing, setIsEditing] = useState(false);

      return (
        <section className="site-section">
          <div className="wrapper flex items-center py-16 min-h-[calc(100vh-4rem)]">
            <div className="profile-cont w-full max-w-5xl m-auto">
              {profileData ? (
                <>
                  {/* Profile card with `preview` = true */}
                  <ProfileCard profile={profileData} preview={true} />
                  {/* list of actions */}
                  <ul className="actions">
                    <li className="action">
                      <button className="cta w-icon">
                        <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                          <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
                          />
                        </svg>
                        <span>Share</span>
                      </button>
                    </li>
                    <li className="action">
                      <button onClick={() => setIsEditing(!isEditing)} className="cta w-icon">
                        {!isEditing ? (
                          <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                            <path
                              strokeLinecap="round"
                              strokeLinejoin="round"
                              d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
                            />
                          </svg>
                        ) : (
                          <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                            <path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
                          </svg>
                        )}
                        <span>{!isEditing ? "Edit" : "Cancel"}</span>
                      </button>
                    </li>
                  </ul>
                </>
              ) : (
                <p className="text-center">Oops, that profile doesn't exist... yet</p>
              )}
            </div>
          </div>
        </section>
      );
    };
    export default Profile;
Enter fullscreen mode Exit fullscreen mode

Here, we also added two action buttons. The Edit button, however, is only going to be rendered when the user is signed in. We’ll get to that very soon. This is what the page should look like for this regular user:

Profile page for a single user

Awesome. Now that we have the basics of the application. Let’s build out the authentication so that we can start creating profiles.

Step 3: Authentication in Remix

We’ll be using the traditional email and password authentication for our application. For Strapi, this means we’ll be using the local authentication provider. This provider is pretty straightforward to work with.

To log in, you need to make a POST request to /api/auth/local with the body object containing an identifier and password, as you see in this example from the docs. We could also easily use other providers if we wanted. Strapi makes that easy.

On Remix’s end, however, we’ll have to do a few things with Cookies to get authentication rolling. Strapi handles the user registration and authentication work. So all we need to do in Remix is keep the user logged in, using cookies to store user data, especially the user id and JWT.

Let’s get started by building the login functionality. To do that, we need to create a login route and a form for users to enter their details. We’ll create a reusable form component for this and call it <ProfileForm>.

Create ProfileForm Component

This form will contain the fields for user login, registration, and updating profile information. To dynamically display fields for authentication (user registration and login) and edit a user profile, we will conditionally render the input fields.

Here’s an overview of how we’ll achieve that:

    <Form>
      {action != "login" && (
        <>
          {/* Profile registeration and update input fields */}
        </>
      )}
      {action != "edit" && (
        <>
          {/* User login input fields */}
        </>
      )}
    </Form>
Enter fullscreen mode Exit fullscreen mode

With this we’ll be able to:

  • For "login" action, display only login input fields like email and password
  • For "edit" action, only display ****the profile fields like username, bio, website, etc.
  • For "create" action, display both the login fields and the profile fields. This allows users to set fill in their data while creating the account.

This “dynamic” form is not crucial to our application, though. We’re just trying to create a reusable form component for all the use cases we currently have. We can as well, create separate forms for different actions.

To implement this, create a new file ./app/components/ProfileForm.tsx:

    // ./app/components/ProfileForm.tsx
    import { Form, useTransition } from "@remix-run/react";
    import { useEffect, useState } from "react";
    // custom type declarations
    import { Profile, ProfileFormProps } from "~/utils/types";
    const ProfileForm = ({ profile, onModifyData, action, errors }: ProfileFormProps) => {
      // get state of form
      const transition = useTransition();
      // state for user profile data
      const [profileData, setProfileData] = useState(profile);
      // state for user login information
      const [authData, setAuthData] = useState({ email: "", password: "" });
      // helper function to set profile data value
      const updateField = (field: object) => setProfileData((value) => ({ ...value, ...field }));
      // listen to changes to the profileData state
      // run the onModifyData() function passing the profileData to it
      //  this will snd the data to the parent component
      useEffect(() => {
        // run function if `onModifyData` is passed to the component
        if (onModifyData) {
          // depending on the action passed to the form
          // select which data to send to parent when modified
          // when action == create, send both the profile data and auth data
          if (action == "create") onModifyData({ ...profileData, ...authData });
          // when action == login, send only auth data
          else if (action == "login") onModifyData(authData);
          // send profile data by default (when action == edit)
          else onModifyData(profileData);
        }
      }, [profileData, authData]);
      return (
        <Form method={action == "edit" ? "put" : "post"} className="form">
          <fieldset disabled={transition.state == "submitting"}>
            <input value={profile?.id} type="hidden" name="id" required />
            <div className="wrapper">
              {action != "login" && (
                // profile edit input forms
                <>
                  <div className="form-group">
                    <div className="form-control">
                      <label htmlFor="username">Name</label>
                      <input
                        onChange={(e) => updateField({ username: e.target.value })}
                        value={profileData?.username}
                        id="username"
                        name="username"
                        type="text"
                        className="form-input"
                        required
                      />
                      {errors?.username ? <em className="text-red-600">{errors.username}</em> : null}
                    </div>
                    <div className="form-control">
                      <label htmlFor="twitterUsername">Twitter username</label>
                      <input
                        onChange={(e) => updateField({ twitterUsername: e.target.value })}
                        value={profileData?.twitterUsername}
                        id="twitterUsername"
                        name="twitterUsername"
                        type="text"
                        className="form-input"
                        placeholder="Without the @"
                      />
                    </div>
                  </div>
                  <div className="form-control">
                    <label htmlFor="bio">Bio</label>
                    <textarea
                      onChange={(e) => updateField({ bio: e.target.value })}
                      value={profileData?.bio}
                      name="bio"
                      id="bio"
                      cols={30}
                      rows={3}
                      className="form-textarea"
                    ></textarea>
                  </div>
                  <div className="form-group">
                    <div className="form-control">
                      <label htmlFor="job-title">Job title</label>
                      <input
                        onChange={(e) => updateField({ title: e.target.value })}
                        value={profileData?.title}
                        id="job-title"
                        name="job-title"
                        type="text"
                        className="form-input"
                      />
                      {errors?.title ? <em className="text-red-600">{errors.title}</em> : null}
                    </div>
                    <div className="form-control">
                      <label htmlFor="website">Website link</label>
                      <input
                        onChange={(e) => updateField({ websiteUrl: e.target.value })}
                        value={profileData?.websiteUrl}
                        id="website"
                        name="website"
                        type="url"
                        className="form-input"
                      />
                    </div>
                  </div>
                </>
              )}
              {action != "edit" && (
                // user auth input forms
                <>
                  <div className="form-control">
                    <label htmlFor="job-title">Email</label>
                    <input
                      onChange={(e) => setAuthData((data) => ({ ...data, email: e.target.value }))}
                      value={authData.email}
                      id="email"
                      name="email"
                      type="email"
                      className="form-input"
                      required
                    />
                    {errors?.email ? <em className="text-red-600">{errors.email}</em> : null}
                  </div>
                  <div className="form-control">
                    <label htmlFor="job-title">Password</label>
                    <input
                      onChange={(e) => setAuthData((data) => ({ ...data, password: e.target.value }))}
                      value={authData.password}
                      id="password"
                      name="password"
                      type="password"
                      className="form-input"
                    />
                    {errors?.password ? <em className="text-red-600">{errors.password}</em> : null}
                  </div>
                  {errors?.ValidationError ? <em className="text-red-600">{errors.ValidationError}</em> : null}
                  {errors?.ApplicationError ? <em className="text-red-600">{errors.ApplicationError}</em> : null}
                </>
              )}
              <div className="action-cont mt-4">
                <button className="cta"> {transition.state == "submitting" ? "Submitting" : "Submit"} </button>
              </div>
            </div>
          </fieldset>
        </Form>
      );
    };
    export default ProfileForm;
Enter fullscreen mode Exit fullscreen mode

In this component, we have the following props:

  • profile - Contains user profile information to fill in the form with.
  • onModifyData - Pass modified data to the parent depending on the action type.
  • action - determine the action of the form
  • errors - errors passed to the form from the parent (after the form has been submitted)

Next, we initialize and assign useTransition() to transition that we’ll use to get the state of the form when it’s submitted. We also set up states - profileData and authData which we use useEffect() to pass the state value to the parent component.

Finally, we return the template for the component and conditionally render the authentication input fields and other profile fields depending on the action type, as explained earlier.

Now that we have our form component ready, let’s start with building out the login functionality.

Create Sign-in Function

We’ll start by creating a function called signIn which will POST the auth details to the Strapi authentication endpoint. In ./app/models/profiles.server.ts, create a new function: signIn()

    // ./app/models/profiles.server.ts
    // import types
    import { LoginActionData, LoginResponse, Profile, ProfileData } from "~/utils/types"

    // ...

    // function to sign in
    export const signIn = async (data: LoginActionData): Promise<LoginResponse> => {
      // make POST request to Strapi Auth URL
      const profile = await fetch(`${strapiApiUrl}/auth/local`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify(data)
      })
      let response = await profile.json()

      // return login response
      return response
    }
Enter fullscreen mode Exit fullscreen mode

This function sends a login request and returns the user data if the details sent in the body match. The next thing we need to do is save the user data to the session.

Save User Session with CreateUserSession

In app/utils/session.server.ts, we’ll write a createUserSession function that accepts a user ID and a route to redirect to. It should do the following:

  • create a new session (via the cookie storage getSession function)
  • set the userId field on the session
  • redirect to the given route setting the Set-Cookie header (via the cookie storage commitSession function)

To do this, create a new file: ./app/utils/session.server.ts

    // ./app/utils/session.server.ts

    import { createCookieSessionStorage, redirect } from "@remix-run/node";
    import { LoginResponse } from "./types";
    // initialize createCookieSession
    const { getSession, commitSession, destroySession } = createCookieSessionStorage({
      cookie: {
        name: "userSession",
        // normally you want this to be `secure: true`
        // but that doesn't work on localhost for Safari
        // https://web.dev/when-to-use-local-https/
        secure: process.env.NODE_ENV === "production",
        sameSite: "lax",
        path: "/",
        maxAge: 60 * 60 * 24 * 30,
        httpOnly: true,
      }
    })
    // fucntion to save user data to session
    export const createUserSession = async (userData: LoginResponse, redirectTo: string) => {
      const session = await getSession()
      session.set("userData", userData);
      console.log({ session });
      return redirect(redirectTo, {
        headers: {
          "Set-Cookie": await commitSession(session)
        }
      })
    }
Enter fullscreen mode Exit fullscreen mode

Great. Now, we can create our login page and use our <ProfileForm> component.

Create a new file ./app/routes/sign-in.tsx:

    // ./app/routes/sign-in.tsx

    import { ActionFunction, json, redirect } from "@remix-run/node";
    import { useActionData } from "@remix-run/react";
    import ProfileForm from "~/components/ProfileForm";
    import { signIn } from "~/models/profiles.server";
    import { createUserSession } from "~/utils/session.server";
    import { LoginErrorResponse, LoginActionData } from "~/utils/types";
    export const action: ActionFunction = async ({ request }) => {
      try {
        // get request form data
        const formData = await request.formData();
        // get form values
        const identifier = formData.get("email");
        const password = formData.get("password");

        // error object
        // each error property is assigned null if it has a value
        const errors: LoginActionData = {
          identifier: identifier ? null : "Email is required",
          password: password ? null : "Password is required",
        };
        // return true if any property in the error object has a value
        const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);

        // throw the errors object if any error
        if (hasErrors) throw errors;

        // sign in user with identifier and password
        let { jwt, user, error } = await signIn({ identifier, password });

        // throw strapi error message if strapi returns an error
        if (error) throw { [error.name]: error.message };
        // create user session
        return createUserSession({ jwt, user }, "/");
      } catch (error) {
        // return error response
        return json<LoginErrorResponse>(error);
      }
    };
    const Login = () => {
      const errors = useActionData();
      return (
        <section className="site-section profiles-section">
          <div className="wrapper">
            <header className="section-header">
              <h2 className="text-4xl">Sign in </h2>
              <p>You have to log in to edit your profile</p>
            </header>
            {/* set form action to `login` and pass errors if any */}
            <ProfileForm action="login" errors={errors} />
          </div>
        </section>
      );
    };
    export default Login;
Enter fullscreen mode Exit fullscreen mode

Here, we have a action function which gets the identifier and password value using formData after the form is submitted and passes the values to signIn(). If there are no errors, the action function creates a session with the user data by returning createUserSession().

If there are errors, we throw the error and return it in the catch block. The errors are then automatically displayed on the form since we pass it as props to <ProfileForm>.

Sign in page

Now, if we sign in using the email and password of the users we created earlier in Strapi, the login request will be sent and if successful, the session will be created. You can view the cookies in the application tab of devtools.

userSession cookie

Now, all requests made will contain the cookies in the Headers:
Cookies in request headers

Also, the <ProfileForm> components can handle errors passed to it. This shows a ValidationError returned by Strapi when the user inputs an incorrect password.

Strapi validation error

Awesome. Now, we need to get the user data from the session so the user knows their signed in.

Get User Data from Cookies Session

To get the user information from the session, we’ll create a few more functions: getUserSession(request), getUserData(request) and logout() in ./app/utils/session.server.ts.

    // ./app/utils/session.server.ts
    // ...

    // get cookies from request
    const getUserSession = (request: Request) => {
      return getSession(request.headers.get("Cookie"))
    }
    // function to get user data from session
    export const getUserData = async (request: Request): Promise<LoginResponse | null> => {
      const session = await getUserSession(request)
      const userData = session.get("userData")
      console.log({userData});
      if(!userData) return null
      return userData
    }

    // function to remove user data from session, logging user out
    export const logout = async (request: Request) => {
      const session = await getUserSession(request);
      return redirect("/sign-in", {
        headers: {
          "Set-Cookie": await destroySession(session)
        }
      })
    }
Enter fullscreen mode Exit fullscreen mode

What we need to do know is to let the user know that they are signed in by showing the user name and hiding the “login” and “register” links in the site header. To do that, we’ll create a loader function in ./app/root.jsx to get the user data from the session and pass it to the <SiteHeader> component.

    // ./app/root.jsx
    // ...
    import { getUserData } from "./utils/session.server";
    type LoaderData = {
      userData: Awaited<ReturnType<typeof getUserData>>;
    };

    // loader function to get and return userdata
    export const loader: LoaderFunction = async ({ request }) => {
      return json<LoaderData>({
        userData: await getUserData(request),
      });
    };
    export default function App() {
      const { userData } = useLoaderData() as LoaderData;
      return (
        <html lang="en">
          <head>
            <Meta />
            <Links />
          </head>
          <body>
            <main className="site-main">
              {/* place site header above app outlet, pass user data as props */}
              <SiteHeader user={userData?.user} />
              {/* ... */}
            </main>
          </body>
        </html>
      );
    }
Enter fullscreen mode Exit fullscreen mode

Remember that the component conditionally displays the “Sign In”, “Register”, and “Sign out” links depending on the user data passed to the component. Now that we’ve passed the user data, we should get something like this:

SiteHeader component showing user name and sign out link

Build the Log-Out Functionality

First thing we’ll do is modify our <SiteHeader> component in ./app/components/SiteHeader.tsx. We’ll replace the Sign out link with a <Form> like this:

    // ./app/components/SiteHeader.tsx
    // import Remix's link component
    import { Form, Link, useTransition } from "@remix-run/react";
    // import type definitions
    import { Profile } from "~/utils/types";
    // component accepts `user` prop to determine if user is logged in
    const SiteHeader = ({user} : {user?: Profile | undefined}) => {
      const transition = useTransition()
      return (
        <header className="site-header">
          <div className="wrapper">
            <figure className="site-logo"><Link to="/"><h1>Profiles</h1></Link></figure>
            <nav className="site-nav">
              <ul className="links">
                {/* show sign out link if user is logged in */}
                {user?.id ?
                  <>
                    {/* link to user profile */}
                    <li>
                      <Link to={`/${user?.slug}`}> Hey, {user?.username}! </Link>
                    </li>
                    {/* Form component to send POST request to the sign out route */}
                    <Form action="/sign-out" method="post" className="link">
                      <button type="submit" disabled={transition.state != "idle"} >
                        {transition.state == "idle" ? "Sign Out" : "Loading..."}
                      </button>
                    </Form>
                  </> :
                  <>
                    {/* show sign in and register link if user is not logged in */}
                    {/* ...  */}
                  </>
                }
              </ul>
            </nav>
          </div>
        </header>
      );
    };
    export default SiteHeader;
Enter fullscreen mode Exit fullscreen mode

Then, we’ll create a ./app/routes/sign-out.tsx route and enter the following code:

    // ./app/routes/sign-out.tsx
    import { ActionFunction, LoaderFunction, redirect } from "@remix-run/node";
    import { logout } from "~/utils/session.server";

    // action to get the /sign-out request action from the sign out form
    export const action: ActionFunction = async ({ request }) => {
      return logout(request);
    };

    // loader to redirect to "/"
    export const loader: LoaderFunction = async () => {
      return redirect("/");
    };
Enter fullscreen mode Exit fullscreen mode

Now, if we click on the sign out button. It submits the form with action="/sign-out", which is handled by the action function in ./app/routes/sign-out.tsx. Then, the loader in the sign-out page redirects the user to “/” by default when the user visits that route.

Sign in and sign out

Now, let’s work on user registration.

User Registration

This is very similar to what we did for login. First, we create the register() function in ./app/models/profiles.server.ts:

    // ./app/models/profiles.server.ts
    // import types
    import slugify from "~/utils/slugify"
    import { LoginActionData, LoginResponse, Profile, ProfileData, RegisterActionData } from "~/utils/types"

    // ...

    // function to register user
    export const register = async (data: RegisterActionData): Promise<LoginResponse> => {
      // generate slug from username
      let slug = slugify(data.username?.toString())
      data.slug = slug

      // make POST request to Strapi Register Auth URL
      const profile = await fetch(`${strapiApiUrl}/auth/local/register`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify(data)
      })

      // get response from request
      let response = await profile.json()

      // return register response
      return response
    }
Enter fullscreen mode Exit fullscreen mode

Now, create a new file ./app/routes/register.jsx for the /register route:

    // ./app/routes/register.tsx
    import { ActionFunction, json } from "@remix-run/node";
    import { useActionData } from "@remix-run/react";
    import ProfileForm from "~/components/ProfileForm";
    import { register } from "~/models/profiles.server";
    import { createUserSession } from "~/utils/session.server";
    import { ErrorResponse, RegisterActionData } from "~/utils/types";
    export const action: ActionFunction = async ({ request }) => {
      try {
        // get request form data
        const formData = await request.formData();
        // get form input values
        const email = formData.get("email");
        const password = formData.get("password");
        const username = formData.get("username");
        const title = formData.get("job-title");
        const twitterUsername = formData.get("twitterUsername");
        const bio = formData.get("bio");
        const websiteUrl = formData.get("website");
        const errors: RegisterActionData = {
          email: email ? null : "Email is required",
          password: password ? null : "Password is required",
          username: username ? null : "Username is required",
          title: title ? null : "Job title is required",
        };
        const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
        if (hasErrors) throw errors;
        console.log({ email, password, username, title, twitterUsername, bio, websiteUrl });
        // function to register user with user details
        const { jwt, user, error } = await register({ email, password, username, title, twitterUsername, bio, websiteUrl });
        console.log({ jwt, user, error });
        // throw strapi error message if strapi returns an error
        if (error) throw { [error.name]: error.message };
        // create user session
        return createUserSession({ jwt, user }, "/");
      } catch (error) {
        // return error response
        return json(error);
      }
    };
    const Register = () => {
      const errors = useActionData();
      console.log({ errors });
      return (
        <section className="site-section profiles-section">
          <div className="wrapper">
            <header className="section-header">
              <h2 className="text-4xl">Register</h2>
              <p>Create a new profile</p>
            </header>
            {/* set form action to `login` and pass errors if any */}
            <ProfileForm action="create" errors={errors} />
          </div>
        </section>
      );
    };
    export default Register;
Enter fullscreen mode Exit fullscreen mode

Here’s what we should have now:
Screenshot

Now, that we can register users and login, let’s allow users to edit their profiles once logged in.

Add Reset Password Functionality

We need to configure Strapi. Let’s install nodemailer to send emails to users. Go to back to the Strapi project folder, stop the server and install the Strapi Nodemailer provider:

    npm install @strapi/provider-email-nodemailer --save
Enter fullscreen mode Exit fullscreen mode

Now, create a new file ./config/plugins.js

    module.exports = ({ env }) => ({
      email: {
        config: {
          provider: 'nodemailer',
          providerOptions: {
            host: env('SMTP_HOST', 'smtp.gmail.com'),
            port: env('SMTP_PORT', 465),
            auth: {
              user: env('GMAIL_USER'),
              pass: env('GMAIL_PASSWORD'),
            },
            // ... any custom nodemailer options
          },
          settings: {
            defaultFrom: 'threepointo.dev@gmail.com',
            defaultReplyTo: 'threepointo.dev@gmail.com',
          },
        },
      },
    });
Enter fullscreen mode Exit fullscreen mode

I’ll be using Gmail for this example; you can use any email provider of your choice. You can find instructions on the Strapi Documentattion.

Add the environment variables in the ./.env file:

SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
GMAIL_USER=threepointo.dev@gmail.com
GMAIL_PASSWORD=<generated-pass>
Enter fullscreen mode Exit fullscreen mode

You can find out more on how to generate Gmail passwords that work with Nodemailer.

Start the server:

    yarn develop
Enter fullscreen mode Exit fullscreen mode

In the Strapi admin dashboard, navigate to SETTINGS > USERS & PERMISSIONS PLUGIN > ROLES > PUBLIC > USERS-PERMISSIONS, enable the forgotPassword and resetPassword actions.

Enable forgot-password and reset-password actions for public roles in strapi admin

We can also modify the email template for reset password in Strapi. Navigate to:

Next, we’ll head back to our Remix project and add new functions for forgot and reset password.

Add 'Forgot Password' Functionality

Create a new function sendResetMail in ./app/models/profiles.server.ts:

    // ./app/models/profiles.server.ts
    // ...

    // function to send password reset email
    export const sendResetMail = async (email: string | File | null | undefined) => {
      const response = await (await fetch(`${strapiApiUrl}/auth/forgot-password`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ email })
      })).json()
      return response
    }
Enter fullscreen mode Exit fullscreen mode

Now, create a forgot password page, create a new file ./app/routes/forgot-password:

    import { ActionFunction, json } from "@remix-run/node";
    import { Form, useActionData, useTransition } from "@remix-run/react";
    import { sendResetMail } from "~/models/profiles.server";

    // action function to get form values and run reset mail function
    export const action: ActionFunction = async ({ request }) => {
      const formData = await request.formData();
      const email = formData.get("email");
      const response = await sendResetMail(email);
      return json(response);
    };
    const ForgotPass = () => {
      const transition = useTransition();
      const data = useActionData();
      return (
        <section className="site-section profiles-section">
          <div className="wrapper">
            <header className="section-header">
              <h2 className="text-4xl">Forgot password</h2>
              <p>Click the button below to send the reset link to your registerd email</p>
            </header>
            <Form method="post" className="form">
              <div className="wrapper">
                <p>{data?.ok ? "Link sent! Check your mail. Can't find it in the inbox? Check Spam" : ""}</p>
                <div className="form-control">
                  <label htmlFor="email">Email</label>
                  <input id="email" name="email" type="email" className="form-input" required />
                </div>
                <div className="action-cont mt-4">
                  <button className="cta"> {transition.state == "submitting" ? "Sending" : "Send link"} </button>
                </div>
              </div>
            </Form>
          </div>
        </section>
      );
    };
    export default ForgotPass;
Enter fullscreen mode Exit fullscreen mode

Here’s what the page looks like:

Forgot password page

Add 'Reset Password' Functionality

First, create a new resetPass function in ./app/models/profiles.session.ts

    // ./app/models/profiles.server.ts
    // ...

    // function to reset password
    export const resetPass = async ({ password, passwordConfirmation, code }: { password: File | string | null | undefined, passwordConfirmation: File | string | null | undefined, code: File | string | null | undefined }) => {
      const response = await (await fetch(`${strapiApiUrl}/auth/reset-password`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          password,
          passwordConfirmation,
          code
        })
      })).json()
      return response
    }
Enter fullscreen mode Exit fullscreen mode

This function sends a request to /api/auth/reset-password with the password, confirmation and code which is sent to the user’s mail. Create a new reset password page to send the request with the password and code, ./app/routes/reset-password.tsx

    // ./app/routes/reset-password.tsx

    import { ActionFunction, json, LoaderFunction, redirect } from "@remix-run/node";
    import { Form, useActionData, useLoaderData, useTransition } from "@remix-run/react";
    import { resetPass } from "~/models/profiles.server";
    type LoaderData = {
      code: string | undefined;
    };
    // get code from URL parameters
    export const loader: LoaderFunction = async ({ request }) => {
      const url = new URL(request.url);
      const code = url.searchParams.get("code");
      // take user to homepage if there's no code in the url
      if (!code) return redirect("/");
      return json<LoaderData>({
        code: code,
      });
    };
    // get password and code and send reset password request
    export const action: ActionFunction = async ({ request }) => {
      const formData = await request.formData();
      const code = formData.get("code");
      const password = formData.get("password");
      const passwordConfirmation = formData.get("confirmPassword");
      const response = await resetPass({ password, passwordConfirmation, code });
      // return error is passwords don't match
      if (password != passwordConfirmation) return json({ confirmPassword: "Passwords should match" });
      return json(response);
    };
    const ResetPass = () => {
      const transition = useTransition();
      const error = useActionData();
      const { code } = useLoaderData() as LoaderData;
      return (
        <section className="site-section profiles-section">
          <div className="wrapper">
            <header className="section-header">
              <h2 className="text-4xl">Reset password</h2>
              <p>Enter your new password</p>
            </header>
            <Form method="post" className="form">
              <input value={code} type="hidden" id="code" name="code" required />
              <div className="wrapper">
                <div className="form-control">
                  <label htmlFor="job-title">Password</label>
                  <input id="password" name="password" type="password" className="form-input" required />
                </div>
                <div className="form-control">
                  <label htmlFor="job-title">Confirm password</label>
                  <input id="confirmPassword" name="confirmPassword" type="password" className="form-input" required />
                  {error?.confirmPassword ? <em className="text-red-600">{error.confirmPassword}</em> : null}
                </div>
                <div className="action-cont mt-4">
                  <button className="cta"> {transition.state == "submitting" ? "Sending" : "Reset password"} </button>
                </div>
              </div>
            </Form>
          </div>
        </section>
      );
    };
    export default ResetPass;
Enter fullscreen mode Exit fullscreen mode

See it in action:

Screenshot

Step 4: Add 'Edit Profile' Functionality for Authenticated Users

First, we create a new updateProfile() function which accepts the user input and JWT token as arguments. Back in ./app/models/profiles.server.ts add the updateProfile() function:

    // ./app/models/profiles.server.ts
    // import types
    import slugify from "~/utils/slugify"
    import { LoginActionData, LoginResponse, Profile, ProfileData, RegisterActionData } from "~/utils/types"

    // ...

    // function to update a profile
    export const updateProfile = async (data: ProfileData, token: string | undefined): Promise<Profile> => {
      // get id from data
      const { id } = data
      // PUT request to update data
      const profile = await fetch(`${strapiApiUrl}/users/${id}`, {
        method: "PUT",
        headers: {
          "Content-Type": "application/json",
          // set the auth token to the user's jwt
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify(data)
      })
      let response = await profile.json()
      return response
    }
Enter fullscreen mode Exit fullscreen mode

Here, we send a request to update the user data with the Authorization set in the headers. We’ll pass the token to the updateProfile function which will be obtained from the user session.

Back in our ./app/routes/$slug.tsx page, we need an action to call this function and pass the necessary arguments. We’ll add our <ProfileForm> component and set the action to "edit". This form will only be rendered if the signed in user data is the same as the user data on the current profile route. We’ll also show the edit button and the <ProfileForm> if the profile id is equal to the signed in user and add an action function to handle the form submission and validation.

    // ./app/routes/$slug.tsx
    import { json, LoaderFunction, ActionFunction, redirect } from "@remix-run/node";
    import { useLoaderData, useActionData } from "@remix-run/react";
    import { useEffect, useState } from "react";
    import { updateProfile } from "~/models/profiles.server";
    import { getProfileBySlug } from "~/models/profiles.server";
    import { getUserData } from "~/utils/session.server";
    import { Profile } from "~/utils/types";
    import ProfileCard from "~/components/ProfileCard";
    import ProfileForm from "~/components/ProfileForm";
    // type definition of Loader data
    type Loaderdata = {
      userData: Awaited<ReturnType<typeof getUserData>>;
      profile: Awaited<ReturnType<typeof getProfileBySlug>>;
    };
    // action data type
    type EditActionData =
      | {
          id: string | null;
          username: string | null;
          title: string | null;
        }
      | undefined;
    // loader function to get posts by slug
    export const loader: LoaderFunction = async ({ params, request }) => {
      return json<Loaderdata>({
        userData: await getUserData(request),
        profile: await getProfileBySlug(params.slug),
      });
    };
    // action to handle form submission
    export const action: ActionFunction = async ({ request }) => {
      // get user data
      const data = await getUserData(request)
      // get request form data
      const formData = await request.formData();
      // get form values
      const id = formData.get("id");
      const username = formData.get("username");
      const twitterUsername = formData.get("twitterUsername");
      const bio = formData.get("bio");
      const title = formData.get("job-title");
      const websiteUrl = formData.get("website");

      // error object
      // each error property is assigned null if it has a value
      const errors: EditActionData = {
        id: id ? null : "Id is required",
        username: username ? null : "username is required",
        title: title ? null : "title is required",
      };
      // return true if any property in the error object has a value
      const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
      // return the error object
      if (hasErrors) return json<EditActionData>(errors);
      // run the update profile function
      // pass the user jwt to the function
      await updateProfile({ id, username, twitterUsername, bio, title, websiteUrl }, data?.jwt);
      // redirect users to home page
      return null;
    };
    const Profile = () => {
      const { profile, userData } = useLoaderData() as Loaderdata;
      const errors = useActionData();
      const [profileData, setprofileData] = useState(profile);
      const [isEditing, setIsEditing] = useState(false);
      console.log({ userData, profile });

      return (
        <section className="site-section">
          <div className="wrapper flex items-center py-16 min-h-[calc(100vh-4rem)]">
            <div className="profile-cont w-full max-w-5xl m-auto">
              {profileData ? (
                <>
                  {/* Profile card with `preview` = true */}
                  <ProfileCard profile={profileData} preview={true} />
                  {/* list of actions */}
                  <ul className="actions">
                    <li className="action">
                      <button className="cta w-icon">
                        <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                          <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
                          />
                        </svg>
                        <span>Share</span>
                      </button>
                    </li>
                    {userData?.user?.id == profile.id && (
                      <li className="action">
                        <button onClick={() => setIsEditing(!isEditing)} className="cta w-icon">
                          {!isEditing ? (
                            <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                              <path
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
                              />
                            </svg>
                          ) : (
                            <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                              <path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
                            </svg>
                          )}
                          <span>{!isEditing ? "Edit" : "Cancel"}</span>
                        </button>
                      </li>
                    )}
                  </ul>
                </>
              ) : (
                <p className="text-center">Oops, that profile doesn't exist... yet</p>
              )}
              {/* display dynamic form component when user clicks on edit */}
              {userData?.user?.id == profile?.id && isEditing && (
                <ProfileForm errors={errors} profile={profile} action={"edit"} onModifyData={(value: Profile) => setprofileData(value)} />
              )}
            </div>
          </div>
        </section>
      );
    };
    export default Profile;
Enter fullscreen mode Exit fullscreen mode

Now, when we’re logged in as a particular user, we can edit that user data as shown here:
Screenshot

Conclusion

So far, we have seen how we can build a Remix application with authentication using Strapi as a Headless CMS. Let’s summarize what we’ve been able to achieve so far.

  • We created and set up Strapi, configured the User collection type, and modified permissions for public and authenticated users.
  • We created a new Remix application with Tailwind for styling.
  • In Strapi, we used the local provider (email and password) for authentication.
  • In Remix, we used cookies to store the user data and JWT, allowing us to make authenticated requests to Strapi.
  • We added forgot-password and reset-password functionalities by configuring Strapi Email plugin.

I’m sure you’ve been able to pick up one or two new things from this tutorial. If you’re stuck somewhere, the Strapi and Remix application source code are available on GitHub and listed in the resources section.

Resources

Here are a few articles I think will be helpful:

As promised, the code for the Remix frontend and Strapi backend is available on GitHub:

Also, here’s the live example hosted on Netlify.

Happy Coding!

Top comments (0)

🌚 Life is too short to browse without dark mode